Compare commits
6 Commits
740607e06d
...
55f45e9861
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f45e9861 | ||
|
|
ffd074499a | ||
|
|
125938fba6 | ||
|
|
cd25cf61ca | ||
|
|
d9c9f6e541 | ||
|
|
acc5e12e3d |
232
.beads/.br_history/issues.20260212_161438.jsonl
Normal file
232
.beads/.br_history/issues.20260212_161438.jsonl
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-3hjh
|
bd-1cjx
|
||||||
|
|||||||
245
docs/diagrams/01-human-flow-map.excalidraw
Normal file
245
docs/diagrams/01-human-flow-map.excalidraw
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 300, "y": 15, "text": "Human User Flow Map", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 220, "y": 53, "text": "15 human workflows mapped to lore commands. Arrows show data dependency.", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "col-trigger", "x": 60, "y": 80, "text": "TRIGGER (Problem)", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "col-flow", "x": 400, "y": 80, "text": "COMMAND FLOW", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "col-gap", "x": 880, "y": 80, "text": "GAP", "fontSize": 16, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-daily", "x": 20, "y": 110, "width": 960, "height": 190,
|
||||||
|
"backgroundColor": "#dbe4ff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#4a9eed", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-daily-label", "x": 30, "y": 115, "text": "Daily Operations", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h1-trigger", "x": 30, "y": 140, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H1: Standup prep\n\"What moved overnight?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a1", "x": 230, "y": 165, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-cmd1", "x": 280, "y": 145, "width": 90, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "sync -q", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a2", "x": 370, "y": 165, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-cmd2", "x": 400, "y": 145, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues --since 1d", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a3", "x": 540, "y": 165, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-cmd3", "x": 570, "y": 145, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs --since 1d", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a4", "x": 700, "y": 165, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-cmd4", "x": 730, "y": 145, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @me", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h1-a5", "x": 830, "y": 165, "width": 40, "height": 0,
|
||||||
|
"points": [[0,0],[40,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h1-gap", "x": 870, "y": 140, "width": 100, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No @me\nNo feed", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h3-trigger", "x": 30, "y": 210, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H3: Incident\n\"Deploy broke prod\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h3-a1", "x": 230, "y": 235, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h3-cmd1", "x": 280, "y": 215, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "timeline deploy", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h3-a2", "x": 410, "y": 235, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h3-cmd2", "x": 440, "y": 215, "width": 160, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "search deploy --mr", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h3-a3", "x": 600, "y": 235, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h3-cmd3", "x": 630, "y": 215, "width": 110, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs <iid>", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h3-a4", "x": 740, "y": 235, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h3-cmd4", "x": 770, "y": 215, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who --overlap", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-planning", "x": 20, "y": 310, "width": 960, "height": 190,
|
||||||
|
"backgroundColor": "#d3f9d8", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#22c55e", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-planning-label", "x": 30, "y": 315, "text": "Planning & Assignment", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h2-trigger", "x": 30, "y": 340, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H2: Sprint plan\n\"What's ready to pick?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h2-a1", "x": 230, "y": 365, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h2-cmd1", "x": 280, "y": 345, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues -s opened -l ready", "fontSize": 13 } },
|
||||||
|
{ "type": "arrow", "id": "h2-a2", "x": 450, "y": 365, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h2-cmd2", "x": 480, "y": 345, "width": 150, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues --has-due", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h2-a3", "x": 630, "y": 365, "width": 230, "height": 0,
|
||||||
|
"points": [[0,0],[230,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h2-gap", "x": 860, "y": 340, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No\n--no-assignee", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h8-trigger", "x": 30, "y": 410, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H8: Assign work\n\"Who has bandwidth?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h8-a1", "x": 230, "y": 435, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h8-cmd1", "x": 280, "y": 415, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @alice", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h8-a2", "x": 400, "y": 435, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h8-cmd2", "x": 430, "y": 415, "width": 110, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @bob", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h8-a3", "x": 540, "y": 435, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h8-cmd3", "x": 570, "y": 415, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @carol...", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h8-a4", "x": 690, "y": 435, "width": 170, "height": 0,
|
||||||
|
"points": [[0,0],[170,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h8-gap", "x": 860, "y": 410, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No team\nworkload view", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-investigation", "x": 20, "y": 510, "width": 960, "height": 260,
|
||||||
|
"backgroundColor": "#fff3bf", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#f59e0b", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-invest-label", "x": 30, "y": 515, "text": "Investigation & Understanding", "fontSize": 14, "strokeColor": "#b45309" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h7-trigger", "x": 30, "y": 540, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H7: Why this way?\n\"Understand a decision\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h7-a1", "x": 230, "y": 565, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h7-cmd1", "x": 280, "y": 545, "width": 160, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "search \"rationale\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h7-a2", "x": 440, "y": 565, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h7-cmd2", "x": 470, "y": 545, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "timeline --depth 2", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h7-a3", "x": 610, "y": 565, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h7-cmd3", "x": 640, "y": 545, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues 234", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h7-a4", "x": 740, "y": 565, "width": 120, "height": 0,
|
||||||
|
"points": [[0,0],[120,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h7-gap", "x": 860, "y": 540, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No per-note\nsearch", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h11-trigger", "x": 30, "y": 610, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H11: Bug lifecycle\n\"Why does #321 reopen?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h11-a1", "x": 230, "y": 635, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h11-cmd1", "x": 280, "y": 615, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues 321", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h11-a2", "x": 400, "y": 635, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h11-cmd2", "x": 430, "y": 615, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "timeline ???", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h11-a3", "x": 560, "y": 635, "width": 300, "height": 0,
|
||||||
|
"points": [[0,0],[300,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h11-gap", "x": 860, "y": 610, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No entity\ntimeline", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h14-trigger", "x": 30, "y": 680, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H14: Prior art?\n\"Was this tried before?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h14-a1", "x": 230, "y": 705, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h14-cmd1", "x": 280, "y": 685, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "search \"memory leak\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h14-a2", "x": 450, "y": 705, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h14-cmd2", "x": 480, "y": 685, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs --closed?", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h14-a3", "x": 600, "y": 705, "width": 260, "height": 0,
|
||||||
|
"points": [[0,0],[260,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h14-gap", "x": 860, "y": 680, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No --state\non search", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-people", "x": 20, "y": 780, "width": 960, "height": 190,
|
||||||
|
"backgroundColor": "#e5dbff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#8b5cf6", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-people-label", "x": 30, "y": 785, "text": "People & Expertise", "fontSize": 14, "strokeColor": "#7048e8" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h4-trigger", "x": 30, "y": 810, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H4: Review prep\n\"Context for MR !789\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h4-a1", "x": 230, "y": 835, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h4-cmd1", "x": 280, "y": 815, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs 789", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h4-a2", "x": 380, "y": 835, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h4-cmd2", "x": 410, "y": 815, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who src/auth/", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h4-a3", "x": 530, "y": 835, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h4-cmd3", "x": 560, "y": 815, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "search \"auth\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h4-a4", "x": 690, "y": 835, "width": 170, "height": 0,
|
||||||
|
"points": [[0,0],[170,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h4-gap", "x": 860, "y": 810, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No MR file\nlist output", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "h6-trigger", "x": 30, "y": 880, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "H6: Find reviewer\n\"Who should review?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h6-a1", "x": 230, "y": 905, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h6-cmd1", "x": 280, "y": 885, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who src/auth/", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h6-a2", "x": 410, "y": 905, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h6-cmd2", "x": 440, "y": 885, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who src/pay/", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h6-a3", "x": 580, "y": 905, "width": 30, "height": 0,
|
||||||
|
"points": [[0,0],[30,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h6-cmd3", "x": 610, "y": 885, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who @candidate", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "h6-a4", "x": 750, "y": 905, "width": 110, "height": 0,
|
||||||
|
"points": [[0,0],[110,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "h6-gap", "x": 860, "y": 880, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No multi-\npath query", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "callout-1", "x": 30, "y": 990, "text": "Pattern: Most human flows require 3-5 serial commands. Average gap rate: 73% of flows have at least one.", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "callout-2", "x": 30, "y": 1015, "text": "Top optimization: Composite commands (activity feed, team workload) would reduce multi-command flows by ~40%.", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
{ "type": "text", "id": "callout-3", "x": 30, "y": 1040, "text": "Top missing data: MR file changes and entity references are stored but invisible to CLI users.", "fontSize": 14, "strokeColor": "#ef4444" }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/01-human-flow-map.png
Normal file
BIN
docs/diagrams/01-human-flow-map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
204
docs/diagrams/02-agent-flow-map.excalidraw
Normal file
204
docs/diagrams/02-agent-flow-map.excalidraw
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 320, "y": 15, "text": "AI Agent Flow Map", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 180, "y": 53, "text": "15 agent automation workflows. Agents need structured JSON (-J), exit codes, and field selection.", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "col-trigger", "x": 60, "y": 80, "text": "TRIGGER (Agent Goal)", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "col-flow", "x": 400, "y": 80, "text": "COMMAND PIPELINE", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "col-gap", "x": 880, "y": 80, "text": "BLOCKED BY", "fontSize": 16, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-context", "x": 20, "y": 110, "width": 960, "height": 200,
|
||||||
|
"backgroundColor": "#e5dbff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#8b5cf6", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-context-label", "x": 30, "y": 115, "text": "Context Gathering (pre-action)", "fontSize": 14, "strokeColor": "#7048e8" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a1-trigger", "x": 30, "y": 140, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A1: Pre-edit context\nAbout to modify files", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a1-a1", "x": 230, "y": 165, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a1-cmd1", "x": 280, "y": 145, "width": 80, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J health", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a1-a2", "x": 360, "y": 165, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a1-cmd2", "x": 380, "y": 145, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J who src/auth/", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a1-a3", "x": 520, "y": 165, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a1-cmd3", "x": 540, "y": 145, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J search \"auth\" -n 10", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a1-a4", "x": 710, "y": 165, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a1-cmd4", "x": 730, "y": 145, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J who --overlap", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a6-trigger", "x": 30, "y": 210, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A6: Auto-assign reviewers\nBased on file expertise", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a6-a1", "x": 230, "y": 235, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a6-cmd1", "x": 280, "y": 215, "width": 100, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J mrs 456", "fontSize": 14 } },
|
||||||
|
{ "type": "text", "id": "a6-block", "x": 390, "y": 218, "text": "file list not\nin response!", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
{ "type": "arrow", "id": "a6-a2", "x": 380, "y": 245, "width": 480, "height": -10,
|
||||||
|
"points": [[0,0],[480,-10]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a6-gap", "x": 860, "y": 210, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "MR files\nnot exposed", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-report", "x": 20, "y": 320, "width": 960, "height": 200,
|
||||||
|
"backgroundColor": "#d3f9d8", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#22c55e", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-report-label", "x": 30, "y": 325, "text": "Reporting & Synthesis", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a3-trigger", "x": 30, "y": 350, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A3: Sprint status report\n7 queries for 1 report", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a3-a1", "x": 230, "y": 375, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd1", "x": 280, "y": 352, "width": 100, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues -s closed", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd2", "x": 390, "y": 352, "width": 100, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues --status", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd3", "x": 500, "y": 352, "width": 100, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs -s merged", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd4", "x": 610, "y": 352, "width": 80, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "mrs -s open", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd5", "x": 700, "y": 352, "width": 80, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "count x2", "fontSize": 12 } },
|
||||||
|
{ "type": "rectangle", "id": "a3-cmd6", "x": 790, "y": 352, "width": 60, "height": 36,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "who", "fontSize": 12 } },
|
||||||
|
{ "type": "arrow", "id": "a3-agap", "x": 850, "y": 370, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a3-gap", "x": 860, "y": 350, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No summary\ncommand", "fontSize": 14 } },
|
||||||
|
{ "type": "text", "id": "a3-note", "x": 280, "y": 395, "text": "7 sequential API calls for one report. A `lore summary` could reduce to 1.", "fontSize": 12, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a7-trigger", "x": 30, "y": 430, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A7: Incident timeline\nPostmortem reconstruction", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a7-a1", "x": 230, "y": 455, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a7-cmd1", "x": 280, "y": 435, "width": 190, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J timeline --depth 2", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a7-a2", "x": 470, "y": 455, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a7-cmd2", "x": 490, "y": 435, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J search --since 3d", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a7-a3", "x": 660, "y": 455, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a7-cmd3", "x": 680, "y": 435, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J mrs -s merged", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-discover", "x": 20, "y": 530, "width": 960, "height": 200,
|
||||||
|
"backgroundColor": "#fff3bf", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#f59e0b", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-discover-label", "x": 30, "y": 535, "text": "Discovery & Correlation", "fontSize": 14, "strokeColor": "#b45309" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a5-trigger", "x": 30, "y": 560, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A5: PR description\nFind related issues to link", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a5-a1", "x": 230, "y": 585, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a5-cmd1", "x": 280, "y": 565, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J search keywords", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a5-a2", "x": 450, "y": 585, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a5-cmd2", "x": 470, "y": 565, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J issues --fields iid,url", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a5-a3", "x": 650, "y": 585, "width": 210, "height": 0,
|
||||||
|
"points": [[0,0],[210,0]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a5-gap", "x": 860, "y": 560, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No refs\nquery", "fontSize": 14 } },
|
||||||
|
{ "type": "text", "id": "a5-note", "x": 280, "y": 612, "text": "Agent can't ask \"which issues does MR !456 close?\" -- entity_references data exists but isn't queryable.", "fontSize": 12, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a11-trigger", "x": 30, "y": 640, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A11: Knowledge graph\nMap entity relationships", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a11-a1", "x": 230, "y": 665, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a11-cmd1", "x": 280, "y": 645, "width": 140, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J search -n 30", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a11-a2", "x": 420, "y": 665, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a11-cmd2", "x": 440, "y": 645, "width": 190, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J timeline --depth 2", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a11-a3", "x": 630, "y": 665, "width": 230, "height": 0,
|
||||||
|
"points": [[0,0],[230,0]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a11-gap", "x": 860, "y": 640, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No refs\nquery", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-maint", "x": 20, "y": 740, "width": 960, "height": 140,
|
||||||
|
"backgroundColor": "#dbe4ff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#4a9eed", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-maint-label", "x": 30, "y": 745, "text": "Maintenance & Cleanup", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a9-trigger", "x": 30, "y": 770, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A9: Stale issue cleanup\nWeekly backlog hygiene", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a9-a1", "x": 230, "y": 795, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a9-cmd1", "x": 280, "y": 775, "width": 200, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J issues --sort updated --asc", "fontSize": 12 } },
|
||||||
|
{ "type": "arrow", "id": "a9-a2", "x": 480, "y": 795, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a9-cmd2", "x": 500, "y": 775, "width": 120, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "filter client-side", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a9-a3", "x": 620, "y": 795, "width": 240, "height": 0,
|
||||||
|
"points": [[0,0],[240,0]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a9-gap", "x": 860, "y": 770, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No --before\nNo offset", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "a15-trigger", "x": 30, "y": 840, "width": 200, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "A15: Conflict detect\n\"Safe to start work?\"", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a15-a1", "x": 230, "y": 865, "width": 50, "height": 0,
|
||||||
|
"points": [[0,0],[50,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a15-cmd1", "x": 280, "y": 845, "width": 110, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J issues 123", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a15-a2", "x": 390, "y": 865, "width": 20, "height": 0,
|
||||||
|
"points": [[0,0],[20,0]], "endArrowhead": "arrow" },
|
||||||
|
{ "type": "rectangle", "id": "a15-cmd2", "x": 410, "y": 845, "width": 130, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "-J who --overlap", "fontSize": 14 } },
|
||||||
|
{ "type": "arrow", "id": "a15-a3", "x": 540, "y": 865, "width": 320, "height": 0,
|
||||||
|
"points": [[0,0],[320,0]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "rectangle", "id": "a15-gap", "x": 860, "y": 840, "width": 110, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "No refs +\n--state", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "callout-1", "x": 30, "y": 910, "text": "Agent-specific pain: Agents always use -J and --fields minimal for token efficiency. Every extra query burns tokens.", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "callout-2", "x": 30, "y": 935, "text": "Biggest ROI: `lore refs` command would unblock A5, A11, A12, A15 instantly. Data already exists in entity_references table.", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
{ "type": "text", "id": "callout-3", "x": 30, "y": 960, "text": "Token waste: Sprint report (A3) requires 7 calls. A composite `lore summary` could save ~85% of tokens.", "fontSize": 14, "strokeColor": "#ef4444" }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/02-agent-flow-map.png
Normal file
BIN
docs/diagrams/02-agent-flow-map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
203
docs/diagrams/03-command-coverage.excalidraw
Normal file
203
docs/diagrams/03-command-coverage.excalidraw
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 280, "y": 15, "text": "Command Coverage Heatmap", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 220, "y": 53, "text": "Which commands serve which workflows? Darker = more essential to that flow.", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "col-issues", "x": 260, "y": 85, "text": "issues", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-mrs", "x": 330, "y": 85, "text": "mrs", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-search", "x": 390, "y": 85, "text": "search", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-who", "x": 465, "y": 85, "text": "who", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-timeline", "x": 520, "y": 85, "text": "timeline", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-sync", "x": 600, "y": 85, "text": "sync", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-count", "x": 660, "y": 85, "text": "count", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-status", "x": 720, "y": 85, "text": "status", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "col-missing", "x": 790, "y": 85, "text": "MISSING?", "fontSize": 14, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-human", "x": 15, "y": 108, "text": "HUMAN FLOWS", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h1-label", "x": 15, "y": 135, "text": "H1 Standup prep", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h1-issues", "x": 255, "y": 130, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h1-mrs", "x": 325, "y": 130, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h1-who", "x": 460, "y": 130, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h1-sync", "x": 595, "y": 130, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h1-gap", "x": 780, "y": 135, "text": "activity feed", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h2-label", "x": 15, "y": 170, "text": "H2 Sprint planning", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h2-issues", "x": 255, "y": 165, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h2-count", "x": 655, "y": 165, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h2-gap", "x": 780, "y": 170, "text": "--no-assignee", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h3-label", "x": 15, "y": 205, "text": "H3 Incident response", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h3-mrs", "x": 325, "y": 200, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h3-search", "x": 390, "y": 200, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h3-who", "x": 460, "y": 200, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h3-timeline", "x": 525, "y": 200, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h3-sync", "x": 595, "y": 200, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h4-label", "x": 15, "y": 240, "text": "H4 Code review prep", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h4-mrs", "x": 325, "y": 235, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h4-search", "x": 390, "y": 235, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h4-who", "x": 460, "y": 235, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h4-timeline", "x": 525, "y": 235, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h4-gap", "x": 780, "y": 240, "text": "MR file list", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h5-label", "x": 15, "y": 275, "text": "H5 Onboarding", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h5-issues", "x": 255, "y": 270, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h5-mrs", "x": 325, "y": 270, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h5-search", "x": 390, "y": 270, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h5-who", "x": 460, "y": 270, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h5-timeline", "x": 525, "y": 270, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h6-label", "x": 15, "y": 310, "text": "H6 Find reviewer", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h6-who", "x": 460, "y": 305, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h6-gap", "x": 780, "y": 310, "text": "multi-path who", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h7-label", "x": 15, "y": 345, "text": "H7 Why was this built?", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h7-issues", "x": 255, "y": 340, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h7-mrs", "x": 325, "y": 340, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h7-search", "x": 390, "y": 340, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h7-timeline", "x": 525, "y": 340, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h7-gap", "x": 780, "y": 345, "text": "per-note search", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h8-label", "x": 15, "y": 380, "text": "H8 Team workload", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h8-who", "x": 460, "y": 375, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h8-gap", "x": 780, "y": 380, "text": "team view", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h9-label", "x": 15, "y": 415, "text": "H9 Release notes", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h9-issues", "x": 255, "y": 410, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h9-mrs", "x": 325, "y": 410, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h9-gap", "x": 780, "y": 415, "text": "mrs --milestone", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h10-label", "x": 15, "y": 450, "text": "H10 Stale issues", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h10-issues", "x": 255, "y": 445, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h10-gap", "x": 780, "y": 450, "text": "--updated-before", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h11-label", "x": 15, "y": 485, "text": "H11 Bug lifecycle", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h11-issues", "x": 255, "y": 480, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h11-timeline", "x": 525, "y": 480, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h11-gap", "x": 780, "y": 485, "text": "entity timeline", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h12-label", "x": 15, "y": 520, "text": "H12 Who broke tests?", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h12-search", "x": 390, "y": 515, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h12-who", "x": 460, "y": 515, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h13-label", "x": 15, "y": 555, "text": "H13 Feature tracking", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h13-issues", "x": 255, "y": 550, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h13-mrs", "x": 325, "y": 550, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h13-timeline", "x": 525, "y": 550, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h14-label", "x": 15, "y": 590, "text": "H14 Prior art check", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h14-search", "x": 390, "y": 585, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "h14-timeline", "x": 525, "y": 585, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h14-gap", "x": 780, "y": 590, "text": "--state on search", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "h15-label", "x": 15, "y": 625, "text": "H15 My discussions", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "h15-who", "x": 460, "y": 620, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "h15-gap", "x": 780, "y": 625, "text": "participant filter", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "divider", "x": 10, "y": 655, "width": 910, "height": 2, "backgroundColor": "#dee2e6", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-agent", "x": 15, "y": 668, "text": "AI AGENT FLOWS", "fontSize": 14, "strokeColor": "#7048e8" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a1-label", "x": 15, "y": 695, "text": "A1 Pre-edit context", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a1-mrs", "x": 325, "y": 690, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a1-search", "x": 390, "y": 690, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a1-who", "x": 460, "y": 690, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a2-label", "x": 15, "y": 730, "text": "A2 Auto-triage", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a2-issues", "x": 255, "y": 725, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a2-search", "x": 390, "y": 725, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a2-who", "x": 460, "y": 725, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a2-gap", "x": 780, "y": 730, "text": "detail --fields", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a3-label", "x": 15, "y": 765, "text": "A3 Sprint report", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a3-issues", "x": 255, "y": 760, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a3-mrs", "x": 325, "y": 760, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a3-who", "x": 460, "y": 760, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a3-count", "x": 655, "y": 760, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a3-gap", "x": 780, "y": 765, "text": "summary cmd", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a4-label", "x": 15, "y": 800, "text": "A4 Prior art", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a4-search", "x": 390, "y": 795, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a4-timeline", "x": 525, "y": 795, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a4-gap", "x": 780, "y": 800, "text": "per-note search", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a5-label", "x": 15, "y": 835, "text": "A5 PR description", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a5-issues", "x": 255, "y": 830, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a5-search", "x": 390, "y": 830, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a5-gap", "x": 780, "y": 835, "text": "entity refs query", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a6-label", "x": 15, "y": 870, "text": "A6 Reviewer assign", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a6-mrs", "x": 325, "y": 865, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a6-who", "x": 460, "y": 865, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a6-gap", "x": 780, "y": 870, "text": "MR file list", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a7-label", "x": 15, "y": 905, "text": "A7 Incident timeline", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a7-mrs", "x": 325, "y": 900, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a7-search", "x": 390, "y": 900, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a7-timeline", "x": 525, "y": 900, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a8-label", "x": 15, "y": 940, "text": "A8 Cross-project", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a8-search", "x": 390, "y": 935, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a8-timeline", "x": 525, "y": 935, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a8-gap", "x": 780, "y": 940, "text": "group by project", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a9-label", "x": 15, "y": 975, "text": "A9 Stale cleanup", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a9-issues", "x": 255, "y": 970, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a9-search", "x": 390, "y": 970, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a9-gap", "x": 780, "y": 975, "text": "--updated-before", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a10-label", "x": 15, "y": 1010, "text": "A10 Review context", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a10-mrs", "x": 325, "y": 1005, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a10-who", "x": 460, "y": 1005, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a10-gap", "x": 780, "y": 1010, "text": "MR file list", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a11-label", "x": 15, "y": 1045, "text": "A11 Knowledge graph", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a11-search", "x": 390, "y": 1040, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a11-timeline", "x": 525, "y": 1040, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a11-gap", "x": 780, "y": 1045, "text": "entity refs query", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a12-label", "x": 15, "y": 1080, "text": "A12 Release check", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a12-issues", "x": 255, "y": 1075, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a12-mrs", "x": 325, "y": 1075, "width": 50, "height": 28, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a12-who", "x": 460, "y": 1075, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a12-gap", "x": 780, "y": 1080, "text": "mrs --milestone", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a13-label", "x": 15, "y": 1115, "text": "A13 What changed?", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a13-issues", "x": 255, "y": 1110, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a13-mrs", "x": 325, "y": 1110, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a13-gap", "x": 780, "y": 1115, "text": "state-change filter", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a14-label", "x": 15, "y": 1150, "text": "A14 Meeting prep", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a14-issues", "x": 255, "y": 1145, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a14-mrs", "x": 325, "y": 1145, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a14-who", "x": 460, "y": 1145, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a14-count", "x": 655, "y": 1145, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a14-gap", "x": 780, "y": 1150, "text": "summary cmd", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "a15-label", "x": 15, "y": 1185, "text": "A15 Conflict detect", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "a15-issues", "x": 255, "y": 1180, "width": 50, "height": 28, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a15-mrs", "x": 325, "y": 1180, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "rectangle", "id": "a15-who", "x": 460, "y": 1180, "width": 50, "height": 28, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "a15-gap", "x": 780, "y": 1185, "text": "entity refs, --state", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "legend-title", "x": 15, "y": 1230, "text": "Legend:", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-essential", "x": 80, "y": 1228, "width": 20, "height": 20, "backgroundColor": "#22c55e", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "leg-essential-t", "x": 105, "y": 1230, "text": "Essential", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-supporting", "x": 190, "y": 1228, "width": 20, "height": 20, "backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "leg-supporting-t", "x": 215, "y": 1230, "text": "Supporting", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-partial", "x": 310, "y": 1228, "width": 20, "height": 20, "backgroundColor": "#ffd8a8", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "leg-partial-t", "x": 335, "y": 1230, "text": "Partially blocked", "fontSize": 14 },
|
||||||
|
{ "type": "text", "id": "leg-gap-t", "x": 470, "y": 1230, "text": "Red text = gap", "fontSize": 14, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "insight-1", "x": 15, "y": 1270, "text": "Key insight: `issues` and `search` are the workhorses (used in 20+ flows).", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "insight-2", "x": 15, "y": 1295, "text": "`who` is critical for people questions but siloed from file-change data.", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "insight-3", "x": 15, "y": 1320, "text": "`timeline` is powerful but keyword-only seeding limits entity-specific queries.", "fontSize": 14, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "insight-4", "x": 15, "y": 1345, "text": "22/30 flows have at least one gap. Most gaps are filter additions, not new commands.", "fontSize": 14, "strokeColor": "#ef4444" }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/03-command-coverage.png
Normal file
BIN
docs/diagrams/03-command-coverage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
110
docs/diagrams/04-gap-priority-matrix.excalidraw
Normal file
110
docs/diagrams/04-gap-priority-matrix.excalidraw
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 300, "y": 20, "text": "Lore CLI Gap Priority Matrix", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 310, "y": 58, "text": "20 identified gaps plotted by impact vs effort", "fontSize": 16, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "q1-zone", "x": 100, "y": 120, "width": 500, "height": 380,
|
||||||
|
"backgroundColor": "#d3f9d8", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#22c55e", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "q1-label", "x": 110, "y": 126, "text": "QUICK WINS", "fontSize": 18, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "q2-zone", "x": 620, "y": 120, "width": 500, "height": 380,
|
||||||
|
"backgroundColor": "#fff3bf", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#f59e0b", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "q2-label", "x": 630, "y": 126, "text": "STRATEGIC", "fontSize": 18, "strokeColor": "#b45309" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "q3-zone", "x": 100, "y": 520, "width": 500, "height": 300,
|
||||||
|
"backgroundColor": "#dbe4ff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#4a9eed", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "q3-label", "x": 110, "y": 526, "text": "FILL-IN", "fontSize": 18, "strokeColor": "#1971c2" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "q4-zone", "x": 620, "y": 520, "width": 500, "height": 300,
|
||||||
|
"backgroundColor": "#ffc9c9", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#ef4444", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "q4-label", "x": 630, "y": 526, "text": "DEPRIORITIZE", "fontSize": 18, "strokeColor": "#c92a2a" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "y-axis-hi", "x": 30, "y": 130, "text": "HIGH\nIMPACT", "fontSize": 16, "strokeColor": "#495057", "textAlign": "center" },
|
||||||
|
{ "type": "text", "id": "y-axis-lo", "x": 30, "y": 550, "text": "LOW\nIMPACT", "fontSize": 16, "strokeColor": "#495057", "textAlign": "center" },
|
||||||
|
{ "type": "text", "id": "x-axis-lo", "x": 280, "y": 840, "text": "LOW EFFORT", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
{ "type": "text", "id": "x-axis-hi", "x": 800, "y": 840, "text": "HIGH EFFORT", "fontSize": 16, "strokeColor": "#495057" },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "y-arrow", "x": 85, "y": 810, "width": 0, "height": -680,
|
||||||
|
"points": [[0,0],[0,-680]], "endArrowhead": "arrow", "strokeColor": "#495057", "strokeWidth": 1 },
|
||||||
|
{ "type": "arrow", "id": "x-arrow", "x": 85, "y": 810, "width": 1050, "height": 0,
|
||||||
|
"points": [[0,0],[1050,0]], "endArrowhead": "arrow", "strokeColor": "#495057", "strokeWidth": 1 },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "g5", "x": 120, "y": 160, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#5 @me alias", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g8", "x": 120, "y": 225, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#8 --state on search", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g9", "x": 120, "y": 290, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#9 mrs --milestone", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g10", "x": 120, "y": 355, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#10 --no-assignee", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g11", "x": 350, "y": 160, "width": 230, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#11 --updated-before", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g14", "x": 350, "y": 225, "width": 230, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#14 detail --fields", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g18", "x": 350, "y": 290, "width": 230, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#18 1y/12m duration", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g20", "x": 350, "y": 355, "width": 230, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#20 sort by due date", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "g1", "x": 640, "y": 160, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#1 MR file changes", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g2", "x": 640, "y": 225, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#2 entity refs query", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g3", "x": 640, "y": 290, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#3 per-note search", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g4", "x": 880, "y": 160, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#4 entity timeline", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g6", "x": 880, "y": 225, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#6 activity feed", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g12", "x": 880, "y": 290, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffd8a8", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#12 team workload", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "g13", "x": 120, "y": 570, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#13 pagination/offset", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g15", "x": 120, "y": 635, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#15 group by project", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g19", "x": 120, "y": 700, "width": 210, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#19 participant filter", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "g7", "x": 640, "y": 570, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#7 multi-path who", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g16", "x": 640, "y": 635, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#16 trend metrics", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "g17", "x": 640, "y": 700, "width": 220, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid",
|
||||||
|
"label": { "text": "#17 --for-issue on mrs", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "q1-count", "x": 180, "y": 430, "text": "8 gaps - lowest hanging fruit", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
{ "type": "text", "id": "q2-count", "x": 710, "y": 370, "text": "6 gaps - build deliberately", "fontSize": 14, "strokeColor": "#b45309" },
|
||||||
|
{ "type": "text", "id": "q3-count", "x": 160, "y": 770, "text": "3 gaps - fill as needed", "fontSize": 14, "strokeColor": "#1971c2" },
|
||||||
|
{ "type": "text", "id": "q4-count", "x": 680, "y": 770, "text": "3 gaps - defer or rethink", "fontSize": 14, "strokeColor": "#c92a2a" }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/04-gap-priority-matrix.png
Normal file
BIN
docs/diagrams/04-gap-priority-matrix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
184
docs/diagrams/05-data-flow-architecture.excalidraw
Normal file
184
docs/diagrams/05-data-flow-architecture.excalidraw
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{ "type": "text", "id": "title", "x": 350, "y": 15, "text": "Lore Data Flow Architecture", "fontSize": 28 },
|
||||||
|
{ "type": "text", "id": "subtitle", "x": 280, "y": 53, "text": "Green = queryable via CLI | Red = stored but hidden | Gray = internal", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-gitlab", "x": 30, "y": 90, "width": 200, "height": 300,
|
||||||
|
"backgroundColor": "#e5dbff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#8b5cf6", "strokeWidth": 1, "opacity": 30 },
|
||||||
|
{ "type": "text", "id": "zone-gitlab-label", "x": 55, "y": 96, "text": "GitLab APIs", "fontSize": 16, "strokeColor": "#7048e8" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "rest-api", "x": 50, "y": 130, "width": 160, "height": 60,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "REST API\n(paginated)", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "graphql-api", "x": 50, "y": 210, "width": 160, "height": 60,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "GraphQL API\n(adaptive pages)", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "ollama-api", "x": 50, "y": 310, "width": 160, "height": 60,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#d0bfff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "Ollama\n(embeddings)", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-ingest", "x": 270, "y": 90, "width": 180, "height": 300,
|
||||||
|
"backgroundColor": "#dbe4ff", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#4a9eed", "strokeWidth": 1, "opacity": 30 },
|
||||||
|
{ "type": "text", "id": "zone-ingest-label", "x": 300, "y": 96, "text": "Ingestion", "fontSize": 16, "strokeColor": "#1971c2" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "ingest-issues", "x": 285, "y": 130, "width": 150, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "Issue Sync", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "ingest-mrs", "x": 285, "y": 195, "width": 150, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "MR Sync", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "ingest-disc", "x": 285, "y": 260, "width": 150, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "Discussion Sync", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "ingest-events", "x": 285, "y": 325, "width": 150, "height": 50,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"label": { "text": "Event Sync", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-rest-issues", "x": 210, "y": 155, "width": 75, "height": 0,
|
||||||
|
"points": [[0,0],[75,0]], "endArrowhead": "arrow", "strokeColor": "#495057" },
|
||||||
|
{ "type": "arrow", "id": "a-rest-mrs", "x": 210, "y": 165, "width": 75, "height": 50,
|
||||||
|
"points": [[0,0],[75,50]], "endArrowhead": "arrow", "strokeColor": "#495057" },
|
||||||
|
{ "type": "arrow", "id": "a-graphql-issues", "x": 210, "y": 240, "width": 75, "height": -80,
|
||||||
|
"points": [[0,0],[75,-80]], "endArrowhead": "arrow", "strokeColor": "#495057" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-sqlite", "x": 490, "y": 90, "width": 400, "height": 650,
|
||||||
|
"backgroundColor": "#d3f9d8", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#22c55e", "strokeWidth": 1, "opacity": 20 },
|
||||||
|
{ "type": "text", "id": "zone-sqlite-label", "x": 570, "y": 96, "text": "SQLite (WAL mode)", "fontSize": 16, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-queryable", "x": 500, "y": 120, "text": "Queryable Tables", "fontSize": 14, "strokeColor": "#15803d" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "t-projects", "x": 500, "y": 145, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "projects", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-issues", "x": 500, "y": 195, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "issues + assignees", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-mrs", "x": 500, "y": 245, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "merge_requests", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-discussions", "x": 500, "y": 295, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "discussions + notes", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-events", "x": 500, "y": 345, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "resource_*_events", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-docs", "x": 500, "y": 395, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "documents + FTS5", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-embed", "x": 500, "y": 445, "width": 170, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid",
|
||||||
|
"label": { "text": "embeddings (vec)", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-hidden", "x": 700, "y": 120, "text": "Hidden Tables", "fontSize": 14, "strokeColor": "#c92a2a" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "t-file-changes", "x": 695, "y": 145, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "mr_file_changes", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-entity-refs", "x": 695, "y": 195, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "entity_references", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-raw", "x": 695, "y": 245, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444",
|
||||||
|
"label": { "text": "raw_payloads", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "grp-internal", "x": 700, "y": 310, "text": "Internal Only", "fontSize": 14, "strokeColor": "#868e96" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "t-sync", "x": 695, "y": 340, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#dee2e6", "fillStyle": "solid", "strokeColor": "#868e96",
|
||||||
|
"label": { "text": "sync_runs + cursors", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-dirty", "x": 695, "y": 390, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#dee2e6", "fillStyle": "solid", "strokeColor": "#868e96",
|
||||||
|
"label": { "text": "dirty_sources", "fontSize": 14 } },
|
||||||
|
{ "type": "rectangle", "id": "t-locks", "x": 695, "y": 440, "width": 180, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#dee2e6", "fillStyle": "solid", "strokeColor": "#868e96",
|
||||||
|
"label": { "text": "app_locks", "fontSize": 14 } },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-ingest-tables", "x": 435, "y": 200, "width": 55, "height": 0,
|
||||||
|
"points": [[0,0],[55,0]], "endArrowhead": "arrow", "strokeColor": "#495057" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "zone-cli", "x": 930, "y": 90, "width": 250, "height": 650,
|
||||||
|
"backgroundColor": "#fff3bf", "fillStyle": "solid", "roundness": { "type": 3 },
|
||||||
|
"strokeColor": "#f59e0b", "strokeWidth": 1, "opacity": 25 },
|
||||||
|
{ "type": "text", "id": "zone-cli-label", "x": 990, "y": 96, "text": "CLI Commands", "fontSize": 16, "strokeColor": "#b45309" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "cmd-issues", "x": 950, "y": 130, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore issues", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-mrs", "x": 950, "y": 185, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore mrs", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-search", "x": 950, "y": 240, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore search", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-who", "x": 950, "y": 295, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore who", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-timeline", "x": 950, "y": 350, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore timeline", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-count", "x": 950, "y": 405, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore count", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-sync", "x": 950, "y": 460, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore sync", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-status", "x": 950, "y": 515, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#fff3bf", "fillStyle": "solid",
|
||||||
|
"label": { "text": "lore status", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-issues-cmd", "x": 670, "y": 215, "width": 270, "height": -65,
|
||||||
|
"points": [[0,0],[270,-65]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
{ "type": "arrow", "id": "a-mrs-cmd", "x": 670, "y": 265, "width": 270, "height": -60,
|
||||||
|
"points": [[0,0],[270,-60]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
{ "type": "arrow", "id": "a-docs-cmd", "x": 670, "y": 415, "width": 270, "height": -155,
|
||||||
|
"points": [[0,0],[270,-155]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
{ "type": "arrow", "id": "a-embed-cmd", "x": 670, "y": 465, "width": 270, "height": -200,
|
||||||
|
"points": [[0,0],[270,-200]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
{ "type": "arrow", "id": "a-events-cmd", "x": 670, "y": 365, "width": 270, "height": 5,
|
||||||
|
"points": [[0,0],[270,5]], "endArrowhead": "arrow", "strokeColor": "#22c55e", "strokeWidth": 2 },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "hidden-note-1", "x": 695, "y": 498, "text": "mr_file_changes: populated by\nMR sync but NOT queryable.\nBlocks H4, A6, A10 flows.", "fontSize": 14, "strokeColor": "#ef4444" },
|
||||||
|
{ "type": "text", "id": "hidden-note-2", "x": 695, "y": 568, "text": "entity_references: used by\ntimeline internally but NOT\nqueryable. Blocks A5, A11.", "fontSize": 14, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-hidden-who", "x": 875, "y": 165, "width": 65, "height": 148,
|
||||||
|
"points": [[0,0],[65,148]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeWidth": 2,
|
||||||
|
"strokeStyle": "dashed" },
|
||||||
|
{ "type": "text", "id": "hidden-who-label", "x": 880, "y": 240, "text": "who uses\nDiffNotes,\nnot file\nchanges", "fontSize": 12, "strokeColor": "#ef4444" },
|
||||||
|
|
||||||
|
{ "type": "arrow", "id": "a-hidden-timeline", "x": 875, "y": 215, "width": 65, "height": 155,
|
||||||
|
"points": [[0,0],[65,155]], "endArrowhead": "arrow", "strokeColor": "#ef4444", "strokeWidth": 2,
|
||||||
|
"strokeStyle": "dashed" },
|
||||||
|
|
||||||
|
{ "type": "rectangle", "id": "cmd-missing-refs", "x": 950, "y": 580, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444", "strokeStyle": "dashed",
|
||||||
|
"label": { "text": "lore refs (missing)", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-missing-files", "x": 950, "y": 635, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444", "strokeStyle": "dashed",
|
||||||
|
"label": { "text": "lore files (missing)", "fontSize": 16 } },
|
||||||
|
{ "type": "rectangle", "id": "cmd-missing-activity", "x": 950, "y": 690, "width": 210, "height": 40,
|
||||||
|
"roundness": { "type": 3 }, "backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444", "strokeStyle": "dashed",
|
||||||
|
"label": { "text": "lore activity (missing)", "fontSize": 16 } },
|
||||||
|
|
||||||
|
{ "type": "text", "id": "legend-title", "x": 30, "y": 430, "text": "Legend", "fontSize": 16 },
|
||||||
|
{ "type": "rectangle", "id": "leg-green", "x": 30, "y": 460, "width": 20, "height": 20,
|
||||||
|
"backgroundColor": "#b2f2bb", "fillStyle": "solid" },
|
||||||
|
{ "type": "text", "id": "leg-green-t", "x": 60, "y": 462, "text": "Queryable via CLI", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-red", "x": 30, "y": 490, "width": 20, "height": 20,
|
||||||
|
"backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444" },
|
||||||
|
{ "type": "text", "id": "leg-red-t", "x": 60, "y": 492, "text": "Stored but hidden", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-gray", "x": 30, "y": 520, "width": 20, "height": 20,
|
||||||
|
"backgroundColor": "#dee2e6", "fillStyle": "solid", "strokeColor": "#868e96" },
|
||||||
|
{ "type": "text", "id": "leg-gray-t", "x": 60, "y": 522, "text": "Internal bookkeeping", "fontSize": 14 },
|
||||||
|
{ "type": "rectangle", "id": "leg-dashed", "x": 30, "y": 550, "width": 20, "height": 20,
|
||||||
|
"backgroundColor": "#ffc9c9", "fillStyle": "solid", "strokeColor": "#ef4444", "strokeStyle": "dashed" },
|
||||||
|
{ "type": "text", "id": "leg-dashed-t", "x": 60, "y": 552, "text": "Missing command", "fontSize": 14 }
|
||||||
|
],
|
||||||
|
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
BIN
docs/diagrams/05-data-flow-architecture.png
Normal file
BIN
docs/diagrams/05-data-flow-architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
131
docs/prd-per-note-search.feedback-6.md
Normal file
131
docs/prd-per-note-search.feedback-6.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
1. **Make immutable identity usable now (`--author-id`)**
|
||||||
|
Why: The plan captures `author_id` but intentionally defers using it, so the core longitudinal-analysis problem is only half-fixed.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Phase 1: `lore notes` Command / Work Chunk 1A
|
||||||
|
pub struct NoteListFilters<'a> {
|
||||||
|
+ pub author_id: Option<i64>, // immutable identity filter
|
||||||
|
@@
|
||||||
|
- pub author: Option<&'a str>, // case-insensitive match via COLLATE NOCASE
|
||||||
|
+ pub author: Option<&'a str>, // display-name filter
|
||||||
|
+ // If both author and author_id are provided, apply both (AND) for precision.
|
||||||
|
}
|
||||||
|
@@
|
||||||
|
Filter mappings:
|
||||||
|
+ - `author_id`: `n.author_id = ?` (exact immutable identity)
|
||||||
|
- `author`: strip `@` prefix, `n.author_username = ? COLLATE NOCASE`
|
||||||
|
@@ Phase 1 / Work Chunk 1B (CLI)
|
||||||
|
+ /// Filter by immutable author id
|
||||||
|
+ #[arg(long = "author-id", help_heading = "Filters")]
|
||||||
|
+ pub author_id: Option<i64>,
|
||||||
|
@@ Phase 2 / Work Chunk 2F
|
||||||
|
+ Add `--author-id` support to `lore search` filtering for note documents.
|
||||||
|
@@ Phase 1 / Work Chunk 1E
|
||||||
|
+ CREATE INDEX IF NOT EXISTS idx_notes_project_author_id_created
|
||||||
|
+ ON notes(project_id, author_id, created_at DESC, id DESC)
|
||||||
|
+ WHERE is_system = 0 AND author_id IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Fix document staleness on username changes**
|
||||||
|
Why: Current plan says username changes are “not semantic,” but note documents include username in content/title, so docs go stale/inconsistent.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 0D: Immutable Author Identity Capture
|
||||||
|
- Assert: changed_semantics = false (username change is not a semantic change for documents)
|
||||||
|
+ Assert: changed_semantics = true (username affects note document content/title)
|
||||||
|
@@ Work Chunk 0A: semantic-change detection
|
||||||
|
- old_body != body || old_note_type != note_type || ...
|
||||||
|
+ old_body != body || old_note_type != note_type || ...
|
||||||
|
+ || old_author_username != author_username
|
||||||
|
@@ Work Chunk 2C: Note Document Extractor header
|
||||||
|
author: @{author}
|
||||||
|
+ author_id: {author_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Replace `last_seen_at` sweep marker with monotonic `sync_run_id`**
|
||||||
|
Why: Timestamp markers are vulnerable to clock skew and concurrent runs; run IDs are deterministic and safer.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Phase 0: Stable Note Identity
|
||||||
|
+ ### Work Chunk 0E: Monotonic Run Marker
|
||||||
|
+ Add `sync_runs` table and `notes.last_seen_run_id`.
|
||||||
|
+ Ingest assigns one run_id per sync transaction.
|
||||||
|
+ Upsert sets `last_seen_run_id = current_run_id`.
|
||||||
|
+ Sweep condition becomes `last_seen_run_id < current_run_id` (when fetch_complete=true).
|
||||||
|
@@ Work Chunk 0C
|
||||||
|
- fetch_complete + last_seen_at-based sweep
|
||||||
|
+ fetch_complete + run_id-based sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Materialize stale-note set once during sweep**
|
||||||
|
Why: Current set-based SQL still re-runs the stale subquery 3 times; materializing once improves performance and guarantees identical deletion set.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 0B: Immediate Deletion Propagation
|
||||||
|
- DELETE FROM documents ... IN (SELECT id FROM notes WHERE ...);
|
||||||
|
- DELETE FROM dirty_sources ... IN (SELECT id FROM notes WHERE ...);
|
||||||
|
- DELETE FROM notes WHERE ...;
|
||||||
|
+ CREATE TEMP TABLE _stale_note_ids AS
|
||||||
|
+ SELECT id, is_system FROM notes WHERE discussion_id = ? AND last_seen_run_id < ?;
|
||||||
|
+ DELETE FROM documents
|
||||||
|
+ WHERE source_type='note' AND source_id IN (SELECT id FROM _stale_note_ids WHERE is_system=0);
|
||||||
|
+ DELETE FROM dirty_sources
|
||||||
|
+ WHERE source_type='note' AND source_id IN (SELECT id FROM _stale_note_ids WHERE is_system=0);
|
||||||
|
+ DELETE FROM notes WHERE id IN (SELECT id FROM _stale_note_ids);
|
||||||
|
+ DROP TABLE _stale_note_ids;
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Move historical note backfill out of migration into resumable runtime job**
|
||||||
|
Why: Data-heavy migration can block startup and is harder to resume/recover on large DBs.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 2H
|
||||||
|
- Backfill Existing Notes After Upgrade (Migration 024)
|
||||||
|
+ Backfill Existing Notes After Upgrade (Resumable Runtime Backfill)
|
||||||
|
@@
|
||||||
|
- Files: `migrations/024_note_dirty_backfill.sql`, `src/core/db.rs`
|
||||||
|
+ Files: `src/documents/backfill.rs`, `src/cli/commands/generate_docs.rs`
|
||||||
|
@@
|
||||||
|
- INSERT INTO dirty_sources ... SELECT ... FROM notes ...
|
||||||
|
+ Introduce batched backfill API:
|
||||||
|
+ `enqueue_missing_note_documents(batch_size: usize) -> BackfillProgress`
|
||||||
|
+ invoked from `generate-docs`/`sync` until complete, resumable across runs.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Add streaming path for large `jsonl`/`csv` note exports**
|
||||||
|
Why: Current `query_notes` materializes full result set in memory; streaming improves scalability and latency.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1A
|
||||||
|
+ Add `query_notes_stream(conn, filters, row_handler)` for forward-only row iteration.
|
||||||
|
@@ Work Chunk 1C
|
||||||
|
- print_list_notes_jsonl(&result)
|
||||||
|
- print_list_notes_csv(&result)
|
||||||
|
+ print_list_notes_jsonl_stream(config, filters)
|
||||||
|
+ print_list_notes_csv_stream(config, filters)
|
||||||
|
+ (table/json keep counted buffered path)
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Add index for path-centric note queries**
|
||||||
|
Why: `--path` + project/date queries are a stated hot path and not fully covered by current proposed indexes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1E: Composite Query Index
|
||||||
|
+ CREATE INDEX IF NOT EXISTS idx_notes_project_path_created
|
||||||
|
+ ON notes(project_id, position_new_path, created_at DESC, id DESC)
|
||||||
|
+ WHERE is_system = 0 AND position_new_path IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Add property/invariant tests (not only examples)**
|
||||||
|
Why: This feature touches ingestion identity, sweeping, deletion propagation, and document regeneration; randomized invariants will catch subtle regressions.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Verification Checklist
|
||||||
|
+ Add property tests (proptest):
|
||||||
|
+ - stable local IDs across randomized re-sync orderings
|
||||||
|
+ - no orphan `documents(source_type='note')` after randomized deletions/sweeps
|
||||||
|
+ - partial-fetch runs never reduce note count
|
||||||
|
+ - repeated full rebuild converges (fixed-point idempotence)
|
||||||
|
```
|
||||||
|
|
||||||
|
These revisions keep your existing direction, avoid all rejected items, and materially improve correctness, scale behavior, and long-term maintainability.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
plan: true
|
plan: true
|
||||||
title: ""
|
title: ""
|
||||||
status: iterating
|
status: iterating
|
||||||
iteration: 6
|
iteration: 5
|
||||||
target_iterations: 8
|
target_iterations: 8
|
||||||
beads_revision: 0
|
beads_revision: 0
|
||||||
related_plans: []
|
related_plans: []
|
||||||
@@ -171,17 +171,12 @@ Then detect semantic change with a separate check that excludes `updated_at` and
|
|||||||
```sql
|
```sql
|
||||||
WHERE notes.body IS NOT excluded.body
|
WHERE notes.body IS NOT excluded.body
|
||||||
OR notes.note_type IS NOT excluded.note_type
|
OR notes.note_type IS NOT excluded.note_type
|
||||||
OR notes.author_username IS NOT excluded.author_username
|
|
||||||
OR notes.resolved IS NOT excluded.resolved
|
OR notes.resolved IS NOT excluded.resolved
|
||||||
OR notes.resolved_by IS NOT excluded.resolved_by
|
OR notes.resolved_by IS NOT excluded.resolved_by
|
||||||
OR notes.position_new_path IS NOT excluded.position_new_path
|
OR notes.position_new_path IS NOT excluded.position_new_path
|
||||||
OR notes.position_new_line IS NOT excluded.position_new_line
|
OR notes.position_new_line IS NOT excluded.position_new_line
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why `author_username` is semantic:** Note documents embed the username in both the content header (`author: @{author}`) and the title (`Note by @{author} on Issue #42`). If a GitLab user changes their username (e.g., `jdefting` -> `jd-engineering`), the existing note documents become stale — search results show the old username, inconsistent with what the API returns. Treating username changes as semantic ensures documents stay accurate.
|
|
||||||
|
|
||||||
**Note:** `author_id` changes do NOT trigger `changed_semantics`. The `author_id` is an immutable identity anchor — it never changes in practice, and even if it did (data migration), it doesn't affect document content.
|
|
||||||
|
|
||||||
**Rationale:** `updated_at` changes alone (e.g., GitLab touching the timestamp without modifying content) should NOT trigger document regeneration. This avoids unnecessary dirty queue churn on large datasets. The WHERE clause fires the DO UPDATE unconditionally (to refresh `last_seen_at`), and `changed_semantics` is derived from `conn.changes()` after a second query that checks only semantic fields:
|
**Rationale:** `updated_at` changes alone (e.g., GitLab touching the timestamp without modifying content) should NOT trigger document regeneration. This avoids unnecessary dirty queue churn on large datasets. The WHERE clause fires the DO UPDATE unconditionally (to refresh `last_seen_at`), and `changed_semantics` is derived from `conn.changes()` after a second query that checks only semantic fields:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -215,8 +210,8 @@ let local_id = match &existed {
|
|||||||
|
|
||||||
let changed_semantics = match &existed {
|
let changed_semantics = match &existed {
|
||||||
None => true, // New insert = always changed
|
None => true, // New insert = always changed
|
||||||
Some((_, old_body, old_note_type, old_author_username, old_resolved, old_path, old_line)) => {
|
Some((_, old_body, old_note_type, old_resolved, old_path, old_line)) => {
|
||||||
old_body.as_deref() != body || old_note_type.as_deref() != note_type || old_author_username.as_deref() != author_username || /* ... */
|
old_body.as_deref() != body || old_note_type.as_deref() != note_type || /* ... */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -406,7 +401,7 @@ if fetch_complete {
|
|||||||
|
|
||||||
GitLab note payloads include `note.author.id` (an immutable integer). Capturing this alongside the username provides a stable identity anchor for longitudinal analysis, even across username changes.
|
GitLab note payloads include `note.author.id` (an immutable integer). Capturing this alongside the username provides a stable identity anchor for longitudinal analysis, even across username changes.
|
||||||
|
|
||||||
**Scope:** This chunk adds the column and populates it during ingestion. A `--author-id` CLI filter for `lore notes` is wired up in Phase 1 (Work Chunk 1A/1B) to make the immutable identity immediately usable for the core longitudinal analysis use case. The value here is data capture and query foundation: once `author_id` is stored, it can never be retroactively recovered if we don't capture it now.
|
**Scope:** This chunk adds the column and populates it during ingestion. It does NOT add a `--author-id` CLI filter — that's deferred to the downstream reviewer profiling PRD. The value here is data capture: once `author_id` is stored, it can never be retroactively recovered if we don't capture it now.
|
||||||
|
|
||||||
#### Tests to Write First
|
#### Tests to Write First
|
||||||
|
|
||||||
@@ -436,7 +431,7 @@ fn test_note_author_id_survives_username_change() {
|
|||||||
// Re-upsert same gitlab_id with author_username = "jd-engineering", author_id = 12345
|
// Re-upsert same gitlab_id with author_username = "jd-engineering", author_id = 12345
|
||||||
// Assert: author_id unchanged (12345)
|
// Assert: author_id unchanged (12345)
|
||||||
// Assert: author_username updated to "jd-engineering"
|
// Assert: author_username updated to "jd-engineering"
|
||||||
// Assert: changed_semantics = true (username is embedded in document content/title)
|
// Assert: changed_semantics = false (username change is not a semantic change for documents)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -450,19 +445,18 @@ Add to the query index migration SQL:
|
|||||||
-- Add immutable author identity column (nullable for backcompat with pre-existing notes)
|
-- Add immutable author identity column (nullable for backcompat with pre-existing notes)
|
||||||
ALTER TABLE notes ADD COLUMN author_id INTEGER;
|
ALTER TABLE notes ADD COLUMN author_id INTEGER;
|
||||||
|
|
||||||
-- Composite index for author_id lookups — used by `lore notes --author-id`
|
-- Index for future author_id lookups (not used by current CLI, but enables
|
||||||
-- for immutable identity queries. Includes project_id and created_at for
|
-- the downstream reviewer profiling PRD to query by stable identity)
|
||||||
-- the common "all notes by this person in this project" pattern.
|
CREATE INDEX IF NOT EXISTS idx_notes_author_id
|
||||||
CREATE INDEX IF NOT EXISTS idx_notes_project_author_id_created
|
ON notes(author_id)
|
||||||
ON notes(project_id, author_id, created_at DESC, id DESC)
|
WHERE author_id IS NOT NULL;
|
||||||
WHERE is_system = 0 AND author_id IS NOT NULL;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Populate `author_id` during upsert** — In both `upsert_note_for_issue()` (discussions.rs) and `upsert_note()` (mr_discussions.rs), add `author_id` to the INSERT and ON CONFLICT DO UPDATE SET clauses. Extract from the GitLab API note payload's `author.id` field.
|
**2. Populate `author_id` during upsert** — In both `upsert_note_for_issue()` (discussions.rs) and `upsert_note()` (mr_discussions.rs), add `author_id` to the INSERT and ON CONFLICT DO UPDATE SET clauses. Extract from the GitLab API note payload's `author.id` field.
|
||||||
|
|
||||||
**3. Semantic change detection** — `author_id` changes should NOT trigger `changed_semantics = true`. The `author_id` is an identity anchor, not a content field. It's excluded from the semantic change comparison alongside `updated_at` and `last_seen_at`. However, `author_username` changes DO trigger `changed_semantics = true` because the username appears in document content and title (see Work Chunk 0A semantic detection).
|
**3. Semantic change detection** — `author_id` changes should NOT trigger `changed_semantics = true`. The `author_id` is an identity anchor, not a content field. It's excluded from the semantic change comparison alongside `updated_at` and `last_seen_at`.
|
||||||
|
|
||||||
**4. Note document extraction** — Work Chunk 2C's `extract_note_document()` function includes both `author_username` (in the document content header and title) and `author_id` (in the metadata header). The `author_id` field enables downstream tools to reliably identify the same person even after username changes.
|
**4. Note document extraction** — No changes needed for this chunk. The `extract_note_document()` function (Work Chunk 2C) uses `author_username` for the document content. The `author_id` is stored for future use but not surfaced in the current document format.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -521,25 +515,11 @@ fn test_query_notes_filter_author_strips_at() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_query_notes_filter_author_case_insensitive() {
|
fn test_query_notes_filter_author_case_insensitive() {
|
||||||
// Insert note from "Alice" (capital A)
|
// Insert notes from "Alice" (capital A)
|
||||||
// Call query_notes with author = Some("alice")
|
// Call query_notes with author = Some("alice")
|
||||||
// Assert: matches (COLLATE NOCASE)
|
// Assert: matches (COLLATE NOCASE)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_query_notes_filter_author_id() {
|
|
||||||
// Insert notes from author_id = 100 (username "alice") and author_id = 200 (username "bob")
|
|
||||||
// Call query_notes with author_id = Some(100)
|
|
||||||
// Assert: only alice's notes returned (by immutable identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_query_notes_filter_author_id_and_author_combined() {
|
|
||||||
// Insert notes from author_id=100/username="alice" and author_id=100/username="alice-renamed"
|
|
||||||
// Call query_notes with author_id = Some(100), author = Some("alice")
|
|
||||||
// Assert: only notes where BOTH match (AND semantics) — returns alice's notes before rename
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_query_notes_filter_note_type() {
|
fn test_query_notes_filter_note_type() {
|
||||||
// Insert notes with note_type = Some("DiffNote") and Some("DiscussionNote") and None
|
// Insert notes with note_type = Some("DiffNote") and Some("DiscussionNote") and None
|
||||||
@@ -805,8 +785,7 @@ impl From<&NoteListResult> for NoteListResultJson { ... }
|
|||||||
pub struct NoteListFilters<'a> {
|
pub struct NoteListFilters<'a> {
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
pub project: Option<&'a str>,
|
pub project: Option<&'a str>,
|
||||||
pub author: Option<&'a str>, // display-name filter, case-insensitive via COLLATE NOCASE
|
pub author: Option<&'a str>, // case-insensitive match via COLLATE NOCASE
|
||||||
pub author_id: Option<i64>, // immutable identity filter (exact match)
|
|
||||||
pub note_type: Option<&'a str>, // "DiffNote" | "DiscussionNote"
|
pub note_type: Option<&'a str>, // "DiffNote" | "DiscussionNote"
|
||||||
pub include_system: bool, // default false
|
pub include_system: bool, // default false
|
||||||
pub for_issue_iid: Option<i64>, // filter by parent issue iid
|
pub for_issue_iid: Option<i64>, // filter by parent issue iid
|
||||||
@@ -878,7 +857,6 @@ Dynamic WHERE clauses follow the same `where_clauses` + `params` vec pattern as
|
|||||||
Filter mappings:
|
Filter mappings:
|
||||||
- `include_system = false` (default): `n.is_system = 0`
|
- `include_system = false` (default): `n.is_system = 0`
|
||||||
- `author`: strip `@` prefix, `n.author_username = ? COLLATE NOCASE`
|
- `author`: strip `@` prefix, `n.author_username = ? COLLATE NOCASE`
|
||||||
- `author_id`: `n.author_id = ?` (exact immutable identity match). If both `author` and `author_id` are provided, both are applied (AND) for precision — this lets users query "notes by user 12345 when they were known as jdefting"
|
|
||||||
- `note_type`: `n.note_type = ?`
|
- `note_type`: `n.note_type = ?`
|
||||||
- `project`: `resolve_project(conn, project)?` then `n.project_id = ?`
|
- `project`: `resolve_project(conn, project)?` then `n.project_id = ?`
|
||||||
- `note_id`: `n.id = ?` (exact local row ID match — useful for debugging sync correctness)
|
- `note_id`: `n.id = ?` (exact local row ID match — useful for debugging sync correctness)
|
||||||
@@ -898,25 +876,14 @@ Filter mappings:
|
|||||||
|
|
||||||
COUNT query first (same pattern as issues), then SELECT with LIMIT.
|
COUNT query first (same pattern as issues), then SELECT with LIMIT.
|
||||||
|
|
||||||
**Public entry points:**
|
**Public entry point:**
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
/// Buffered query — materializes full result set. Used by table and JSON output.
|
|
||||||
pub fn run_list_notes(config: &Config, filters: NoteListFilters) -> Result<NoteListResult> {
|
pub fn run_list_notes(config: &Config, filters: NoteListFilters) -> Result<NoteListResult> {
|
||||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||||
let conn = create_connection(&db_path)?;
|
let conn = create_connection(&db_path)?;
|
||||||
query_notes(&conn, &filters)
|
query_notes(&conn, &filters)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming query — calls row_handler for each row without full materialization.
|
|
||||||
/// Used by JSONL and CSV output (Work Chunk 1C). Skips COUNT query.
|
|
||||||
pub fn query_notes_stream<F>(conn: &Connection, filters: &NoteListFilters, mut row_handler: F) -> Result<()>
|
|
||||||
where
|
|
||||||
F: FnMut(NoteListRow) -> Result<()>,
|
|
||||||
{
|
|
||||||
// Same SQL as query_notes() but iterates with Statement::query_map()
|
|
||||||
// instead of collecting into Vec<NoteListRow>
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -973,10 +940,6 @@ pub struct NotesArgs {
|
|||||||
#[arg(short = 'a', long, help_heading = "Filters")]
|
#[arg(short = 'a', long, help_heading = "Filters")]
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
|
|
||||||
/// Filter by immutable GitLab author id (stable across username changes)
|
|
||||||
#[arg(long = "author-id", help_heading = "Filters")]
|
|
||||||
pub author_id: Option<i64>,
|
|
||||||
|
|
||||||
/// Filter by note type (DiffNote, DiscussionNote)
|
/// Filter by note type (DiffNote, DiscussionNote)
|
||||||
#[arg(long = "note-type", value_parser = ["DiffNote", "DiscussionNote"], help_heading = "Filters")]
|
#[arg(long = "note-type", value_parser = ["DiffNote", "DiscussionNote"], help_heading = "Filters")]
|
||||||
pub note_type: Option<String>,
|
pub note_type: Option<String>,
|
||||||
@@ -1074,7 +1037,6 @@ fn handle_notes(config_path: Option<&str>, args: NotesArgs, robot_mode: bool) ->
|
|||||||
limit: args.limit,
|
limit: args.limit,
|
||||||
project: args.project.as_deref(),
|
project: args.project.as_deref(),
|
||||||
author: args.author.as_deref(),
|
author: args.author.as_deref(),
|
||||||
author_id: args.author_id,
|
|
||||||
note_type: args.note_type.as_deref(),
|
note_type: args.note_type.as_deref(),
|
||||||
include_system: args.include_system,
|
include_system: args.include_system,
|
||||||
for_issue_iid: args.for_issue,
|
for_issue_iid: args.for_issue,
|
||||||
@@ -1091,27 +1053,20 @@ fn handle_notes(config_path: Option<&str>, args: NotesArgs, robot_mode: bool) ->
|
|||||||
order: if args.asc { "asc" } else { "desc" },
|
order: if args.asc { "asc" } else { "desc" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// JSONL and CSV use streaming path (no full materialization in memory)
|
let result = run_list_notes(&config, filters)?;
|
||||||
// Table and JSON use buffered path (need total_count for envelope/summary)
|
|
||||||
match (robot_mode, args.format.as_str()) {
|
match (robot_mode, args.format.as_str()) {
|
||||||
|
(true, _) | (_, "json") => {
|
||||||
|
print_list_notes_json(&result, start.elapsed().as_millis() as u64, args.fields.as_deref());
|
||||||
|
}
|
||||||
(_, "jsonl") => {
|
(_, "jsonl") => {
|
||||||
let conn = open_db(&config)?;
|
print_list_notes_jsonl(&result);
|
||||||
print_list_notes_jsonl_stream(&conn, &filters)?;
|
|
||||||
}
|
}
|
||||||
(_, "csv") => {
|
(_, "csv") => {
|
||||||
let conn = open_db(&config)?;
|
print_list_notes_csv(&result);
|
||||||
print_list_notes_csv_stream(&conn, &filters)?;
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let result = run_list_notes(&config, filters)?;
|
print_list_notes(&result);
|
||||||
match (robot_mode, args.format.as_str()) {
|
|
||||||
(true, _) | (_, "json") => {
|
|
||||||
print_list_notes_json(&result, start.elapsed().as_millis() as u64, args.fields.as_deref());
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
print_list_notes(&result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1127,7 +1082,7 @@ Some(Commands::Notes(args)) => handle_notes(cli.config.as_deref(), args, robot_m
|
|||||||
**5. Re-export in `src/cli/commands/mod.rs`:**
|
**5. Re-export in `src/cli/commands/mod.rs`:**
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub use list::{run_list_notes, query_notes_stream, print_list_notes, print_list_notes_json, print_list_notes_jsonl_stream, print_list_notes_csv_stream};
|
pub use list::{run_list_notes, print_list_notes, print_list_notes_json, print_list_notes_jsonl, print_list_notes_csv};
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1151,27 +1106,18 @@ fn test_truncate_note_body() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_csv_stream_output_roundtrip() {
|
fn test_csv_output_roundtrip() {
|
||||||
// Setup DB with notes containing commas, quotes, newlines, and multi-byte chars in body
|
// NoteListRow with body containing commas, quotes, newlines, and multi-byte chars
|
||||||
// Run print_list_notes_csv_stream, capture stdout, parse back with csv::ReaderBuilder
|
// Write via print_list_notes_csv, parse back with csv::ReaderBuilder
|
||||||
// Assert: all fields roundtrip correctly
|
// Assert: all fields roundtrip correctly
|
||||||
// Assert: header row present
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_jsonl_stream_output_one_per_line() {
|
fn test_jsonl_output_one_per_line() {
|
||||||
// Setup DB with 3 non-system notes
|
// NoteListResult with 3 notes
|
||||||
// Run print_list_notes_jsonl_stream, capture stdout, split by newline
|
// Capture stdout, split by newline
|
||||||
// Assert: each line parses as valid JSON
|
// Assert: each line parses as valid JSON
|
||||||
// Assert: 3 lines total (no envelope, no metadata line)
|
// Assert: 3 lines total
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_streaming_matches_buffered_content() {
|
|
||||||
// Setup DB with 5 non-system notes
|
|
||||||
// Run buffered query_notes() and streaming query_notes_stream()
|
|
||||||
// Assert: identical note data in same order (streaming omits total_count, but
|
|
||||||
// the content of each row must match the buffered path)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1207,42 +1153,33 @@ Follows exact envelope pattern:
|
|||||||
|
|
||||||
Supports `--fields` via `filter_fields(&mut output, "notes", &expanded)`.
|
Supports `--fields` via `filter_fields(&mut output, "notes", &expanded)`.
|
||||||
|
|
||||||
**`print_list_notes_jsonl` / `print_list_notes_csv`** — streaming output:
|
**`print_list_notes_jsonl(result: &NoteListResult)`** — one JSON object per line:
|
||||||
|
|
||||||
For JSONL and CSV formats, use a **streaming path** that writes rows directly to stdout as they're read from the database, avoiding full materialization in memory. This matters for the year-long analysis use case where `--limit 10000` or higher is common, and for piped workflows where downstream consumers (jq, LLM ingestion) can begin processing before the query completes.
|
|
||||||
|
|
||||||
**`print_list_notes_jsonl_stream(conn, filters)`** — streaming JSONL:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Execute query, iterate over rows with a callback
|
|
||||||
query_notes_stream(&conn, &filters, |row| {
|
|
||||||
let json_row = NoteListRowJson::from(&row);
|
|
||||||
println!("{}", serde_json::to_string(&json_row).unwrap());
|
|
||||||
Ok(())
|
|
||||||
})?;
|
|
||||||
```
|
|
||||||
|
|
||||||
Each line is a complete `NoteListRowJson` object. No envelope, no metadata. This format is ideal for streaming into LLM prompts, `jq` pipelines, or notebook ingestion.
|
Each line is a complete `NoteListRowJson` object. No envelope, no metadata. This format is ideal for streaming into LLM prompts, `jq` pipelines, or notebook ingestion.
|
||||||
|
|
||||||
**`print_list_notes_csv_stream(conn, filters)`** — streaming CSV:
|
```rust
|
||||||
|
for note in &result.notes {
|
||||||
|
let json_row = NoteListRowJson::from(note);
|
||||||
|
println!("{}", serde_json::to_string(&json_row).unwrap());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`print_list_notes_csv(result: &NoteListResult)`** — CSV with header:
|
||||||
|
|
||||||
|
Columns mirror `NoteListRowJson` field names. Uses the `csv` crate (`csv::Writer`) for RFC 4180-compliant escaping, handling commas, quotes, newlines, and multi-byte characters correctly. This avoids the fragility of manual CSV escaping.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let mut wtr = csv::Writer::from_writer(std::io::stdout());
|
let mut wtr = csv::Writer::from_writer(std::io::stdout());
|
||||||
|
// Write header
|
||||||
wtr.write_record(&["id", "gitlab_id", "author_username", "body", "note_type", ...])?;
|
wtr.write_record(&["id", "gitlab_id", "author_username", "body", "note_type", ...])?;
|
||||||
query_notes_stream(&conn, &filters, |row| {
|
// Write rows
|
||||||
let json_row = NoteListRowJson::from(&row);
|
for note in &result.notes {
|
||||||
|
let json_row = NoteListRowJson::from(note);
|
||||||
wtr.write_record(&[json_row.id.to_string(), ...])?;
|
wtr.write_record(&[json_row.id.to_string(), ...])?;
|
||||||
Ok(())
|
}
|
||||||
})?;
|
|
||||||
wtr.flush()?;
|
wtr.flush()?;
|
||||||
```
|
```
|
||||||
|
|
||||||
Columns mirror `NoteListRowJson` field names. Uses the `csv` crate (`csv::Writer`) for RFC 4180-compliant escaping, handling commas, quotes, newlines, and multi-byte characters correctly.
|
|
||||||
|
|
||||||
**`query_notes_stream(conn, filters, row_handler)`** — forward-only row iteration that calls `row_handler` for each row. Uses the same SQL as `query_notes()` but iterates with `rusqlite::Statement::query_map()` instead of collecting into a Vec. The table and JSON formats continue to use the buffered `query_notes()` path since they need `total_count` and `showing` metadata.
|
|
||||||
|
|
||||||
**Note:** The streaming path skips the COUNT query since there's no envelope to report `total_count` in. For JSONL, this is expected — consumers count lines themselves. For CSV, the header row provides column names; row count is implicit.
|
|
||||||
|
|
||||||
**Dependency:** Add `csv = "1"` to `Cargo.toml` under `[dependencies]`. The `csv` crate is well-maintained, widely adopted (~100M downloads), and has zero unsafe code.
|
**Dependency:** Add `csv = "1"` to `Cargo.toml` under `[dependencies]`. The `csv` crate is well-maintained, widely adopted (~100M downloads), and has zero unsafe code.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1282,13 +1219,12 @@ fn test_migration_022_indexes_exist() {
|
|||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN (
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN (
|
||||||
'idx_notes_user_created', 'idx_notes_project_created',
|
'idx_notes_user_created', 'idx_notes_project_created',
|
||||||
'idx_notes_project_path_created',
|
|
||||||
'idx_discussions_issue_id', 'idx_discussions_mr_id'
|
'idx_discussions_issue_id', 'idx_discussions_mr_id'
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
assert_eq!(count, 5);
|
assert_eq!(count, 4);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1312,13 +1248,6 @@ CREATE INDEX IF NOT EXISTS idx_notes_project_created
|
|||||||
ON notes(project_id, created_at DESC, id DESC)
|
ON notes(project_id, created_at DESC, id DESC)
|
||||||
WHERE is_system = 0;
|
WHERE is_system = 0;
|
||||||
|
|
||||||
-- Composite index for path-centric note queries (--path with project/date filters).
|
|
||||||
-- DiffNote reviews on specific files are a stated hot path for the reviewer
|
|
||||||
-- profiling use case. Only indexes rows where position_new_path is populated.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notes_project_path_created
|
|
||||||
ON notes(project_id, position_new_path, created_at DESC, id DESC)
|
|
||||||
WHERE is_system = 0 AND position_new_path IS NOT NULL;
|
|
||||||
|
|
||||||
-- Index on discussions.issue_id for efficient JOIN when filtering by parent issue.
|
-- Index on discussions.issue_id for efficient JOIN when filtering by parent issue.
|
||||||
-- The query_notes() function JOINs discussions to reach parent entities.
|
-- The query_notes() function JOINs discussions to reach parent entities.
|
||||||
CREATE INDEX IF NOT EXISTS idx_discussions_issue_id
|
CREATE INDEX IF NOT EXISTS idx_discussions_issue_id
|
||||||
@@ -1329,7 +1258,7 @@ CREATE INDEX IF NOT EXISTS idx_discussions_mr_id
|
|||||||
ON discussions(merge_request_id);
|
ON discussions(merge_request_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
The first partial index serves the primary use case (author-scoped queries) with `COLLATE NOCASE` matching the query's case-insensitive author comparison. The second serves project-scoped date-range queries (`--since`/`--until` without `--author`). The third serves path-centric DiffNote queries (`--path src/auth/` combined with project and date filters). All three exclude system notes, which are filtered out by default. The discussion indexes accelerate the JOIN path used by all note queries.
|
The first partial index serves the primary use case (author-scoped queries) with `COLLATE NOCASE` matching the query's case-insensitive author comparison. The second serves project-scoped date-range queries (`--since`/`--until` without `--author`). Both exclude system notes, which are filtered out by default. The discussion indexes accelerate the JOIN path used by all note queries.
|
||||||
|
|
||||||
**Register in `src/core/db.rs`:**
|
**Register in `src/core/db.rs`:**
|
||||||
|
|
||||||
@@ -1459,17 +1388,156 @@ The migration must:
|
|||||||
6. Same pattern for `dirty_sources` (simpler — no dependents)
|
6. Same pattern for `dirty_sources` (simpler — no dependents)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Backfill: seed all existing non-system notes into the dirty queue
|
-- Capture pre-migration counts for integrity verification
|
||||||
-- so the next generate-docs run creates documents for them.
|
CREATE TEMP TABLE _pre_counts AS
|
||||||
-- Uses LEFT JOIN to skip notes that already have documents (idempotent).
|
SELECT
|
||||||
-- ON CONFLICT DO NOTHING handles notes already in the dirty queue.
|
(SELECT COUNT(*) FROM documents) AS doc_count,
|
||||||
INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
(SELECT COUNT(*) FROM document_labels) AS label_count,
|
||||||
SELECT 'note', n.id, CAST(strftime('%s', 'now') AS INTEGER) * 1000
|
(SELECT COUNT(*) FROM document_paths) AS path_count,
|
||||||
FROM notes n
|
(SELECT COUNT(*) FROM dirty_sources) AS dirty_count;
|
||||||
LEFT JOIN documents d
|
|
||||||
ON d.source_type = 'note' AND d.source_id = n.id
|
-- Rebuild dirty_sources with expanded CHECK
|
||||||
WHERE n.is_system = 0 AND d.id IS NULL
|
CREATE TABLE dirty_sources_new (
|
||||||
ON CONFLICT(source_type, source_id) DO NOTHING;
|
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
queued_at INTEGER NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_attempt_at INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
next_attempt_at INTEGER,
|
||||||
|
PRIMARY KEY(source_type, source_id)
|
||||||
|
);
|
||||||
|
INSERT INTO dirty_sources_new SELECT * FROM dirty_sources;
|
||||||
|
DROP TABLE dirty_sources;
|
||||||
|
ALTER TABLE dirty_sources_new RENAME TO dirty_sources;
|
||||||
|
CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at);
|
||||||
|
|
||||||
|
-- Rebuild documents (must preserve FTS consistency)
|
||||||
|
-- Step 1: Save junction table data
|
||||||
|
CREATE TABLE _doc_labels_backup AS SELECT * FROM document_labels;
|
||||||
|
CREATE TABLE _doc_paths_backup AS SELECT * FROM document_paths;
|
||||||
|
|
||||||
|
-- Step 2: Drop FTS triggers (they reference 'documents')
|
||||||
|
DROP TRIGGER IF EXISTS documents_ai;
|
||||||
|
DROP TRIGGER IF EXISTS documents_ad;
|
||||||
|
DROP TRIGGER IF EXISTS documents_au;
|
||||||
|
|
||||||
|
-- Step 3: Drop junction tables (they FK to documents)
|
||||||
|
DROP TABLE document_labels;
|
||||||
|
DROP TABLE document_paths;
|
||||||
|
|
||||||
|
-- Step 4: Rebuild documents with updated CHECK
|
||||||
|
CREATE TABLE documents_new (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||||
|
author_username TEXT,
|
||||||
|
label_names TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER,
|
||||||
|
url TEXT,
|
||||||
|
title TEXT,
|
||||||
|
content_text TEXT NOT NULL,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
labels_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
paths_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
is_truncated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
truncated_reason TEXT CHECK (
|
||||||
|
truncated_reason IN (
|
||||||
|
'token_limit_middle_drop','single_note_oversized','first_last_oversized',
|
||||||
|
'hard_cap_oversized'
|
||||||
|
)
|
||||||
|
OR truncated_reason IS NULL
|
||||||
|
),
|
||||||
|
UNIQUE(source_type, source_id)
|
||||||
|
);
|
||||||
|
INSERT INTO documents_new SELECT * FROM documents;
|
||||||
|
DROP TABLE documents;
|
||||||
|
ALTER TABLE documents_new RENAME TO documents;
|
||||||
|
|
||||||
|
-- Step 5: Recreate indexes
|
||||||
|
CREATE INDEX idx_documents_project_updated ON documents(project_id, updated_at);
|
||||||
|
CREATE INDEX idx_documents_author ON documents(author_username);
|
||||||
|
CREATE INDEX idx_documents_source ON documents(source_type, source_id);
|
||||||
|
CREATE INDEX idx_documents_hash ON documents(content_hash);
|
||||||
|
|
||||||
|
-- Step 6: Recreate junction tables
|
||||||
|
CREATE TABLE document_labels (
|
||||||
|
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
label_name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(document_id, label_name)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE INDEX idx_document_labels_label ON document_labels(label_name);
|
||||||
|
|
||||||
|
CREATE TABLE document_paths (
|
||||||
|
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(document_id, path)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE INDEX idx_document_paths_path ON document_paths(path);
|
||||||
|
|
||||||
|
-- Step 7: Restore junction table data
|
||||||
|
INSERT INTO document_labels SELECT * FROM _doc_labels_backup;
|
||||||
|
INSERT INTO document_paths SELECT * FROM _doc_paths_backup;
|
||||||
|
DROP TABLE _doc_labels_backup;
|
||||||
|
DROP TABLE _doc_paths_backup;
|
||||||
|
|
||||||
|
-- Step 8: Recreate FTS triggers
|
||||||
|
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
||||||
|
INSERT INTO documents_fts(rowid, title, content_text)
|
||||||
|
VALUES (new.id, COALESCE(new.title, ''), new.content_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
||||||
|
INSERT INTO documents_fts(documents_fts, rowid, title, content_text)
|
||||||
|
VALUES('delete', old.id, COALESCE(old.title, ''), old.content_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER documents_au AFTER UPDATE ON documents
|
||||||
|
WHEN old.title IS NOT new.title OR old.content_text != new.content_text
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO documents_fts(documents_fts, rowid, title, content_text)
|
||||||
|
VALUES('delete', old.id, COALESCE(old.title, ''), old.content_text);
|
||||||
|
INSERT INTO documents_fts(rowid, title, content_text)
|
||||||
|
VALUES (new.id, COALESCE(new.title, ''), new.content_text);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Step 9: Rebuild FTS index to be safe
|
||||||
|
INSERT INTO documents_fts(documents_fts) VALUES('rebuild');
|
||||||
|
|
||||||
|
-- Step 10: Defense-in-depth cleanup triggers for note documents.
|
||||||
|
-- These fire when a note is deleted or flipped to system, ensuring orphaned
|
||||||
|
-- documents/dirty_sources entries cannot survive even if a future code path
|
||||||
|
-- deletes notes outside the normal sweep functions (Work Chunk 0B).
|
||||||
|
-- The sweep functions handle the common path; these triggers are the safety net.
|
||||||
|
CREATE TRIGGER notes_ad_cleanup AFTER DELETE ON notes
|
||||||
|
WHEN old.is_system = 0
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM documents
|
||||||
|
WHERE source_type = 'note' AND source_id = old.id;
|
||||||
|
DELETE FROM dirty_sources
|
||||||
|
WHERE source_type = 'note' AND source_id = old.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- If a note is reclassified from user to system (unlikely but possible via
|
||||||
|
-- API changes), remove its document artifacts since system notes don't get documents.
|
||||||
|
CREATE TRIGGER notes_au_system_cleanup AFTER UPDATE OF is_system ON notes
|
||||||
|
WHEN old.is_system = 0 AND new.is_system = 1
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM documents
|
||||||
|
WHERE source_type = 'note' AND source_id = new.id;
|
||||||
|
DELETE FROM dirty_sources
|
||||||
|
WHERE source_type = 'note' AND source_id = new.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Step 11: Integrity verification (moved to migration tests)
|
||||||
|
-- Note: RAISE(ABORT, ...) in standalone SELECT is not valid SQLite usage outside
|
||||||
|
-- triggers/CHECK constraints. Integrity checks are enforced in the migration test
|
||||||
|
-- suite instead (see test_migration_023_integrity_checks_pass). This keeps migration
|
||||||
|
-- SQL portable and avoids relying on SQLite-version-specific behavior.
|
||||||
|
|
||||||
|
DROP TABLE _pre_counts;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Register in `src/core/db.rs`:**
|
**Register in `src/core/db.rs`:**
|
||||||
@@ -1483,7 +1551,59 @@ Add to the `MIGRATIONS` array (after migration 022):
|
|||||||
),
|
),
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** This is a data-only migration — no schema changes. It's safe to run on empty databases (no notes = no-op). On databases with existing notes, it queues them for document generation on the next `lore generate-docs` or `lore sync` run.
|
`LATEST_SCHEMA_VERSION` auto-derives from `MIGRATIONS.len()` — no manual change needed.
|
||||||
|
|
||||||
|
**Migration integrity tests** (add to migration test module):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn test_migration_023_integrity_checks_pass() {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
// Run all migrations up to 022, then insert test data
|
||||||
|
// Run migration 023
|
||||||
|
// 1. Verify pre/post row count equality for documents, document_labels,
|
||||||
|
// document_paths, and dirty_sources
|
||||||
|
// 2. Verify PRAGMA foreign_key_check returns empty result set
|
||||||
|
// 3. Verify documents_fts row count matches documents row count after rebuild
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_023_fts_rebuild_consistent() {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
// Insert several documents with different source types
|
||||||
|
// Verify: SELECT COUNT(*) FROM documents_fts == SELECT COUNT(*) FROM documents
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_023_note_delete_trigger_cleans_document() {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
// Setup: project, issue, discussion, non-system note
|
||||||
|
// Insert note document (source_type='note', source_id=note.id)
|
||||||
|
// Delete the note row directly (simulating a non-sweep deletion path)
|
||||||
|
// Assert: document row for that note is gone (trigger fired)
|
||||||
|
// Assert: dirty_sources entry (if any) is gone
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_023_note_system_flip_trigger_cleans_document() {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
// Setup: project, issue, discussion, non-system note with a document
|
||||||
|
// UPDATE notes SET is_system = 1 WHERE id = note_id
|
||||||
|
// Assert: document row for that note is gone (trigger fired)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_023_system_note_delete_trigger_does_not_fire() {
|
||||||
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
// Setup: system note (is_system = 1) — no document exists
|
||||||
|
// Delete the system note row
|
||||||
|
// Assert: no error (trigger WHEN clause skips system notes)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1607,7 +1727,7 @@ fn test_note_document_inherits_parent_labels() {
|
|||||||
link_issue_label(&conn, 1, 1);
|
link_issue_label(&conn, 1, 1);
|
||||||
link_issue_label(&conn, 1, 2);
|
link_issue_label(&conn, 1, 2);
|
||||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Hello"), 1710460800000, false, None, None);
|
insert_note(&conn, 1, 100, 1, Some("jdefting"), Some("Comment"), 1000, false, None, None);
|
||||||
|
|
||||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||||
assert_eq!(doc.labels, vec!["backend", "security"]);
|
assert_eq!(doc.labels, vec!["backend", "security"]);
|
||||||
@@ -1620,7 +1740,7 @@ fn test_note_document_mr_labels() {
|
|||||||
insert_label(&conn, 1, "review");
|
insert_label(&conn, 1, "review");
|
||||||
link_mr_label(&conn, 1, 1);
|
link_mr_label(&conn, 1, 1);
|
||||||
insert_discussion(&conn, 1, "MergeRequest", None, Some(1));
|
insert_discussion(&conn, 1, "MergeRequest", None, Some(1));
|
||||||
insert_note(&conn, 1, 100, 1, Some("reviewer"), Some("LGTM"), 1710460800000, false, None, None);
|
insert_note(&conn, 1, 100, 1, Some("reviewer"), Some("LGTM"), 1000, false, None, None);
|
||||||
|
|
||||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||||
assert_eq!(doc.labels, vec!["review"]);
|
assert_eq!(doc.labels, vec!["review"]);
|
||||||
@@ -1631,7 +1751,7 @@ fn test_note_document_system_note_returns_none() {
|
|||||||
let conn = setup_discussion_test_db();
|
let conn = setup_discussion_test_db();
|
||||||
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
||||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||||
insert_note(&conn, 1, 100, 1, Some("bot"), Some("assigned to @alice"), 1710460800000, true, None, None);
|
insert_note(&conn, 1, 100, 1, Some("bot"), Some("assigned to @alice"), 1000, true, None, None);
|
||||||
|
|
||||||
let result = extract_note_document(&conn, 1).unwrap();
|
let result = extract_note_document(&conn, 1).unwrap();
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
@@ -1650,7 +1770,7 @@ fn test_note_document_orphaned_discussion() {
|
|||||||
let conn = setup_discussion_test_db();
|
let conn = setup_discussion_test_db();
|
||||||
insert_issue(&conn, 99, 10, Some("Deleted"), None, "opened", None, None);
|
insert_issue(&conn, 99, 10, Some("Deleted"), None, "opened", None, None);
|
||||||
insert_discussion(&conn, 1, "Issue", Some(99), None);
|
insert_discussion(&conn, 1, "Issue", Some(99), None);
|
||||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Hello"), 1710460800000, false, None, None);
|
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Hello"), 1000, false, None, None);
|
||||||
conn.execute("PRAGMA foreign_keys = OFF", []).unwrap();
|
conn.execute("PRAGMA foreign_keys = OFF", []).unwrap();
|
||||||
conn.execute("DELETE FROM issues WHERE id = 99", []).unwrap();
|
conn.execute("DELETE FROM issues WHERE id = 99", []).unwrap();
|
||||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||||
@@ -1664,7 +1784,7 @@ fn test_note_document_hash_deterministic() {
|
|||||||
let conn = setup_discussion_test_db();
|
let conn = setup_discussion_test_db();
|
||||||
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
||||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Comment"), 1710460800000, false, None, None);
|
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Comment"), 1000, false, None, None);
|
||||||
|
|
||||||
let doc1 = extract_note_document(&conn, 1).unwrap().unwrap();
|
let doc1 = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||||
let doc2 = extract_note_document(&conn, 1).unwrap().unwrap();
|
let doc2 = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||||
@@ -1678,11 +1798,10 @@ fn test_note_document_empty_body() {
|
|||||||
let conn = setup_discussion_test_db();
|
let conn = setup_discussion_test_db();
|
||||||
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
||||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some(""), 1710460800000, false, None, None);
|
insert_note(&conn, 1, 100, 1, Some("alice"), Some(""), 1000, false, None, None);
|
||||||
|
|
||||||
// Should still produce a document (body is optional in schema)
|
|
||||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||||
assert!(doc.content_text.contains("[[Note]]"));
|
assert!(doc.content_text.contains("--- Body ---"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1690,7 +1809,7 @@ fn test_note_document_null_body() {
|
|||||||
let conn = setup_discussion_test_db();
|
let conn = setup_discussion_test_db();
|
||||||
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
||||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||||
insert_note(&conn, 1, 100, 1, Some("alice"), None, 1710460800000, false, None, None);
|
insert_note(&conn, 1, 100, 1, Some("alice"), None, 1000, false, None, None);
|
||||||
|
|
||||||
// Should still produce a document (body is optional in schema)
|
// Should still produce a document (body is optional in schema)
|
||||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||||
@@ -1745,7 +1864,6 @@ pub fn extract_note_document(
|
|||||||
// parent_title: {title}
|
// parent_title: {title}
|
||||||
// note_type: {DiffNote|DiscussionNote|Comment}
|
// note_type: {DiffNote|DiscussionNote|Comment}
|
||||||
// author: @{author}
|
// author: @{author}
|
||||||
// author_id: {author_id} (only if non-null)
|
|
||||||
// created_at: {iso8601}
|
// created_at: {iso8601}
|
||||||
// resolved: {true|false} (only if resolvable)
|
// resolved: {true|false} (only if resolvable)
|
||||||
// path: {position_new_path}:{position_new_line} (only if DiffNote)
|
// path: {position_new_path}:{position_new_line} (only if DiffNote)
|
||||||
@@ -1873,7 +1991,7 @@ if !note.is_system && outcome.changed_semantics {
|
|||||||
|
|
||||||
### Work Chunk 2E: Generate-Docs Full Rebuild Support
|
### Work Chunk 2E: Generate-Docs Full Rebuild Support
|
||||||
|
|
||||||
**Files:** Search for where `robot-docs` manifest is generated (search for `robot-docs` or `RobotDocs` command handler)
|
**Files:** Search for where `generate-docs --full` seeds the dirty queue
|
||||||
|
|
||||||
**Depends on:** Work Chunk 2D
|
**Depends on:** Work Chunk 2D
|
||||||
|
|
||||||
@@ -1924,7 +2042,7 @@ ON CONFLICT(source_type, source_id) DO UPDATE SET
|
|||||||
|
|
||||||
**Files:** `src/cli/mod.rs`, `src/cli/commands/search.rs` (display code)
|
**Files:** `src/cli/mod.rs`, `src/cli/commands/search.rs` (display code)
|
||||||
|
|
||||||
**Depends on:** Work Chunks 2A-2E (documents must exist to be searched)
|
**Depends on:** Work Chunks 2A-2D (documents must exist to be searched)
|
||||||
|
|
||||||
#### Tests to Write First
|
#### Tests to Write First
|
||||||
|
|
||||||
@@ -2219,8 +2337,6 @@ lore notes --resolution unresolved # Tri-state resolution filter
|
|||||||
lore notes --contains "unwrap" --note-type DiffNote # Body substring + type filter
|
lore notes --contains "unwrap" --note-type DiffNote # Body substring + type filter
|
||||||
lore notes --author jdefting --format jsonl | wc -l # JSONL streaming
|
lore notes --author jdefting --format jsonl | wc -l # JSONL streaming
|
||||||
lore notes --format csv > /tmp/notes.csv && head -1 /tmp/notes.csv # CSV header
|
lore notes --format csv > /tmp/notes.csv && head -1 /tmp/notes.csv # CSV header
|
||||||
lore -J notes --author-id 12345 --since 365d # Immutable identity filter
|
|
||||||
lore -J notes --author-id 12345 --author jdefting # Combined: both must match (AND)
|
|
||||||
lore -J notes --gitlab-note-id 12345 # Precision filter: exact GitLab note
|
lore -J notes --gitlab-note-id 12345 # Precision filter: exact GitLab note
|
||||||
lore -J notes --discussion-id 42 # Precision filter: all notes in thread
|
lore -J notes --discussion-id 42 # Precision filter: all notes in thread
|
||||||
|
|
||||||
@@ -2292,15 +2408,6 @@ WHERE n.is_system = 0 AND d.issue_id = (SELECT id FROM issues WHERE iid = 42 AND
|
|||||||
ORDER BY n.created_at DESC, n.id DESC
|
ORDER BY n.created_at DESC, n.id DESC
|
||||||
LIMIT 50;"
|
LIMIT 50;"
|
||||||
# Should show SEARCH using idx_discussions_issue_id for the join
|
# Should show SEARCH using idx_discussions_issue_id for the join
|
||||||
|
|
||||||
sqlite3 ~/.local/share/lore/lore.db "EXPLAIN QUERY PLAN
|
|
||||||
SELECT n.id FROM notes n
|
|
||||||
JOIN discussions d ON n.discussion_id = d.id
|
|
||||||
JOIN projects p ON n.project_id = p.id
|
|
||||||
WHERE n.is_system = 0 AND n.project_id = 1 AND n.position_new_path LIKE 'src/auth/%' ESCAPE '\' AND n.created_at >= 1704067200000
|
|
||||||
ORDER BY n.created_at DESC, n.id DESC
|
|
||||||
LIMIT 50;"
|
|
||||||
# Should show SEARCH using idx_notes_project_path_created
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Operational checks:
|
Operational checks:
|
||||||
@@ -2396,16 +2503,16 @@ These recommendations were proposed during review and deliberately rejected. Doc
|
|||||||
|
|
||||||
- **Compact/slim metadata header for note documents** — rejected because the verbose key-value header is intentional. The structured fields (`source_type`, `note_gitlab_id`, `project`, `parent_type`, `parent_iid`, etc.) are what enable precise FTS and embedding search for queries like "jdefting's comments on authentication issues in project-one." The compact format (`@author on Issue#42 in project`) loses machine-parseable structure and reduces search precision. Metadata stored in document columns/labels/paths is not searchable via FTS — only `content_text` is FTS-indexed. The token cost of the header (~50 tokens) is negligible compared to typical note body length.
|
- **Compact/slim metadata header for note documents** — rejected because the verbose key-value header is intentional. The structured fields (`source_type`, `note_gitlab_id`, `project`, `parent_type`, `parent_iid`, etc.) are what enable precise FTS and embedding search for queries like "jdefting's comments on authentication issues in project-one." The compact format (`@author on Issue#42 in project`) loses machine-parseable structure and reduces search precision. Metadata stored in document columns/labels/paths is not searchable via FTS — only `content_text` is FTS-indexed. The token cost of the header (~50 tokens) is negligible compared to typical note body length.
|
||||||
|
|
||||||
|
- **Replace IID filter subqueries with JOIN predicates** — rejected because the subquery approach (`d.issue_id = (SELECT id FROM issues WHERE iid = ? AND project_id = ?)`) is clearer about intent and the performance difference is negligible. The subquery hits a UNIQUE index for a single-row lookup. The JOIN alternative (`i.iid = ? AND i.project_id = ?`) requires the query planner to choose the right join order, and the LEFT JOIN is already present for fetching parent metadata. Adding a WHERE clause on a LEFT JOINed table that may have NULL values for non-matching rows introduces subtle correctness risks. The subquery is self-contained and correct by construction.
|
||||||
|
|
||||||
|
- **Use `notes.gitlab_id` instead of `notes.id` as document `source_id`** (feedback-4, rec #1) — rejected because the entire existing document pipeline uses local row IDs as `source_id` for issues, MRs, and discussions. Switching to `gitlab_id` only for notes would create an inconsistent pattern where note documents use a different identity scheme than every other document type. This inconsistency would complicate the regenerator (which dispatches by `source_type` + `source_id`), the dirty tracker, and the full-rebuild seeder. Phase 0 specifically stabilizes local IDs via upsert, making them reliable for this purpose. If we ever want to move to `gitlab_id` globally, that's a cross-cutting migration affecting all source types — not a per-type decision.
|
||||||
|
|
||||||
|
- **`--aggregate` analytics mode for `lore notes`** (feedback-4, rec #5) — rejected because it's scope creep that edges into the explicitly excluded "reviewer profile" non-goal. The raw note output in JSONL/CSV format already supports downstream analysis via `jq`, `awk`, or LLM ingestion. Adding `--aggregate author|note_type|path|resolution` with `--top N` introduces a new query mode, output format, and interaction model. This belongs in a follow-up PRD focused on analytics primitives, not in the per-note search infrastructure PRD.
|
||||||
|
|
||||||
|
- **Source-type fairness / weighted scheduling in dirty queue processing** (feedback-4, rec #6) — rejected because the dirty queue is processed by a single-user CLI tool, not a multi-tenant service. The backfill of ~8k notes is a one-time event after upgrade. After the initial backfill, incremental syncs produce proportional dirty counts across source types. Adding weighted bucket scheduling (issue:3, MR:3, discussion:2, note:1) for a CLI that runs `generate-docs` on demand is premature optimization. If queue starvation becomes a real problem, we can add round-robin by source type then — but it hasn't happened with 2,800 documents and won't happen with 10,800.
|
||||||
|
|
||||||
- **Replace `fetch_complete: bool` with `FetchState` enum (`Complete`/`Partial`/`Failed`) and run_seen_at monotonicity checks** (feedback-5, rec #2) — rejected because the boolean captures the one bit of information that matters: did the fetch complete? `FetchState::Failed` is redundant with not reaching the sweep call site — if the fetch fails, we don't call sweep at all. The monotonicity check on `run_seen_at` adds complexity for a condition that can't occur in practice: `run_seen_at` is generated once per sync run and passed unchanged through all upserts. The boolean is sufficient and self-documenting.
|
- **Replace `fetch_complete: bool` with `FetchState` enum (`Complete`/`Partial`/`Failed`) and run_seen_at monotonicity checks** (feedback-5, rec #2) — rejected because the boolean captures the one bit of information that matters: did the fetch complete? `FetchState::Failed` is redundant with not reaching the sweep call site — if the fetch fails, we don't call sweep at all. The monotonicity check on `run_seen_at` adds complexity for a condition that can't occur in practice: `run_seen_at` is generated once per sync run and passed unchanged through all upserts. The boolean is sufficient and self-documenting.
|
||||||
|
|
||||||
- **Embedding dedup cache keyed by semantic text hash** (feedback-5, rec #5) — rejected because the existing `content_hash` dedup already prevents re-embedding unchanged documents. A semantic-text-only hash that ignores metadata would conflate genuinely different review contexts: two "LGTM" notes from different authors on different MRs are semantically distinct for the reviewer profiling use case (who said it, where, and when matters). The embedding pipeline handles ~8k notes comfortably without dedup optimization.
|
- **Embedding dedup cache keyed by semantic text hash** (feedback-5, rec #5) — rejected because the existing `content_hash` dedup already prevents re-embedding unchanged documents. A semantic-text-only hash that ignores metadata would conflate genuinely different review contexts: two "LGTM" notes from different authors on different MRs are semantically distinct for the reviewer profiling use case (who said it, where, and when matters). The embedding pipeline handles ~8k notes comfortably without dedup optimization.
|
||||||
|
|
||||||
- **Derived review signal labels (`signal:nit`, `signal:blocking`, `signal:security`)** (feedback-5, rec #6) — rejected because (a) it encroaches on the explicitly excluded reviewer profiling scope, (b) heuristic signal derivation (regex for "nit:", keyword matching for "security") is inherently fragile and would require ongoing maintenance as review vocabulary evolves, and (c) the raw note text already supports downstream LLM-based analysis that produces far more accurate signal classification than static keyword matching. This belongs in the downstream profiling PRD where LLM-based classification can be done properly.
|
- **Derived review signal labels (`signal:nit`, `signal:blocking`, `signal:security`)** (feedback-5, rec #6) — rejected because (a) it encroaches on the explicitly excluded reviewer profiling scope, (b) heuristic signal derivation (regex for "nit:", keyword matching for "security") is inherently fragile and would require ongoing maintenance as review vocabulary evolves, and (c) the raw note text already supports downstream LLM-based analysis that produces far more accurate signal classification than static keyword matching. This belongs in the downstream profiling PRD where LLM-based classification can be done properly.
|
||||||
|
|
||||||
- **Replace `last_seen_at` sweep marker with monotonic `sync_run_id`** (feedback-6, rec #3) — rejected because it introduces a new `sync_runs` table, a new column (`last_seen_run_id`), and changes sweep mechanics across both issue and MR ingestion paths. The `last_seen_at` approach is already battle-tested in the MR discussion path and works correctly for a single-user local CLI. Clock skew isn't a real concern when the same process generates timestamps within a single sync run. The engineering cost (new table, migration, plumbing through all callers) far exceeds the theoretical risk it mitigates.
|
|
||||||
|
|
||||||
- **Materialize stale-note set with temp table during sweep** (feedback-6, rec #4) — rejected because the subquery `SELECT id FROM notes WHERE discussion_id = ? AND last_seen_at < ?` runs against a UNIQUE(gitlab_id) index and SQLite's query optimizer handles repeated identical subqueries efficiently. Adding `CREATE TEMP TABLE` / `DROP TABLE` DDL adds transaction complexity for negligible performance gain on typical thread sizes (< 100 notes per discussion). The defense-in-depth triggers from Work Chunk 2A already guarantee consistency even if a subquery somehow produced different results across statements (which it can't within a transaction).
|
|
||||||
|
|
||||||
- **Move historical note backfill from migration to resumable runtime job** (feedback-6, rec #5) — rejected because the migration backfill is a single `INSERT...SELECT` that seeds dirty_sources from the notes table. On 8k notes, this executes in under a second on SQLite. Moving it to a runtime job adds resumability state tracking, a new code path in `generate-docs`/`sync`, and the risk that users forget to run the backfill. The migration approach is simpler, atomic, runs exactly once, and is guaranteed to execute on upgrade. If the note count were 1M+, runtime batching would be justified — at 8k it's premature.
|
|
||||||
|
|
||||||
- **Property/invariant tests with proptest** (feedback-6, rec #8) — rejected because the plan already has extensive example-based tests covering all four invariants mentioned (stable local IDs across re-syncs, no orphan documents after sweeps, partial-fetch safety, idempotent full rebuilds). Adding `proptest` as a dependency for randomized testing introduces nondeterministic CI behavior, slower test runs, and harder-to-debug failures. The deterministic example-based tests provide equivalent coverage with better debuggability. If specific invariants prove fragile in practice, targeted property tests can be added later — but speculative fuzz testing at plan time is premature.
|
|
||||||
|
|||||||
250
plans/plan-to-beads-v2-draft.md
Normal file
250
plans/plan-to-beads-v2-draft.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# plan-to-beads v2 — Draft for Review
|
||||||
|
|
||||||
|
This is a draft of the improved skill. Review before applying to `~/.claude/skills/plan-to-beads/SKILL.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: plan-to-beads
|
||||||
|
description: Transforms markdown implementation plans into granular, agent-ready beads with dependency graphs. Each bead is fully self-contained — an agent can execute it with zero external context. Triggers on "break down this plan", "create beads from", "convert to beads", "make issues from plan".
|
||||||
|
argument-hint: "[path/to/plan.md]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan to Beads Conversion
|
||||||
|
|
||||||
|
## The Prime Directive
|
||||||
|
|
||||||
|
**Every bead must be executable by an agent that has ONLY the bead description.** No plan document. No Slack context. No "see the PRD." The bead IS the spec. If an agent can't start coding within 60 seconds of reading the bead, it's not ready.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ 1. PARSE │──▶│ 2. MINE │──▶│ 3. BUILD │──▶│ 4. LINK │──▶│ 5. AUDIT │
|
||||||
|
│ Structure│ │ Context │ │ Beads │ │ Deps │ │ Quality │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Parse Structure
|
||||||
|
|
||||||
|
Read the plan document. Identify:
|
||||||
|
- **Epics**: Major sections / phases / milestones
|
||||||
|
- **Tasks**: Implementable units with clear outcomes (1-4 hour scope)
|
||||||
|
- **Subtasks**: Granular steps within tasks
|
||||||
|
|
||||||
|
### 2. Mine Context
|
||||||
|
|
||||||
|
This is the critical step. For EACH identified task, extract everything an implementing agent will need.
|
||||||
|
|
||||||
|
#### From the plan document:
|
||||||
|
|
||||||
|
| Extract | Where to look | Example |
|
||||||
|
|---------|--------------|---------|
|
||||||
|
| **Rationale** | Intro paragraphs, "why" sections | "We need this because the current approach causes N+1 queries" |
|
||||||
|
| **Approach details** | Implementation notes, code snippets, architecture decisions | "Use a 5-stage pipeline: SEED → HYDRATE → ..." |
|
||||||
|
| **Test requirements** | TDD sections, acceptance criteria, "verify by" notes | "Test that empty input returns empty vec" |
|
||||||
|
| **Edge cases & risks** | Warnings, gotchas, "watch out for" notes | "Multi-byte UTF-8 chars can cause panics at byte boundaries" |
|
||||||
|
| **Data shapes** | Type definitions, struct descriptions, API contracts | "TimelineEvent { kind: EventKind, timestamp: DateTime, ... }" |
|
||||||
|
| **File paths** | Explicit mentions or inferable from module structure | "src/core/timeline_seed.rs" |
|
||||||
|
| **Dependencies on other tasks** | "requires X", "after Y is done", "uses Z from step N" | "Consumes the TimelineEvent struct from the types task" |
|
||||||
|
| **Verification commands** | Test commands, CLI invocations, expected outputs | "cargo test timeline_seed -- --nocapture" |
|
||||||
|
|
||||||
|
#### From the codebase:
|
||||||
|
|
||||||
|
Search the codebase to supplement what the plan says:
|
||||||
|
- Find existing files mentioned or implied by the plan
|
||||||
|
- Discover patterns the task should follow (e.g., how existing similar modules are structured)
|
||||||
|
- Check test files for naming conventions and test infrastructure in use
|
||||||
|
- Confirm exact file paths rather than guessing
|
||||||
|
|
||||||
|
Use codebase search tools (WarpGrep, Explore agent, or targeted Grep/Glob) appropriate to the scope of what you need to find.
|
||||||
|
|
||||||
|
### 3. Build Beads
|
||||||
|
|
||||||
|
Use `br` exclusively.
|
||||||
|
|
||||||
|
| Type | Priority | Command |
|
||||||
|
|------|----------|---------|
|
||||||
|
| Epic | 1 | `br create "Epic: [Title]" -p 1` |
|
||||||
|
| Task | 2-3 | `br create "[Verb] [Object]" -p 2` |
|
||||||
|
| Subtask | 3-4 | `br q "[Verb] [Object]"` |
|
||||||
|
|
||||||
|
**Granularity target**: Each bead completable in 1-4 hours by one agent.
|
||||||
|
|
||||||
|
#### Description Templates
|
||||||
|
|
||||||
|
Use the **full template** for all task-level beads. Use the **light template** only for trivially small tasks (config change, single-line fix, add a re-export).
|
||||||
|
|
||||||
|
##### Full Template (default)
|
||||||
|
|
||||||
|
```
|
||||||
|
## Background
|
||||||
|
[WHY this exists. What problem it solves. How it fits into the larger system.
|
||||||
|
Include enough context that an agent unfamiliar with the project understands
|
||||||
|
the purpose. Reference architectural patterns in use.]
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
[HOW to implement. Be specific:
|
||||||
|
- Data structures / types to create or use (include field names and types)
|
||||||
|
- Algorithms or patterns to follow
|
||||||
|
- Code snippets from the plan if available
|
||||||
|
- Which existing code to reference for patterns (exact file paths)]
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Specified (from plan — implement as-is)
|
||||||
|
- [ ] <criteria explicitly stated in the plan>
|
||||||
|
- [ ] <criteria explicitly stated in the plan>
|
||||||
|
|
||||||
|
### Proposed (inferred — confirm with user before implementing) [?]
|
||||||
|
- [ ] [?] <criteria the agent inferred but the plan didn't specify>
|
||||||
|
- [ ] [?] <criteria the agent inferred but the plan didn't specify>
|
||||||
|
|
||||||
|
**ASSUMPTION RULE**: If proposed criteria exceed ~30% of total, STOP.
|
||||||
|
The bead needs human input before it's ready for implementation. Flag it
|
||||||
|
in the audit output and ask the user to refine the ACs.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
[Exact paths to create or modify. Confirmed by searching the codebase.]
|
||||||
|
- CREATE: src/foo/bar.rs
|
||||||
|
- MODIFY: src/foo/mod.rs (add pub mod bar)
|
||||||
|
- MODIFY: tests/foo_tests.rs (add test module)
|
||||||
|
|
||||||
|
## TDD Anchor
|
||||||
|
[The first test to write. This grounds the agent's work.]
|
||||||
|
RED: Write `test_<name>` in `<test_file>` that asserts <specific behavior>.
|
||||||
|
GREEN: Implement the minimal code to make it pass.
|
||||||
|
VERIFY: <project's test command> <pattern>
|
||||||
|
|
||||||
|
[If the plan specifies additional tests, list them all:]
|
||||||
|
- test_empty_input_returns_empty_vec
|
||||||
|
- test_single_issue_produces_one_event
|
||||||
|
- test_handles_missing_fields_gracefully
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
[Gotchas, risks, things that aren't obvious. Pulled from the plan's warnings,
|
||||||
|
known issues, or your analysis of the approach.]
|
||||||
|
- <edge case 1>
|
||||||
|
- <edge case 2>
|
||||||
|
|
||||||
|
## Dependency Context
|
||||||
|
[For each dependency, explain WHAT it provides that this bead consumes.
|
||||||
|
Not just "depends on bd-xyz" but "uses the `TimelineEvent` struct and
|
||||||
|
`SeedConfig` type defined in bd-xyz".]
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Light Template (trivially small tasks only)
|
||||||
|
|
||||||
|
Use this ONLY when the task is a one-liner or pure mechanical change (add a re-export, flip a config flag, rename a constant). If there's any ambiguity about approach, use the full template.
|
||||||
|
|
||||||
|
```
|
||||||
|
## What
|
||||||
|
[One sentence: what to do and where.]
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] <single binary criterion>
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- MODIFY: <exact path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Link Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
br dep add [blocker-id] [blocked-id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependency patterns:
|
||||||
|
- Types/structs → code that uses them
|
||||||
|
- Infrastructure (DB, config) → features that need them
|
||||||
|
- Core logic → extensions/enhancements
|
||||||
|
- Tests may depend on test helpers
|
||||||
|
|
||||||
|
**Critical**: When linking deps, update the "Dependency Context" section in the blocked bead to describe exactly what it receives from the blocker.
|
||||||
|
|
||||||
|
### 5. Audit Quality
|
||||||
|
|
||||||
|
Before reporting, review EVERY bead against this checklist:
|
||||||
|
|
||||||
|
| Check | Pass criteria |
|
||||||
|
|-------|--------------|
|
||||||
|
| **Self-contained?** | Agent can start coding in 60 seconds with ONLY this description |
|
||||||
|
| **TDD anchor?** | First test to write is named and described |
|
||||||
|
| **Binary criteria?** | Every acceptance criterion is pass/fail, not subjective |
|
||||||
|
| **Exact paths?** | File paths verified against codebase, not guessed |
|
||||||
|
| **Edge cases?** | At least 1 non-obvious gotcha identified |
|
||||||
|
| **Dep context?** | Each dependency explains WHAT it provides, not just its ID |
|
||||||
|
| **Approach specifics?** | Types, field names, patterns — not "implement the thing" |
|
||||||
|
| **Assumption budget?** | Proposed [?] criteria are <30% of total ACs |
|
||||||
|
|
||||||
|
If a bead fails any check, fix it before moving on. If the assumption budget is exceeded, flag the bead for human review rather than inventing more ACs.
|
||||||
|
|
||||||
|
## Assumption & AC Guidance
|
||||||
|
|
||||||
|
Agents filling in beads will inevitably encounter gaps in the plan. The rules:
|
||||||
|
|
||||||
|
1. **Never silently fill gaps.** If the plan doesn't specify a behavior, don't assume one and bury it in the ACs. Mark it `[?]` so the implementing agent knows to ask.
|
||||||
|
|
||||||
|
2. **Specify provenance on every AC.** Specified = from the plan. Proposed = your inference. The implementing agent treats these differently:
|
||||||
|
- **Specified**: implement without question
|
||||||
|
- **Proposed [?]**: pause and confirm with the user before implementing
|
||||||
|
|
||||||
|
3. **The 30% rule.** If more than ~30% of ACs on a bead are proposed/inferred, the plan was too vague for this task. Don't create the bead as-is. Instead:
|
||||||
|
- Create it with status noting "needs AC refinement"
|
||||||
|
- List the open questions explicitly
|
||||||
|
- Flag it in the output report under "Beads Needing Human Input"
|
||||||
|
|
||||||
|
4. **Prefer smaller scope over more assumptions.** If you're unsure whether a task should handle edge case X, make the bead's scope explicitly exclude it and note it as a potential follow-up. A bead that does less but does it right beats one that guesses wrong.
|
||||||
|
|
||||||
|
5. **Implementing agents: honor the markers.** When you encounter `[?]` on an AC, you MUST ask the user before implementing that behavior. Do not silently resolve it in either direction.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
After completion, report:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Beads Created: N total (X epics, Y tasks, Z subtasks)
|
||||||
|
|
||||||
|
### Quality Audit
|
||||||
|
- Beads scoring 4+: N/N (target: 100%)
|
||||||
|
- [list any beads that needed extra attention and why]
|
||||||
|
|
||||||
|
### Beads Needing Human Input
|
||||||
|
[List any beads where proposed ACs exceeded 30%, or where significant
|
||||||
|
ambiguity in the plan made self-contained descriptions impossible.
|
||||||
|
Include the specific open questions for each.]
|
||||||
|
|
||||||
|
### Critical Path
|
||||||
|
[blocker] → [blocked] → [blocked]
|
||||||
|
|
||||||
|
### Ready to Start
|
||||||
|
- bd-xxx: [Title] — [one-line summary of what agent will do]
|
||||||
|
- bd-yyy: [Title] — [one-line summary of what agent will do]
|
||||||
|
|
||||||
|
### Dependency Graph
|
||||||
|
[Brief visualization or description of the dep structure]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risk Tiers
|
||||||
|
|
||||||
|
| Operation | Tier | Behavior |
|
||||||
|
|-----------|------|----------|
|
||||||
|
| `br create` | SAFE | Auto-proceed |
|
||||||
|
| `br dep add` | SAFE | Auto-proceed |
|
||||||
|
| `br update --description` | CAUTION | Verify content |
|
||||||
|
| Bulk creation (>20 beads) | CAUTION | Confirm count first |
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why it's bad | Fix |
|
||||||
|
|-------------|-------------|-----|
|
||||||
|
| "Implement the pipeline stage" | Agent doesn't know WHAT to implement | Name the types, the function signatures, the test |
|
||||||
|
| "See plan for details" | Plan isn't available to the agent | Copy the relevant details INTO the bead |
|
||||||
|
| "Files: probably src/foo/" | Agent wastes time finding the right file | Search the codebase, confirm exact paths |
|
||||||
|
| "Should work correctly" | Not binary, not testable | "test_x passes" or "output matches Y" |
|
||||||
|
| No TDD anchor | Agent doesn't know where to start | Always specify the first test to write |
|
||||||
|
| "Depends on bd-xyz" (without context) | Agent doesn't know what bd-xyz provides | "Uses FooStruct and bar() function from bd-xyz" |
|
||||||
|
| Single-line description | Score 1 bead, agent is stuck | Use the full template, every section |
|
||||||
|
| Silently invented ACs | User surprised by implementation choices | Mark inferred ACs with [?], honor the 30% rule |
|
||||||
|
```
|
||||||
134
plans/time-decay-expert-scoring.feedback-6.md
Normal file
134
plans/time-decay-expert-scoring.feedback-6.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
I avoided everything already listed in your `Rejected Ideas` section and focused on net-new upgrades.
|
||||||
|
|
||||||
|
1. Centralize MR temporal semantics in one `mr_activity` CTE (architecture + correctness)
|
||||||
|
Why this improves the plan: right now the state-aware timestamp logic is repeated across multiple signal branches, while `closed_mr_multiplier` is applied later in Rust by string state checks. That split is brittle. A single `mr_activity` CTE removes drift risk, simplifies query maintenance, and avoids per-row state-string handling in Rust.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ SQL Restructure
|
||||||
|
+mr_activity AS (
|
||||||
|
+ SELECT
|
||||||
|
+ m.id AS mr_id,
|
||||||
|
+ m.project_id,
|
||||||
|
+ m.author_username,
|
||||||
|
+ m.state,
|
||||||
|
+ CASE
|
||||||
|
+ WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at)
|
||||||
|
+ WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at)
|
||||||
|
+ ELSE COALESCE(m.updated_at, m.created_at)
|
||||||
|
+ END AS activity_ts,
|
||||||
|
+ CASE
|
||||||
|
+ WHEN m.state = 'closed' THEN ?5
|
||||||
|
+ ELSE 1.0
|
||||||
|
+ END AS state_mult
|
||||||
|
+ FROM merge_requests m
|
||||||
|
+ WHERE m.state IN ('opened','merged','closed')
|
||||||
|
+),
|
||||||
|
@@
|
||||||
|
-... {state_aware_ts} AS seen_at, m.state AS mr_state
|
||||||
|
+... a.activity_ts AS seen_at, a.state_mult
|
||||||
|
@@
|
||||||
|
-SELECT username, signal, mr_id, qty, ts, mr_state FROM aggregated
|
||||||
|
+SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Parameterize `reviewer_min_note_chars` and tighten config validation (robustness)
|
||||||
|
Why this improves the plan: inlining `reviewer_min_note_chars` into SQL text creates statement-cache churn and avoidable SQL-text variability. Also, current validation misses finite-range guards (`NaN`, absurd half-lives). Parameterization + stronger validation reduces weird failure modes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ 1. ScoringConfig (config.rs)
|
||||||
|
- reviewer_min_note_chars must be >= 0
|
||||||
|
+ reviewer_min_note_chars must be <= 4096
|
||||||
|
+ all half-life values must be <= 3650 (10 years safety cap)
|
||||||
|
+ closed_mr_multiplier must be finite and in (0.0, 1.0]
|
||||||
|
@@ SQL Restructure
|
||||||
|
-AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= {reviewer_min_note_chars}
|
||||||
|
+AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add path canonicalization before probes/scoring (correctness + UX)
|
||||||
|
Why this improves the plan: rename-awareness helps only after path resolution succeeds. Inputs like `./src//foo.rs` or inconsistent trailing slashes can still miss. Canonicalizing query paths up front reduces false negatives and ambiguous suffix behavior.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ 3a. Path Resolution Probes (who.rs)
|
||||||
|
+Add `normalize_query_path()` before `build_path_query()`:
|
||||||
|
+- strip leading `./`
|
||||||
|
+- collapse repeated `/`
|
||||||
|
+- trim whitespace
|
||||||
|
+- preserve trailing `/` only for explicit prefix intent
|
||||||
|
+Expose both `path_input_original` and `path_input_normalized` in `resolved_input`.
|
||||||
|
@@ New Tests
|
||||||
|
+test_path_normalization_handles_dot_and_double_slash
|
||||||
|
+test_path_normalization_preserves_explicit_prefix_semantics
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add epsilon-based tie buckets for stable ranking (determinism)
|
||||||
|
Why this improves the plan: even with deterministic summation order, tiny `powf` platform differences can reorder near-equal scores. Tie bucketing keeps ordering stable and user-meaningful.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ 4. Rust-Side Aggregation (who.rs)
|
||||||
|
-Sort on raw `f64` score — `(raw_score DESC, last_seen DESC, username ASC)`.
|
||||||
|
+Sort using a tie bucket:
|
||||||
|
+`score_bucket = (raw_score / 1e-9).floor() as i64`
|
||||||
|
+Order by `(score_bucket DESC, raw_score DESC, last_seen DESC, username ASC)`.
|
||||||
|
+This preserves precision while preventing meaningless micro-delta reorderings.
|
||||||
|
@@ New Tests
|
||||||
|
+test_near_equal_scores_use_stable_tie_bucket_order
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Add `--diagnose-score` aggregated diagnostics (operability)
|
||||||
|
Why this improves the plan: `--explain-score` tells “why this user scored”, but not “why this query behaved oddly” (path ambiguity, dedup collapse, old_path contribution share, filtered bots, window exclusions). Lightweight aggregate diagnostics are high-value without per-MR drill-down complexity.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ CLI changes (who.rs)
|
||||||
|
+Add `--diagnose-score` flag (compatible with `--explain-score`, incompatible with `--detail`).
|
||||||
|
+When enabled, include:
|
||||||
|
+- matched_notes_raw_count
|
||||||
|
+- matched_notes_dedup_count
|
||||||
|
+- matched_file_changes_raw_count
|
||||||
|
+- matched_file_changes_dedup_count
|
||||||
|
+- rows_excluded_by_window_upper_bound
|
||||||
|
+- users_filtered_by_excluded_usernames
|
||||||
|
+- query_elapsed_ms
|
||||||
|
@@ Robot output
|
||||||
|
+`diagnostics` object emitted only when `--diagnose-score` is set.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Add probe-optimized indexes for path resolution (performance)
|
||||||
|
Why this improves the plan: current proposed indexes are optimized for scoring joins, but `build_path_query()` and `suffix_probe()` run existence/path-only probes where `author_username` is not constrained. Dedicated probe indexes will materially reduce latency for path lookup modes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ 6. Index Migration (db.rs)
|
||||||
|
+-- Fast exact/prefix/suffix path probes on notes (no author predicate)
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_notes_new_path_project_created
|
||||||
|
+ ON notes(position_new_path, project_id, created_at)
|
||||||
|
+ WHERE note_type = 'DiffNote' AND is_system = 0 AND position_new_path IS NOT NULL;
|
||||||
|
+
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
||||||
|
+ ON notes(position_old_path, project_id, created_at)
|
||||||
|
+ WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Add multi-path expert scoring (`--path` repeatable) with dedup across paths (feature + utility)
|
||||||
|
Why this improves the plan: current model is single-path centric. Real ownership questions are usually subsystem-level. Repeatable paths/prefixes let users ask “who knows auth stack?” in one call. Dedup by `(username, signal, mr_id)` avoids double-counting same MR touching multiple requested paths.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/plan.md b/plan.md
|
||||||
|
@@ CLI/feature scope
|
||||||
|
+Add repeatable `--path` in expert mode:
|
||||||
|
+`lore who --expert --path src/auth/ --path src/session/`
|
||||||
|
+Optional `--path-file <file>` for large path sets (one per line).
|
||||||
|
@@ SQL Restructure
|
||||||
|
+Add `requested_paths` CTE and match each source against that set.
|
||||||
|
+Ensure dedup key includes `(username, signal, mr_id)` so one MR contributes once per signal even if multiple paths match.
|
||||||
|
@@ New Tests
|
||||||
|
+test_multi_path_query_unions_results_without_double_counting
|
||||||
|
+test_multi_path_with_overlap_prefixes_is_idempotent
|
||||||
|
```
|
||||||
|
|
||||||
|
These 7 revisions keep your current model direction intact, but reduce correctness drift risk, harden edge handling, improve query observability, and make the feature materially more useful for real ownership workflows.
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
plan: true
|
plan: true
|
||||||
title: ""
|
title: ""
|
||||||
status: iterating
|
status: iterating
|
||||||
iteration: 5
|
iteration: 6
|
||||||
target_iterations: 8
|
target_iterations: 8
|
||||||
beads_revision: 1
|
beads_revision: 1
|
||||||
related_plans: []
|
related_plans: []
|
||||||
created: 2026-02-08
|
created: 2026-02-08
|
||||||
updated: 2026-02-09
|
updated: 2026-02-12
|
||||||
---
|
---
|
||||||
|
|
||||||
# Time-Decay Expert Scoring Model
|
# Time-Decay Expert Scoring Model
|
||||||
@@ -70,7 +70,8 @@ Author/reviewer signals are deduplicated per MR (one signal per distinct MR). No
|
|||||||
1. **`src/core/config.rs`** — Add half-life fields + assigned-only reviewer config to `ScoringConfig`; add config validation
|
1. **`src/core/config.rs`** — Add half-life fields + assigned-only reviewer config to `ScoringConfig`; add config validation
|
||||||
2. **`src/cli/commands/who.rs`** — Core changes:
|
2. **`src/cli/commands/who.rs`** — Core changes:
|
||||||
- Add `half_life_decay()` pure function
|
- Add `half_life_decay()` pure function
|
||||||
- Restructure `query_expert()`: SQL returns hybrid-aggregated signal rows with timestamps (MR-level for author/reviewer, note-count-per-MR for notes), Rust applies decay + `log2(1+count)` + final ranking
|
- Add `normalize_query_path()` for input canonicalization before path resolution
|
||||||
|
- Restructure `query_expert()`: SQL returns hybrid-aggregated signal rows with timestamps and state multiplier (MR-level for author/reviewer, note-count-per-MR for notes), Rust applies decay + `log2(1+count)` + final ranking
|
||||||
- Match both `new_path` and `old_path` in all signal queries (rename awareness)
|
- Match both `new_path` and `old_path` in all signal queries (rename awareness)
|
||||||
- Extend rename awareness to `build_path_query()` probes and `suffix_probe()` (not just scoring)
|
- Extend rename awareness to `build_path_query()` probes and `suffix_probe()` (not just scoring)
|
||||||
- Split reviewer signal into participated vs assigned-only
|
- Split reviewer signal into participated vs assigned-only
|
||||||
@@ -106,10 +107,10 @@ pub struct ScoringConfig {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Config validation**: Add a `validate_scoring()` call in `Config::load_from_path()` after deserialization:
|
**Config validation**: Add a `validate_scoring()` call in `Config::load_from_path()` after deserialization:
|
||||||
- All `*_half_life_days` must be > 0 (prevents division by zero in decay function)
|
- All `*_half_life_days` must be > 0 and <= 3650 (prevents division by zero in decay function; rejects absurd 10+ year half-lives that would effectively disable decay)
|
||||||
- All `*_weight` / `*_bonus` must be >= 0 (negative weights produce nonsensical scores)
|
- All `*_weight` / `*_bonus` must be >= 0 (negative weights produce nonsensical scores)
|
||||||
- `closed_mr_multiplier` must be in `(0.0, 1.0]` (0 would discard closed MRs entirely; >1 would over-weight them)
|
- `closed_mr_multiplier` must be finite (not NaN/Inf) and in `(0.0, 1.0]` (0 would discard closed MRs entirely; >1 would over-weight them; NaN/Inf would propagate through all scores)
|
||||||
- `reviewer_min_note_chars` must be >= 0 (0 disables the filter; typical useful values: 10-50)
|
- `reviewer_min_note_chars` must be >= 0 and <= 4096 (0 disables the filter; 4096 is a sane upper bound — no real review comment needs to be longer to qualify; typical useful values: 10-50)
|
||||||
- `excluded_usernames` entries must be non-empty strings (no blank entries)
|
- `excluded_usernames` entries must be non-empty strings (no blank entries)
|
||||||
- Return `LoreError::ConfigInvalid` with a clear message on failure
|
- Return `LoreError::ConfigInvalid` with a clear message on failure
|
||||||
|
|
||||||
@@ -126,9 +127,9 @@ fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
|
|||||||
|
|
||||||
### 3. SQL Restructure (who.rs)
|
### 3. SQL Restructure (who.rs)
|
||||||
|
|
||||||
The SQL uses **CTE-based dual-path matching** and **hybrid aggregation**. Rather than repeating `OR old_path` in every signal subquery, two foundational CTEs (`matched_notes`, `matched_file_changes`) centralize path matching. A third CTE (`reviewer_participation`) precomputes which reviewers actually left DiffNotes, avoiding correlated `EXISTS`/`NOT EXISTS` subqueries.
|
The SQL uses **CTE-based dual-path matching**, a **centralized `mr_activity` CTE**, and **hybrid aggregation**. Rather than repeating `OR old_path` in every signal subquery, two foundational CTEs (`matched_notes`, `matched_file_changes`) centralize path matching. A `mr_activity` CTE centralizes the state-aware timestamp and state multiplier in one place, eliminating repetition of the CASE expression across signals 3, 4a, 4b. A fourth CTE (`reviewer_participation`) precomputes which reviewers actually left DiffNotes, avoiding correlated `EXISTS`/`NOT EXISTS` subqueries.
|
||||||
|
|
||||||
MR-level signals return one row per (username, signal, mr_id) with a timestamp; note signals return one row per (username, mr_id) with `note_count` and `max_ts`. This keeps row counts bounded (dozens to low hundreds per path) while giving Rust the data it needs for decay and `log2(1+count)`.
|
MR-level signals return one row per (username, signal, mr_id) with a timestamp and state multiplier; note signals return one row per (username, mr_id) with `note_count` and `max_ts`. This keeps row counts bounded (dozens to low hundreds per path) while giving Rust the data it needs for decay and `log2(1+count)`.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
WITH matched_notes_raw AS (
|
WITH matched_notes_raw AS (
|
||||||
@@ -177,6 +178,24 @@ matched_file_changes AS (
|
|||||||
SELECT DISTINCT merge_request_id, project_id
|
SELECT DISTINCT merge_request_id, project_id
|
||||||
FROM matched_file_changes_raw
|
FROM matched_file_changes_raw
|
||||||
),
|
),
|
||||||
|
mr_activity AS (
|
||||||
|
-- Centralized state-aware timestamps and state multiplier.
|
||||||
|
-- Defined once, referenced by all file-change-based signals (3, 4a, 4b).
|
||||||
|
-- Scoped to MRs matched by file changes to avoid materializing the full MR table.
|
||||||
|
SELECT DISTINCT
|
||||||
|
m.id AS mr_id,
|
||||||
|
m.author_username,
|
||||||
|
m.state,
|
||||||
|
CASE
|
||||||
|
WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at)
|
||||||
|
WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at)
|
||||||
|
ELSE COALESCE(m.updated_at, m.created_at)
|
||||||
|
END AS activity_ts,
|
||||||
|
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||||
|
FROM merge_requests m
|
||||||
|
JOIN matched_file_changes mfc ON mfc.merge_request_id = m.id
|
||||||
|
WHERE m.state IN ('opened','merged','closed')
|
||||||
|
),
|
||||||
reviewer_participation AS (
|
reviewer_participation AS (
|
||||||
-- Precompute which (mr_id, username) pairs have substantive DiffNote participation.
|
-- Precompute which (mr_id, username) pairs have substantive DiffNote participation.
|
||||||
-- Materialized once, then joined against mr_reviewers to classify.
|
-- Materialized once, then joined against mr_reviewers to classify.
|
||||||
@@ -185,17 +204,20 @@ reviewer_participation AS (
|
|||||||
-- reviewer from 3-point to 10-point weight, defeating the purpose of the split.
|
-- reviewer from 3-point to 10-point weight, defeating the purpose of the split.
|
||||||
-- Note: mn.id refers back to notes.id, so we join notes to access the body column
|
-- Note: mn.id refers back to notes.id, so we join notes to access the body column
|
||||||
-- (not carried in matched_notes to avoid bloating that CTE with body text).
|
-- (not carried in matched_notes to avoid bloating that CTE with body text).
|
||||||
|
-- ?6 is the configured reviewer_min_note_chars value (default 20).
|
||||||
SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username
|
SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username
|
||||||
FROM matched_notes mn
|
FROM matched_notes mn
|
||||||
JOIN discussions d ON mn.discussion_id = d.id
|
JOIN discussions d ON mn.discussion_id = d.id
|
||||||
JOIN notes n_body ON mn.id = n_body.id
|
JOIN notes n_body ON mn.id = n_body.id
|
||||||
WHERE d.merge_request_id IS NOT NULL
|
WHERE d.merge_request_id IS NOT NULL
|
||||||
AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= {reviewer_min_note_chars}
|
AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6
|
||||||
),
|
),
|
||||||
raw AS (
|
raw AS (
|
||||||
-- Signal 1: DiffNote reviewer (individual notes for note_cnt)
|
-- Signal 1: DiffNote reviewer (individual notes for note_cnt)
|
||||||
|
-- Computes state_mult inline (not via mr_activity) because this joins through discussions, not file changes.
|
||||||
SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal,
|
SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal,
|
||||||
m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at, m.state AS mr_state
|
m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at,
|
||||||
|
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||||
FROM matched_notes mn
|
FROM matched_notes mn
|
||||||
JOIN discussions d ON mn.discussion_id = d.id
|
JOIN discussions d ON mn.discussion_id = d.id
|
||||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||||
@@ -205,8 +227,10 @@ raw AS (
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
-- Signal 2: DiffNote MR author
|
-- Signal 2: DiffNote MR author
|
||||||
|
-- Computes state_mult inline (same reason as signal 1).
|
||||||
SELECT m.author_username AS username, 'diffnote_author' AS signal,
|
SELECT m.author_username AS username, 'diffnote_author' AS signal,
|
||||||
m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at, m.state AS mr_state
|
m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at,
|
||||||
|
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||||
FROM merge_requests m
|
FROM merge_requests m
|
||||||
JOIN discussions d ON d.merge_request_id = m.id
|
JOIN discussions d ON d.merge_request_id = m.id
|
||||||
JOIN matched_notes mn ON mn.discussion_id = d.id
|
JOIN matched_notes mn ON mn.discussion_id = d.id
|
||||||
@@ -216,65 +240,59 @@ raw AS (
|
|||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
-- Signal 3: MR author via file changes (state-aware timestamp)
|
-- Signal 3: MR author via file changes (uses mr_activity CTE for timestamp + state_mult)
|
||||||
SELECT m.author_username AS username, 'file_author' AS signal,
|
SELECT a.author_username AS username, 'file_author' AS signal,
|
||||||
m.id AS mr_id, NULL AS note_id,
|
a.mr_id, NULL AS note_id,
|
||||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
a.activity_ts AS seen_at, a.state_mult
|
||||||
FROM matched_file_changes mfc
|
FROM mr_activity a
|
||||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
WHERE a.author_username IS NOT NULL
|
||||||
WHERE m.author_username IS NOT NULL
|
AND a.activity_ts >= ?2
|
||||||
AND m.state IN ('opened','merged','closed')
|
AND a.activity_ts < ?4
|
||||||
AND {state_aware_ts} >= ?2
|
|
||||||
AND {state_aware_ts} < ?4
|
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
-- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path)
|
-- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path)
|
||||||
SELECT r.username AS username, 'file_reviewer_participated' AS signal,
|
SELECT r.username AS username, 'file_reviewer_participated' AS signal,
|
||||||
m.id AS mr_id, NULL AS note_id,
|
a.mr_id, NULL AS note_id,
|
||||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
a.activity_ts AS seen_at, a.state_mult
|
||||||
FROM matched_file_changes mfc
|
FROM mr_activity a
|
||||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
|
||||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
|
||||||
JOIN reviewer_participation rp ON rp.mr_id = m.id AND rp.username = r.username
|
|
||||||
WHERE r.username IS NOT NULL
|
WHERE r.username IS NOT NULL
|
||||||
AND (m.author_username IS NULL OR r.username != m.author_username)
|
AND (a.author_username IS NULL OR r.username != a.author_username)
|
||||||
AND m.state IN ('opened','merged','closed')
|
AND a.activity_ts >= ?2
|
||||||
AND {state_aware_ts} >= ?2
|
AND a.activity_ts < ?4
|
||||||
AND {state_aware_ts} < ?4
|
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
-- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path)
|
-- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path)
|
||||||
SELECT r.username AS username, 'file_reviewer_assigned' AS signal,
|
SELECT r.username AS username, 'file_reviewer_assigned' AS signal,
|
||||||
m.id AS mr_id, NULL AS note_id,
|
a.mr_id, NULL AS note_id,
|
||||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
a.activity_ts AS seen_at, a.state_mult
|
||||||
FROM matched_file_changes mfc
|
FROM mr_activity a
|
||||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
|
||||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
LEFT JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
|
||||||
LEFT JOIN reviewer_participation rp ON rp.mr_id = m.id AND rp.username = r.username
|
|
||||||
WHERE rp.username IS NULL -- NOT in participation set
|
WHERE rp.username IS NULL -- NOT in participation set
|
||||||
AND r.username IS NOT NULL
|
AND r.username IS NOT NULL
|
||||||
AND (m.author_username IS NULL OR r.username != m.author_username)
|
AND (a.author_username IS NULL OR r.username != a.author_username)
|
||||||
AND m.state IN ('opened','merged','closed')
|
AND a.activity_ts >= ?2
|
||||||
AND {state_aware_ts} >= ?2
|
AND a.activity_ts < ?4
|
||||||
AND {state_aware_ts} < ?4
|
|
||||||
),
|
),
|
||||||
aggregated AS (
|
aggregated AS (
|
||||||
-- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts)
|
-- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts)
|
||||||
SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, mr_state
|
SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult
|
||||||
FROM raw WHERE signal != 'diffnote_reviewer'
|
FROM raw WHERE signal != 'diffnote_reviewer'
|
||||||
GROUP BY username, signal, mr_id
|
GROUP BY username, signal, mr_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
-- Note signals: 1 row per (username, mr_id) with note_count and max_ts
|
-- Note signals: 1 row per (username, mr_id) with note_count and max_ts
|
||||||
SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts, mr_state
|
SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult
|
||||||
FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL
|
FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL
|
||||||
GROUP BY username, mr_id
|
GROUP BY username, mr_id
|
||||||
)
|
)
|
||||||
SELECT username, signal, mr_id, qty, ts, mr_state FROM aggregated WHERE username IS NOT NULL
|
SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE username IS NOT NULL
|
||||||
```
|
```
|
||||||
|
|
||||||
Where `{state_aware_ts}` is the state-aware timestamp expression (defined in the next section), `{path_op}` is either `= ?1` or `LIKE ?1 ESCAPE '\\'` depending on the path query type, `?4` is the `as_of_ms` exclusive upper bound (defaults to `now_ms` when `--as-of` is not specified), and `{reviewer_min_note_chars}` is the configured `reviewer_min_note_chars` value (default 20, inlined as a literal in the SQL string). The `>= ?2 AND < ?4` pattern (half-open interval) ensures that when `--as-of` is set to a past date, events at or after that date are excluded — without this, "future" events would leak in with full weight, breaking reproducibility. The exclusive upper bound avoids edge-case ambiguity when events have timestamps exactly equal to the as-of value.
|
Where `{path_op}` is either `= ?1` or `LIKE ?1 ESCAPE '\\'` depending on the path query type, `?2` is `since_ms`, `?3` is the optional project_id, `?4` is the `as_of_ms` exclusive upper bound (defaults to `now_ms` when `--as-of` is not specified), `?5` is the `closed_mr_multiplier` (default 0.5, bound as a parameter), and `?6` is the configured `reviewer_min_note_chars` value (default 20, bound as a parameter). The `>= ?2 AND < ?4` pattern (half-open interval) ensures that when `--as-of` is set to a past date, events at or after that date are excluded — without this, "future" events would leak in with full weight, breaking reproducibility. The exclusive upper bound avoids edge-case ambiguity when events have timestamps exactly equal to the as-of value.
|
||||||
|
|
||||||
**Rationale for CTE-based dual-path matching**: The previous approach (repeating `OR old_path` in every signal subquery) duplicated the path matching logic 5 times. Factoring it into foundational CTEs (`matched_notes_raw` → `matched_notes`, `matched_file_changes_raw` → `matched_file_changes`) means path matching is defined once, each index branch is explicit, and adding future path resolution logic (e.g., alias chains) only requires changes in one place. The UNION ALL + dedup pattern ensures SQLite uses the optimal index for each path column independently.
|
**Rationale for CTE-based dual-path matching**: The previous approach (repeating `OR old_path` in every signal subquery) duplicated the path matching logic 5 times. Factoring it into foundational CTEs (`matched_notes_raw` → `matched_notes`, `matched_file_changes_raw` → `matched_file_changes`) means path matching is defined once, each index branch is explicit, and adding future path resolution logic (e.g., alias chains) only requires changes in one place. The UNION ALL + dedup pattern ensures SQLite uses the optimal index for each path column independently.
|
||||||
|
|
||||||
@@ -308,7 +326,21 @@ Both columns already exist in the schema (`notes.position_old_path` from migrati
|
|||||||
- **Signal 4a** (`file_reviewer_participated`): User is in `mr_reviewers` AND appears in the `reviewer_participation` CTE (left DiffNotes on the path for that MR). Gets `reviewer_weight` (10) and `reviewer_half_life_days` (90).
|
- **Signal 4a** (`file_reviewer_participated`): User is in `mr_reviewers` AND appears in the `reviewer_participation` CTE (left DiffNotes on the path for that MR). Gets `reviewer_weight` (10) and `reviewer_half_life_days` (90).
|
||||||
- **Signal 4b** (`file_reviewer_assigned`): User is in `mr_reviewers` but NOT in the `reviewer_participation` CTE. Gets `reviewer_assignment_weight` (3) and `reviewer_assignment_half_life_days` (45).
|
- **Signal 4b** (`file_reviewer_assigned`): User is in `mr_reviewers` but NOT in the `reviewer_participation` CTE. Gets `reviewer_assignment_weight` (3) and `reviewer_assignment_half_life_days` (45).
|
||||||
|
|
||||||
### 3a. Path Resolution Probes (who.rs)
|
**Rationale for `mr_activity` CTE**: The previous approach repeated the state-aware CASE expression and `m.state` column in signals 3, 4a, and 4b, with the `closed_mr_multiplier` applied later in Rust by string-matching on `mr_state`. This split was brittle — the CASE expression could drift between signal branches, and per-row state-string handling in Rust was unnecessary indirection. The `mr_activity` CTE defines the timestamp and multiplier once, scoped to matched MRs only (via JOIN with `matched_file_changes`) to avoid materializing the full MR table. Signals 3, 4a, 4b now reference `a.activity_ts` and `a.state_mult` directly. Signals 1 and 2 (DiffNote-based) still compute `state_mult` inline because they join through `discussions`, not `matched_file_changes`, and adding them to `mr_activity` would require a second join path that doesn't simplify anything.
|
||||||
|
|
||||||
|
**Rationale for parameterized `reviewer_min_note_chars` and `closed_mr_multiplier`**: Previous iterations inlined `reviewer_min_note_chars` as a literal in the SQL string and kept `closed_mr_multiplier` in Rust only. Binding both as SQL parameters (`?5` for `closed_mr_multiplier`, `?6` for `reviewer_min_note_chars`) eliminates statement-cache churn (the SQL text is identical regardless of config values), avoids SQL-text variability that complicates EXPLAIN QUERY PLAN analysis, and centralizes the multiplier application in SQL for file-change signals. The DiffNote signals (1, 2) still compute `state_mult` inline because they don't go through `mr_activity`.
|
||||||
|
|
||||||
|
### 3a. Path Canonicalization and Resolution Probes (who.rs)
|
||||||
|
|
||||||
|
**Path canonicalization**: Before any path resolution or scoring, normalize the user's input path via `normalize_query_path()`:
|
||||||
|
- Strip leading `./` (e.g., `./src/foo.rs` → `src/foo.rs`)
|
||||||
|
- Collapse repeated `/` (e.g., `src//foo.rs` → `src/foo.rs`)
|
||||||
|
- Trim leading/trailing whitespace
|
||||||
|
- Preserve trailing `/` only when present — it signals explicit prefix intent
|
||||||
|
|
||||||
|
This is applied once at the top of `run_who()` before `build_path_query()`. The robot JSON `resolved_input` includes both `path_input_original` (raw user input) and `path_input_normalized` (after canonicalization) for debugging transparency. The normalization is purely syntactic — no filesystem lookups, no canonicalization against the database.
|
||||||
|
|
||||||
|
**Path resolution probes**: Rename awareness must extend beyond scoring queries to the path resolution layer. Currently `build_path_query()` (line 457) and `suffix_probe()` (line 584) only check `position_new_path` and `new_path`. If a user queries an old path name, these probes return "not found" and the scoring query never runs.
|
||||||
|
|
||||||
Rename awareness must extend beyond scoring queries to the path resolution layer. Currently `build_path_query()` (line 457) and `suffix_probe()` (line 584) only check `position_new_path` and `new_path`. If a user queries an old path name, these probes return "not found" and the scoring query never runs.
|
Rename awareness must extend beyond scoring queries to the path resolution layer. Currently `build_path_query()` (line 457) and `suffix_probe()` (line 584) only check `position_new_path` and `new_path`. If a user queries an old path name, these probes return "not found" and the scoring query never runs.
|
||||||
|
|
||||||
@@ -337,39 +369,29 @@ WHERE old_path IS NOT NULL
|
|||||||
|
|
||||||
This ensures that querying by an old filename (e.g., `login.rs` after it was renamed to `auth.rs`) still resolves to a usable path for scoring. The UNION deduplicates so the same path appearing in both old and new columns doesn't cause false ambiguity.
|
This ensures that querying by an old filename (e.g., `login.rs` after it was renamed to `auth.rs`) still resolves to a usable path for scoring. The UNION deduplicates so the same path appearing in both old and new columns doesn't cause false ambiguity.
|
||||||
|
|
||||||
**State-aware timestamps for file-change signals (signals 3, 4a, 4b)**: Replace `m.updated_at` with a state-aware expression:
|
**State-aware timestamps for file-change signals (signals 3, 4a, 4b)**: Centralized in the `mr_activity` CTE (see section 3). The CASE expression uses `merged_at` for merged MRs, `closed_at` for closed MRs, and `updated_at` for open MRs, with `created_at` as fallback when the preferred timestamp is NULL.
|
||||||
|
|
||||||
```sql
|
|
||||||
CASE
|
|
||||||
WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at)
|
|
||||||
WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at)
|
|
||||||
ELSE COALESCE(m.updated_at, m.created_at) -- opened / other
|
|
||||||
END AS activity_ts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rationale**: `updated_at` is noisy for merged MRs — it changes on label edits, title changes, rebases, and metadata touches, creating false recency. `merged_at` is the best indicator of when code expertise was formed (the moment the code entered the branch). But for **open MRs**, `updated_at` is actually the right signal because it reflects ongoing active work. `closed_at` anchors closed-without-merge MRs to their closure time (these represent review effort even if the code was abandoned). Each state gets the timestamp that best represents when expertise was last exercised.
|
**Rationale**: `updated_at` is noisy for merged MRs — it changes on label edits, title changes, rebases, and metadata touches, creating false recency. `merged_at` is the best indicator of when code expertise was formed (the moment the code entered the branch). But for **open MRs**, `updated_at` is actually the right signal because it reflects ongoing active work. `closed_at` anchors closed-without-merge MRs to their closure time (these represent review effort even if the code was abandoned). Each state gets the timestamp that best represents when expertise was last exercised.
|
||||||
|
|
||||||
### 4. Rust-Side Aggregation (who.rs)
|
### 4. Rust-Side Aggregation (who.rs)
|
||||||
|
|
||||||
For each username, accumulate into a struct with:
|
For each username, accumulate into a struct with:
|
||||||
- **Author MRs**: `HashMap<i64, (i64, String)>` (mr_id -> (max timestamp, mr_state)) from `diffnote_author` + `file_author` signals
|
- **Author MRs**: `HashMap<i64, (i64, f64)>` (mr_id -> (max timestamp, state_mult)) from `diffnote_author` + `file_author` signals
|
||||||
- **Reviewer Participated MRs**: `HashMap<i64, (i64, String)>` from `diffnote_reviewer` + `file_reviewer_participated` signals
|
- **Reviewer Participated MRs**: `HashMap<i64, (i64, f64)>` from `diffnote_reviewer` + `file_reviewer_participated` signals
|
||||||
- **Reviewer Assigned-Only MRs**: `HashMap<i64, (i64, String)>` from `file_reviewer_assigned` signals (excluding any MR already in participated set)
|
- **Reviewer Assigned-Only MRs**: `HashMap<i64, (i64, f64)>` from `file_reviewer_assigned` signals (excluding any MR already in participated set)
|
||||||
- **Notes per MR**: `HashMap<i64, (u32, i64, String)>` (mr_id -> (count, max_ts, mr_state)) from `note_group` rows in the aggregated query (already grouped per user+MR with note_count in `qty`). Used for `log2(1 + count)` diminishing returns.
|
- **Notes per MR**: `HashMap<i64, (u32, i64, f64)>` (mr_id -> (count, max_ts, state_mult)) from `note_group` rows in the aggregated query (already grouped per user+MR with note_count in `qty`). Used for `log2(1 + count)` diminishing returns.
|
||||||
- **Last seen**: max of all timestamps
|
- **Last seen**: max of all timestamps
|
||||||
- **Components** (when `--explain-score`): Track per-component f64 subtotals for `author`, `reviewer_participated`, `reviewer_assigned`, `notes`
|
- **Components** (when `--explain-score`): Track per-component f64 subtotals for `author`, `reviewer_participated`, `reviewer_assigned`, `notes`
|
||||||
|
|
||||||
The `mr_state` field from each SQL row is stored alongside the timestamp so the Rust-side can apply `closed_mr_multiplier` when `mr_state == "closed"`.
|
The `state_mult` field from each SQL row (already computed in SQL as 1.0 for merged/open or `closed_mr_multiplier` for closed) is stored alongside the timestamp — no string-matching on MR state needed in Rust.
|
||||||
|
|
||||||
Compute score as `f64` with **deterministic contribution ordering**: within each signal type, sort contributions by `(mr_id ASC)` before summing. This eliminates platform-dependent HashMap iteration order as a source of f64 rounding variance near ties, ensuring CI reproducibility without the complexity of compensated summation (Neumaier/Kahan). Each MR-level contribution is multiplied by `closed_mr_multiplier` (default 0.5) when the MR's state is `"closed"`:
|
Compute score as `f64` with **deterministic contribution ordering**: within each signal type, sort contributions by `(mr_id ASC)` before summing. This eliminates platform-dependent HashMap iteration order as a source of f64 rounding variance near ties, ensuring CI reproducibility without the complexity of compensated summation (Neumaier/Kahan). Each MR-level contribution is multiplied by its `state_mult` (already computed in SQL):
|
||||||
```
|
```
|
||||||
state_mult(mr) = if mr.state == "closed" { closed_mr_multiplier } else { 1.0 }
|
|
||||||
|
|
||||||
raw_score =
|
raw_score =
|
||||||
sum(author_weight * state_mult(mr) * decay(now - ts, author_hl) for (mr, ts) in author_mrs)
|
sum(author_weight * state_mult * decay(now - ts, author_hl) for (mr, ts, state_mult) in author_mrs)
|
||||||
+ sum(reviewer_weight * state_mult(mr) * decay(now - ts, reviewer_hl) for (mr, ts) in reviewer_participated)
|
+ sum(reviewer_weight * state_mult * decay(now - ts, reviewer_hl) for (mr, ts, state_mult) in reviewer_participated)
|
||||||
+ sum(reviewer_assignment_weight * state_mult(mr) * decay(now - ts, reviewer_assignment_hl) for (mr, ts) in reviewer_assigned)
|
+ sum(reviewer_assignment_weight * state_mult * decay(now - ts, reviewer_assignment_hl) for (mr, ts, state_mult) in reviewer_assigned)
|
||||||
+ sum(note_bonus * state_mult(mr) * log2(1 + count) * decay(now - ts, note_hl) for (mr, count, ts) in notes_per_mr)
|
+ sum(note_bonus * state_mult * log2(1 + count) * decay(now - ts, note_hl) for (mr, count, ts, state_mult) in notes_per_mr)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why include closed MRs?** A closed-without-merge MR still represents review effort and code familiarity — the reviewer read the diff, left comments, and engaged with the code even though it was ultimately abandoned. Excluding closed MRs entirely (the previous plan's approach) discarded this signal. The `closed_mr_multiplier` (default 0.5) halves the contribution, reflecting that the code never landed but the reviewer's cognitive engagement was real. This also eliminates the dead-code inconsistency where the state-aware CASE expression handled `closed` but the WHERE clause excluded it.
|
**Why include closed MRs?** A closed-without-merge MR still represents review effort and code familiarity — the reviewer read the diff, left comments, and engaged with the code even though it was ultimately abandoned. Excluding closed MRs entirely (the previous plan's approach) discarded this signal. The `closed_mr_multiplier` (default 0.5) halves the contribution, reflecting that the code never landed but the reviewer's cognitive engagement was real. This also eliminates the dead-code inconsistency where the state-aware CASE expression handled `closed` but the WHERE clause excluded it.
|
||||||
@@ -458,9 +480,16 @@ CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
|
|||||||
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
||||||
ON notes(discussion_id, author_username, created_at)
|
ON notes(discussion_id, author_username, created_at)
|
||||||
WHERE note_type = 'DiffNote' AND is_system = 0;
|
WHERE note_type = 'DiffNote' AND is_system = 0;
|
||||||
|
|
||||||
|
-- Support path resolution probes on old_path (build_path_query() and suffix_probe())
|
||||||
|
-- The existing idx_notes_diffnote_path_created covers new_path probes, but old_path probes
|
||||||
|
-- need their own index since probes don't constrain author_username.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
||||||
|
ON notes(position_old_path, project_id, created_at)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rationale**: The existing indexes cover `position_new_path` and `new_path` but not their `old_path` counterparts. Without these, the `OR old_path` clauses would force table scans on renamed files. The `reviewer_participation` CTE joins `matched_notes` -> `discussions` -> `merge_requests`, so an index on `(discussion_id, author_username)` speeds up the CTE materialization.
|
**Rationale**: The existing indexes cover `position_new_path` and `new_path` but not their `old_path` counterparts. Without these, the `OR old_path` clauses would force table scans on renamed files. The `reviewer_participation` CTE joins `matched_notes` -> `discussions` -> `merge_requests`, so an index on `(discussion_id, author_username)` speeds up the CTE materialization. The `idx_notes_old_path_project_created` index supports path resolution probes (`build_path_query()` and `suffix_probe()`) which run existence/path-only checks without constraining `author_username` — the scoring-oriented `idx_notes_old_path_author` has `author_username` as the second column, which is suboptimal for these probes.
|
||||||
|
|
||||||
**Schema note**: The `notes` table uses `discussion_id` as its FK to `discussions`, which in turn has `merge_request_id`. There is no `noteable_id` column on `notes`. The previous plan revision incorrectly referenced `noteable_id` — this is corrected.
|
**Schema note**: The `notes` table uses `discussion_id` as its FK to `discussions`, which in turn has `merge_request_id`. There is no `noteable_id` column on `notes`. The previous plan revision incorrectly referenced `noteable_id` — this is corrected.
|
||||||
|
|
||||||
@@ -526,6 +555,14 @@ Add timestamp-aware variants:
|
|||||||
|
|
||||||
**`test_null_timestamp_fallback_to_created_at`**: Insert a merged MR with `merged_at = NULL` (edge case: old data before the column was populated). The state-aware timestamp should fall back to `created_at`. Verify the score reflects `created_at`, not 0 or a panic.
|
**`test_null_timestamp_fallback_to_created_at`**: Insert a merged MR with `merged_at = NULL` (edge case: old data before the column was populated). The state-aware timestamp should fall back to `created_at`. Verify the score reflects `created_at`, not 0 or a panic.
|
||||||
|
|
||||||
|
**`test_path_normalization_handles_dot_and_double_slash`**: Call `normalize_query_path("./src//foo.rs")` — should return `"src/foo.rs"`. Call `normalize_query_path(" src/bar.rs ")` — should return `"src/bar.rs"`. Call `normalize_query_path("src/foo.rs")` — should return unchanged (already normalized). Call `normalize_query_path("")` — should return `""` (empty input passes through).
|
||||||
|
|
||||||
|
**`test_path_normalization_preserves_prefix_semantics`**: Call `normalize_query_path("./src/dir/")` — should return `"src/dir/"` (trailing slash preserved for prefix intent). Call `normalize_query_path("src/dir")` — should return `"src/dir"` (no trailing slash = file, not prefix).
|
||||||
|
|
||||||
|
**`test_config_validation_rejects_absurd_half_life`**: `ScoringConfig` with `author_half_life_days = 5000` (>3650 cap) should return `ConfigInvalid` error. Similarly, `reviewer_min_note_chars = 5000` (>4096 cap) should fail.
|
||||||
|
|
||||||
|
**`test_config_validation_rejects_nan_multiplier`**: `ScoringConfig` with `closed_mr_multiplier = f64::NAN` should return `ConfigInvalid` error. Same for `f64::INFINITY`.
|
||||||
|
|
||||||
#### Invariant tests (regression safety for ranking systems)
|
#### Invariant tests (regression safety for ranking systems)
|
||||||
|
|
||||||
**`test_score_monotonicity_by_age`**: For any single signal type, an older timestamp must never produce a higher score than a newer timestamp with the same weight and half-life. Generate N random (age, half_life) pairs and assert `decay(older) <= decay(newer)` for all.
|
**`test_score_monotonicity_by_age`**: For any single signal type, an older timestamp must never produce a higher score than a newer timestamp with the same weight and half-life. Generate N random (age, half_life) pairs and assert `decay(older) <= decay(newer)` for all.
|
||||||
@@ -554,6 +591,8 @@ The `test_expert_scoring_weights_are_configurable` test needs `..Default::defaul
|
|||||||
- Confirm that `matched_notes_raw` branch 1 uses the existing new_path index and branch 2 uses `idx_notes_old_path_author` (not a full table scan on either branch)
|
- Confirm that `matched_notes_raw` branch 1 uses the existing new_path index and branch 2 uses `idx_notes_old_path_author` (not a full table scan on either branch)
|
||||||
- Confirm that `matched_file_changes_raw` branch 1 uses `idx_mfc_new_path_project_mr` and branch 2 uses `idx_mfc_old_path_project_mr`
|
- Confirm that `matched_file_changes_raw` branch 1 uses `idx_mfc_new_path_project_mr` and branch 2 uses `idx_mfc_old_path_project_mr`
|
||||||
- Confirm that `reviewer_participation` CTE uses `idx_notes_diffnote_discussion_author`
|
- Confirm that `reviewer_participation` CTE uses `idx_notes_diffnote_discussion_author`
|
||||||
|
- Confirm that `mr_activity` CTE joins `merge_requests` via primary key from `matched_file_changes`
|
||||||
|
- Confirm that path resolution probes (old_path leg) use `idx_notes_old_path_project_created`
|
||||||
- Document the observed plan in a comment near the SQL for future regression reference
|
- Document the observed plan in a comment near the SQL for future regression reference
|
||||||
7. Performance baseline (manual, not CI-gated):
|
7. Performance baseline (manual, not CI-gated):
|
||||||
- Run `time cargo run --release -- who --path <exact-path>` on the real database for exact, prefix, and suffix modes
|
- Run `time cargo run --release -- who --path <exact-path>` on the real database for exact, prefix, and suffix modes
|
||||||
@@ -571,6 +610,7 @@ The `test_expert_scoring_weights_are_configurable` test needs `..Default::defaul
|
|||||||
- Spot-check that reviewers who only left "LGTM"-style notes are classified as assigned-only (not participated)
|
- Spot-check that reviewers who only left "LGTM"-style notes are classified as assigned-only (not participated)
|
||||||
- Verify closed MRs contribute at ~50% of equivalent merged MR scores via `--explain-score`
|
- Verify closed MRs contribute at ~50% of equivalent merged MR scores via `--explain-score`
|
||||||
- If the project has known bot accounts (e.g., renovate-bot), add them to `excluded_usernames` config and verify they no longer appear in results. Run again with `--include-bots` to confirm they reappear.
|
- If the project has known bot accounts (e.g., renovate-bot), add them to `excluded_usernames` config and verify they no longer appear in results. Run again with `--include-bots` to confirm they reappear.
|
||||||
|
- Test path normalization: `who --path ./src//foo.rs` and `who --path src/foo.rs` should produce identical results
|
||||||
|
|
||||||
## Accepted from External Review
|
## Accepted from External Review
|
||||||
|
|
||||||
@@ -614,6 +654,14 @@ Ideas incorporated from ChatGPT review (feedback-1 through feedback-4) that genu
|
|||||||
- **Performance baseline SLOs**: Added manual performance baseline step to verification — record timings for exact/prefix/suffix modes and flag >2x regressions. Kept lightweight (no CI gating, no synthetic benchmarks) to match the project's current maturity.
|
- **Performance baseline SLOs**: Added manual performance baseline step to verification — record timings for exact/prefix/suffix modes and flag >2x regressions. Kept lightweight (no CI gating, no synthetic benchmarks) to match the project's current maturity.
|
||||||
- **New tests**: `test_as_of_exclusive_upper_bound`, `test_excluded_usernames_filters_bots`, `test_include_bots_flag_disables_filtering`, `test_deterministic_accumulation_order` — cover the newly-accepted features.
|
- **New tests**: `test_as_of_exclusive_upper_bound`, `test_excluded_usernames_filters_bots`, `test_include_bots_flag_disables_filtering`, `test_deterministic_accumulation_order` — cover the newly-accepted features.
|
||||||
|
|
||||||
|
**From feedback-6 (ChatGPT review):**
|
||||||
|
- **Centralized `mr_activity` CTE**: The state-aware timestamp CASE expression and `closed_mr_multiplier` were repeated across signals 3, 4a, 4b with the multiplier applied later in Rust via string-matching on `mr_state`. This was brittle — the CASE could drift between branches and the Rust-side string matching was unnecessary indirection. A single `mr_activity` CTE defines both `activity_ts` and `state_mult` once, scoped to matched MRs only (via JOIN with `matched_file_changes`). Signals 1 and 2 still compute `state_mult` inline because they join through `discussions`, not `matched_file_changes`.
|
||||||
|
- **Parameterized `reviewer_min_note_chars` and `closed_mr_multiplier`**: Previously `reviewer_min_note_chars` was inlined as a literal in the SQL string and `closed_mr_multiplier` was applied only in Rust. Binding both as SQL parameters (`?5` for `closed_mr_multiplier`, `?6` for `reviewer_min_note_chars`) eliminates statement-cache churn, ensures identical SQL text regardless of config values, and simplifies EXPLAIN QUERY PLAN analysis.
|
||||||
|
- **Tightened config validation**: Added upper bounds — `*_half_life_days <= 3650` (10-year safety cap), `reviewer_min_note_chars <= 4096`, and `closed_mr_multiplier` must be finite (not NaN/Inf). These prevent absurd configurations from silently producing nonsensical results.
|
||||||
|
- **Path canonicalization via `normalize_query_path()`**: Inputs like `./src//foo.rs` or whitespace-padded paths could fail path resolution even when the file exists in the database. A simple syntactic normalization (strip `./`, collapse `//`, trim whitespace, preserve trailing `/`) runs before `build_path_query()` to reduce false negatives. No filesystem or database lookups — purely string manipulation.
|
||||||
|
- **Probe-optimized `idx_notes_old_path_project_created` index**: The scoring-oriented `idx_notes_old_path_author` index has `author_username` as its second column, which is suboptimal for path resolution probes that don't constrain author. A dedicated probe index on `(position_old_path, project_id, created_at)` ensures `build_path_query()` and `suffix_probe()` old_path lookups are efficient.
|
||||||
|
- **New tests**: `test_path_normalization_handles_dot_and_double_slash`, `test_path_normalization_preserves_prefix_semantics`, `test_config_validation_rejects_absurd_half_life`, `test_config_validation_rejects_nan_multiplier` — cover the path canonicalization and tightened validation logic.
|
||||||
|
|
||||||
## Rejected Ideas (with rationale)
|
## Rejected Ideas (with rationale)
|
||||||
|
|
||||||
These suggestions were considered during review but explicitly excluded from this iteration:
|
These suggestions were considered during review but explicitly excluded from this iteration:
|
||||||
@@ -635,3 +683,6 @@ These suggestions were considered during review but explicitly excluded from thi
|
|||||||
- **Full evidence drill-down in `--explain-score`** (feedback-5 #8): Proposes `--explain-score=summary|full` with per-MR evidence rows. Already rejected in feedback-2 #7. Component totals are sufficient for v1 debugging — they answer "which signal type drives this user's score." Per-MR drill-down requires additional SQL queries and significant output format complexity. Deferred unless component breakdowns prove insufficient.
|
- **Full evidence drill-down in `--explain-score`** (feedback-5 #8): Proposes `--explain-score=summary|full` with per-MR evidence rows. Already rejected in feedback-2 #7. Component totals are sufficient for v1 debugging — they answer "which signal type drives this user's score." Per-MR drill-down requires additional SQL queries and significant output format complexity. Deferred unless component breakdowns prove insufficient.
|
||||||
- **Neumaier compensated summation** (feedback-5 #7 partial): Accepted the sorting aspect for deterministic ordering, but rejected Neumaier/Kahan compensated summation. At the scale of dozens to low hundreds of contributions per user, the rounding error from naive f64 summation is on the order of 1e-14 — several orders of magnitude below any meaningful score difference. Compensated summation adds code complexity and a maintenance burden for no practical benefit at this scale.
|
- **Neumaier compensated summation** (feedback-5 #7 partial): Accepted the sorting aspect for deterministic ordering, but rejected Neumaier/Kahan compensated summation. At the scale of dozens to low hundreds of contributions per user, the rounding error from naive f64 summation is on the order of 1e-14 — several orders of magnitude below any meaningful score difference. Compensated summation adds code complexity and a maintenance burden for no practical benefit at this scale.
|
||||||
- **Automated CI benchmark gate** (feedback-5 #10 partial): Accepted manual performance baselines, but rejected automated CI regression gating with synthetic fixtures (100k/1M/5M notes). Building and maintaining benchmark infrastructure is a significant investment that's premature for a CLI tool with ~3 users. Manual timing checks during development are sufficient until performance becomes a real concern.
|
- **Automated CI benchmark gate** (feedback-5 #10 partial): Accepted manual performance baselines, but rejected automated CI regression gating with synthetic fixtures (100k/1M/5M notes). Building and maintaining benchmark infrastructure is a significant investment that's premature for a CLI tool with ~3 users. Manual timing checks during development are sufficient until performance becomes a real concern.
|
||||||
|
- **Epsilon-based tie buckets for ranking** (feedback-6 #4) — rejected because the plan already has deterministic contribution ordering by `mr_id` within each signal type, which eliminates HashMap-iteration nondeterminism. Platform-dependent `powf` differences at the scale of dozens to hundreds of contributions per user are sub-epsilon (order of 1e-15). If two users genuinely score within 1e-9 of each other, the existing tiebreak by `(last_seen DESC, username ASC)` is already meaningful and deterministic. Adding a bucketing layer introduces a magic epsilon constant and floor operation for a problem that doesn't manifest in practice.
|
||||||
|
- **`--diagnose-score` aggregated diagnostics flag** (feedback-6 #5) — rejected because this is diagnostic/debugging tooling that adds a new flag, new output format, and new counting logic (matched_notes_raw_count, dedup_count, window exclusions, etc.) across the SQL pipeline. The existing `--explain-score` component breakdown + manual EXPLAIN QUERY PLAN verification already covers the debugging need. The additional SQL instrumentation required (counting rows at each CTE stage) would complicate the query for a feature with unclear demand. A v2 addition if operational debugging becomes a recurring need.
|
||||||
|
- **Multi-path expert scoring (`--path` repeatable)** (feedback-6 #7) — rejected because this is a feature expansion, not a plan improvement for the time-decay model. Multi-path requires a `requested_paths` CTE, modified dedup logic keyed on `(username, signal, mr_id)` across paths, CLI parsing changes for repeatable `--path` and `--path-file`, and new test cases for overlap/prefix/dedup semantics. This is a separate bead/feature that should be designed independently — it's orthogonal to time-decay scoring and can be added later without requiring any changes to the decay model.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
plan: true
|
plan: true
|
||||||
title: "Gitlore TUI PRD v2 - FrankenTUI"
|
title: "Gitlore TUI PRD v2 - FrankenTUI"
|
||||||
status: iterating
|
status: iterating
|
||||||
iteration: 10
|
iteration: 11
|
||||||
target_iterations: 10
|
target_iterations: 10
|
||||||
beads_revision: 0
|
beads_revision: 0
|
||||||
related_plans: []
|
related_plans: []
|
||||||
@@ -135,7 +135,7 @@ We are making a deliberate bet that FrankenTUI's technical superiority justifies
|
|||||||
| Runtime panic leaves user blocked | High | Medium | Panic hook captures crash context (last 2000 events ring buffer + screen/nav/task/build/db snapshot), restores terminal, offers fallback CLI command. Retention: latest 20 crash files, oldest auto-pruned. |
|
| Runtime panic leaves user blocked | High | Medium | Panic hook captures crash context (last 2000 events ring buffer + screen/nav/task/build/db snapshot), restores terminal, offers fallback CLI command. Retention: latest 20 crash files, oldest auto-pruned. |
|
||||||
| Hard-to-reproduce input race bugs | Medium | Medium | Crash context ring buffer includes last 2000 normalized events + current screen + in-flight task keys/generations + build version + DB fingerprint for post-mortem replay |
|
| Hard-to-reproduce input race bugs | Medium | Medium | Crash context ring buffer includes last 2000 normalized events + current screen + in-flight task keys/generations + build version + DB fingerprint for post-mortem replay |
|
||||||
| Interrupted sync loses partial progress | Medium | Medium | Per-project fault isolation; failed lanes marked degraded while others continue. Resumable checkpoints planned for post-v1 (requires `sync_checkpoints` table). |
|
| Interrupted sync loses partial progress | Medium | Medium | Per-project fault isolation; failed lanes marked degraded while others continue. Resumable checkpoints planned for post-v1 (requires `sync_checkpoints` table). |
|
||||||
| Malicious URL in entity data opened in browser | Medium | Low | URL host validated against configured GitLab instance before `open`/`xdg-open` |
|
| Malicious URL in entity data opened in browser | Medium | Low | Validate scheme+host+port AND path pattern allowlist (`/-/issues/`, `/-/merge_requests/`, project issue/MR routes) before `open`/`xdg-open`. Unknown same-host paths require explicit confirm modal. |
|
||||||
| Terminal escape/control-sequence injection via issue/note text | High | Medium | Strip ANSI/OSC/control chars + C1 controls (U+0080..U+009F) + bidi overrides + directional marks (LRM/RLM/ALM) via `sanitize_for_terminal()` before render; origin-normalized URL validation before open; disable raw HTML in markdown rendering |
|
| Terminal escape/control-sequence injection via issue/note text | High | Medium | Strip ANSI/OSC/control chars + C1 controls (U+0080..U+009F) + bidi overrides + directional marks (LRM/RLM/ALM) via `sanitize_for_terminal()` before render; origin-normalized URL validation before open; disable raw HTML in markdown rendering |
|
||||||
|
|
||||||
### 3.2 Nightly Rust Strategy
|
### 3.2 Nightly Rust Strategy
|
||||||
@@ -2923,7 +2923,7 @@ Ensures the TUI displays the same data as the CLI robot mode, preventing drift b
|
|||||||
|
|
||||||
**Success criterion:** Parity suite passes on CI fixtures (S and M tiers). Parity is asserted by field-level comparison, not string formatting comparison — the TUI and CLI may format differently but must present the same underlying data.
|
**Success criterion:** Parity suite passes on CI fixtures (S and M tiers). Parity is asserted by field-level comparison, not string formatting comparison — the TUI and CLI may format differently but must present the same underlying data.
|
||||||
|
|
||||||
**Total estimated scope:** ~49 implementation days across 9 phases (increased from ~47 to account for filter DSL parser, render cache, progress coalescer, Quick Peek panel, ReaderLease interrupt handles, and generation-guarding all async Msg variants).
|
**Total estimated scope:** ~51 implementation days across 9 phases (increased from ~49 to account for snapshot fences, sync delta ledger, bootstrap screen, global scope pinning, concurrent pagination/write race tests, and cancellation race tests).
|
||||||
|
|
||||||
### 9.3 Phase 0 — Toolchain Gate
|
### 9.3 Phase 0 — Toolchain Gate
|
||||||
|
|
||||||
@@ -2969,6 +2969,8 @@ This is a hard gate. If Phase 0 fails, we evaluate alternatives before proceedin
|
|||||||
23. Single-instance lock enforced: second TUI launch attempt yields clear error message and non-zero exit.
|
23. Single-instance lock enforced: second TUI launch attempt yields clear error message and non-zero exit.
|
||||||
24. Sync stream stats are emitted and rendered; terminal events (completed/failed/cancelled) delivery is 100% under induced backpressure.
|
24. Sync stream stats are emitted and rendered; terminal events (completed/failed/cancelled) delivery is 100% under induced backpressure.
|
||||||
25. Entity cache provides near-instant reopen for Issue/MR detail views during Enter/Esc drill-in/out workflows; cache invalidated on sync completion.
|
25. Entity cache provides near-instant reopen for Issue/MR detail views during Enter/Esc drill-in/out workflows; cache invalidated on sync completion.
|
||||||
|
26. Concurrent pagination/write race test proves no duplicate or skipped rows within a pinned browse snapshot fence under concurrent sync writes.
|
||||||
|
27. Cancellation race test proves no cross-task interrupt bleed and no stuck loading state after rapid cancel-then-resubmit sequences.
|
||||||
|
|
||||||
**Performance SLO rationale:** Interactive TUI responsiveness requires sub-100ms for list operations and sub-250ms for search. Tiered fixtures catch scaling regressions at different data magnitudes — a query that's fast at 10k rows may degrade at 100k without proper indexing or pagination. Memory ceilings prevent unbounded growth from large in-memory result sets. These targets are validated with synthetic SQLite fixtures during Phase 0 and enforced as CI benchmark gates thereafter. Required indexes are documented and migration-backed before TUI GA.
|
**Performance SLO rationale:** Interactive TUI responsiveness requires sub-100ms for list operations and sub-250ms for search. Tiered fixtures catch scaling regressions at different data magnitudes — a query that's fast at 10k rows may degrade at 100k without proper indexing or pagination. Memory ceilings prevent unbounded growth from large in-memory result sets. These targets are validated with synthetic SQLite fixtures during Phase 0 and enforced as CI benchmark gates thereafter. Required indexes are documented and migration-backed before TUI GA.
|
||||||
|
|
||||||
@@ -3515,25 +3517,56 @@ pub fn sanitize_for_terminal(input: &str) -> String {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a URL against the configured GitLab origin(s) before opening.
|
/// Classify a URL's safety level against the configured GitLab origin(s) and
|
||||||
/// Enforces scheme + normalized host + port match to prevent deceptive variants
|
/// known entity path patterns before opening in browser.
|
||||||
/// (e.g., IDN homograph attacks, unexpected port redirects).
|
/// Returns tri-state: AllowedEntityPath (open immediately), AllowedButUnrecognizedPath
|
||||||
pub fn is_safe_url(url: &str, allowed_origins: &[AllowedOrigin]) -> bool {
|
/// (prompt user to confirm), or Blocked (refuse to open).
|
||||||
let Ok(parsed) = url::Url::parse(url) else { return false };
|
pub fn classify_safe_url(url: &str, policy: &UrlPolicy) -> UrlSafety {
|
||||||
|
let Ok(parsed) = url::Url::parse(url) else { return UrlSafety::Blocked };
|
||||||
|
|
||||||
// Only allow HTTPS
|
// Only allow HTTPS
|
||||||
if parsed.scheme() != "https" { return false; }
|
if parsed.scheme() != "https" { return UrlSafety::Blocked; }
|
||||||
|
|
||||||
// Normalize host (lowercase, IDNA-compatible) and match scheme+host+port
|
// Normalize host (lowercase, IDNA-compatible) and match scheme+host+port
|
||||||
let Some(host) = parsed.host_str() else { return false; };
|
let Some(host) = parsed.host_str() else { return UrlSafety::Blocked; };
|
||||||
let host_lower = host.to_ascii_lowercase();
|
let host_lower = host.to_ascii_lowercase();
|
||||||
let port = parsed.port_or_known_default();
|
let port = parsed.port_or_known_default();
|
||||||
|
|
||||||
allowed_origins.iter().any(|origin| {
|
let origin_match = policy.allowed_origins.iter().any(|origin| {
|
||||||
origin.scheme == "https"
|
origin.scheme == "https"
|
||||||
&& origin.host == host_lower
|
&& origin.host == host_lower
|
||||||
&& origin.port == port
|
&& origin.port == port
|
||||||
})
|
});
|
||||||
|
|
||||||
|
if !origin_match {
|
||||||
|
return UrlSafety::Blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check path against known GitLab entity patterns
|
||||||
|
let path = parsed.path();
|
||||||
|
if policy.entity_path_patterns.iter().any(|pat| pat.matches(path)) {
|
||||||
|
UrlSafety::AllowedEntityPath
|
||||||
|
} else {
|
||||||
|
UrlSafety::AllowedButUnrecognizedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tri-state URL safety classification.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum UrlSafety {
|
||||||
|
/// Known GitLab entity path — open immediately without prompt.
|
||||||
|
AllowedEntityPath,
|
||||||
|
/// Same host but unrecognized path — show confirmation modal before opening.
|
||||||
|
AllowedButUnrecognizedPath,
|
||||||
|
/// Different host, wrong scheme, or parse failure — refuse to open.
|
||||||
|
Blocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL validation policy: allowed origins + known GitLab entity path patterns.
|
||||||
|
pub struct UrlPolicy {
|
||||||
|
pub allowed_origins: Vec<AllowedOrigin>,
|
||||||
|
/// Path patterns for known GitLab entity routes (e.g., `/-/issues/`, `/-/merge_requests/`).
|
||||||
|
pub entity_path_patterns: Vec<PathPattern>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed origin for URL validation (scheme + normalized host + port).
|
/// Typed origin for URL validation (scheme + normalized host + port).
|
||||||
@@ -7942,3 +7975,9 @@ Recommendations from external review (feedback-9, ChatGPT) that were evaluated a
|
|||||||
Recommendations from external review (feedback-10, ChatGPT) that were evaluated and declined:
|
Recommendations from external review (feedback-10, ChatGPT) that were evaluated and declined:
|
||||||
|
|
||||||
- **Structured compat handshake (`--compat-json` replacing `--compat-version` integer)** — rejected because the current two-step contract (integer compat version + separate schema version check) is intentionally minimal and robust. Adding JSON parsing (`{ "protocol": 1, "compat_version": 2, "min_schema": 14, "max_schema": 16, "build": "..." }`) to a preflight binary validation introduces a new failure mode (malformed JSON, missing fields, version parsing) for zero user-visible benefit. The integer check detects "too old to work" — the only case that matters before spawning the TUI. Schema range is already validated separately via `--check-schema`. Combining both into a single JSON response couples concerns that are better kept independent (binary compat vs schema compat). The current approach is more resilient: if `--compat-version` is missing (old binary), we warn and proceed; JSON parsing failure would be a hard error. KISS principle applies.
|
- **Structured compat handshake (`--compat-json` replacing `--compat-version` integer)** — rejected because the current two-step contract (integer compat version + separate schema version check) is intentionally minimal and robust. Adding JSON parsing (`{ "protocol": 1, "compat_version": 2, "min_schema": 14, "max_schema": 16, "build": "..." }`) to a preflight binary validation introduces a new failure mode (malformed JSON, missing fields, version parsing) for zero user-visible benefit. The integer check detects "too old to work" — the only case that matters before spawning the TUI. Schema range is already validated separately via `--check-schema`. Combining both into a single JSON response couples concerns that are better kept independent (binary compat vs schema compat). The current approach is more resilient: if `--compat-version` is missing (old binary), we warn and proceed; JSON parsing failure would be a hard error. KISS principle applies.
|
||||||
|
|
||||||
|
Recommendations from external review (feedback-11, ChatGPT) that were evaluated and declined:
|
||||||
|
|
||||||
|
- **Query budgets and soft deadlines (120ms/250ms hard deadlines with `QueryDegraded` truncation)** — rejected as over-engineering for a local SQLite tool. The proposal adds per-query-type latency budgets (list: 250ms, detail: 150ms, search: 250ms hard deadline) with SQLite progress handler interrupts and inline "results truncated" badges. This papers over slow queries with UX complexity rather than fixing the root cause. If a list query exceeds 250ms on a local SQLite database, the correct fix is adding an index or optimizing the query plan — not truncating results and showing a retry badge. The existing cancellation + stale-drop system already handles the interactive case (user navigates away before query completes). SQLite progress handlers are also tricky to implement correctly — they fire on every VM instruction, adding overhead to all queries, and the cancellation semantics interact poorly with SQLite's transaction semantics. The complexity-to-benefit ratio is wrong for a single-user local tool. If specific queries are slow, we fix them at the query/index level (Section 9.3.1 already documents required covering indexes).
|
||||||
|
|
||||||
|
- **Adaptive render governor (runtime frame-time monitoring with automatic profile downgrading)** — rejected for the same reason as feedback-3's SLO telemetry and runtime monitoring proposals. The proposal adds a frame-time p95 sliding window, stream pressure detection, automatic profile switching (quality/balanced/speed), hysteresis for recovery, and a `--render-profile` CLI flag. This is appropriate for a multi-user rendering engine or game, not a single-user TUI. The capability detection in Section 3.4.1 already handles the static case (detect terminal capabilities, choose appropriate rendering). If the TUI is slow in tmux or over SSH, the user can pass `--ascii` or reduce their terminal size. Adding a runtime monitoring system with automatic visual degradation introduces a state machine, requires frame-time measurement infrastructure, needs hysteresis tuning to avoid flapping, and must be tested across all the profiles it can switch between. This is significant complexity for an edge case that affects one user once and is solved by a flag. The `--render-profile` flag itself is a reasonable addition as a static override — but the dynamic adaptation runtime is rejected.
|
||||||
|
|||||||
@@ -1532,6 +1532,10 @@ fn query_overlap(
|
|||||||
} else {
|
} else {
|
||||||
"= ?1"
|
"= ?1"
|
||||||
};
|
};
|
||||||
|
// Force the partial index on DiffNote queries (same rationale as expert mode).
|
||||||
|
// Without this hint SQLite picks idx_notes_system (38% of rows) instead of
|
||||||
|
// idx_notes_diffnote_path_created (9.3% of rows): measured 50-133x slower.
|
||||||
|
let notes_indexed_by = "INDEXED BY idx_notes_diffnote_path_created";
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
"SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
||||||
-- 1. DiffNote reviewer
|
-- 1. DiffNote reviewer
|
||||||
@@ -1541,7 +1545,7 @@ fn query_overlap(
|
|||||||
COUNT(DISTINCT m.id) AS touch_count,
|
COUNT(DISTINCT m.id) AS touch_count,
|
||||||
MAX(n.created_at) AS last_seen_at,
|
MAX(n.created_at) AS last_seen_at,
|
||||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||||
FROM notes n
|
FROM notes n {notes_indexed_by}
|
||||||
JOIN discussions d ON n.discussion_id = d.id
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||||
JOIN projects p ON m.project_id = p.id
|
JOIN projects p ON m.project_id = p.id
|
||||||
@@ -1564,9 +1568,9 @@ fn query_overlap(
|
|||||||
COUNT(DISTINCT m.id) AS touch_count,
|
COUNT(DISTINCT m.id) AS touch_count,
|
||||||
MAX(n.created_at) AS last_seen_at,
|
MAX(n.created_at) AS last_seen_at,
|
||||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||||
FROM merge_requests m
|
FROM notes n {notes_indexed_by}
|
||||||
JOIN discussions d ON d.merge_request_id = m.id
|
JOIN discussions d ON n.discussion_id = d.id
|
||||||
JOIN notes n ON n.discussion_id = d.id
|
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||||
JOIN projects p ON m.project_id = p.id
|
JOIN projects p ON m.project_id = p.id
|
||||||
WHERE n.note_type = 'DiffNote'
|
WHERE n.note_type = 'DiffNote'
|
||||||
AND n.position_new_path {path_op}
|
AND n.position_new_path {path_op}
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
|||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let escaped = escape_like(project_str);
|
||||||
let mut suffix_stmt = conn.prepare(
|
let mut suffix_stmt = conn.prepare(
|
||||||
"SELECT id, path_with_namespace FROM projects
|
"SELECT id, path_with_namespace FROM projects
|
||||||
WHERE path_with_namespace LIKE '%/' || ?1
|
WHERE path_with_namespace LIKE '%/' || ?1 ESCAPE '\\'
|
||||||
OR path_with_namespace = ?1",
|
OR path_with_namespace = ?2",
|
||||||
)?;
|
)?;
|
||||||
let suffix_matches: Vec<(i64, String)> = suffix_stmt
|
let suffix_matches: Vec<(i64, String)> = suffix_stmt
|
||||||
.query_map(rusqlite::params![project_str], |row| {
|
.query_map(rusqlite::params![escaped, project_str], |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?))
|
Ok((row.get(0)?, row.get(1)?))
|
||||||
})?
|
})?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
@@ -52,10 +53,10 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
|||||||
|
|
||||||
let mut substr_stmt = conn.prepare(
|
let mut substr_stmt = conn.prepare(
|
||||||
"SELECT id, path_with_namespace FROM projects
|
"SELECT id, path_with_namespace FROM projects
|
||||||
WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%'",
|
WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%' ESCAPE '\\'",
|
||||||
)?;
|
)?;
|
||||||
let substr_matches: Vec<(i64, String)> = substr_stmt
|
let substr_matches: Vec<(i64, String)> = substr_stmt
|
||||||
.query_map(rusqlite::params![project_str], |row| {
|
.query_map(rusqlite::params![escaped], |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?))
|
Ok((row.get(0)?, row.get(1)?))
|
||||||
})?
|
})?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
@@ -103,6 +104,15 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escape LIKE metacharacters so `%` and `_` in user input are treated as
|
||||||
|
/// literals. All queries using this must include `ESCAPE '\'`.
|
||||||
|
fn escape_like(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('%', "\\%")
|
||||||
|
.replace('_', "\\_")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -241,4 +251,24 @@ mod tests {
|
|||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(msg.contains("No projects have been synced"));
|
assert!(msg.contains("No projects have been synced"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_underscore_not_wildcard() {
|
||||||
|
let conn = setup_db();
|
||||||
|
insert_project(&conn, 1, "backend/my_project");
|
||||||
|
insert_project(&conn, 2, "backend/my-project");
|
||||||
|
// `_` in user input must not match `-` (LIKE wildcard behavior)
|
||||||
|
let id = resolve_project(&conn, "my_project").unwrap();
|
||||||
|
assert_eq!(id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_percent_not_wildcard() {
|
||||||
|
let conn = setup_db();
|
||||||
|
insert_project(&conn, 1, "backend/a%b");
|
||||||
|
insert_project(&conn, 2, "backend/axyzb");
|
||||||
|
// `%` in user input must not match arbitrary strings
|
||||||
|
let id = resolve_project(&conn, "a%b").unwrap();
|
||||||
|
assert_eq!(id, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user