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 |
179
docs/performance-audit-2026-02-12.md
Normal file
179
docs/performance-audit-2026-02-12.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Deep Performance Audit Report
|
||||||
|
|
||||||
|
**Date:** 2026-02-12
|
||||||
|
**Branch:** `perf-audit` (e9bacc94)
|
||||||
|
**Parent:** `039ab1c2` (master, v0.6.1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Methodology
|
||||||
|
|
||||||
|
1. **Baseline** — measured p50/p95 latency for all major commands with warm cache
|
||||||
|
2. **Profile** — used macOS `sample` profiler and `EXPLAIN QUERY PLAN` to identify hotspots
|
||||||
|
3. **Golden output** — captured exact numeric outputs before changes as equivalence oracle
|
||||||
|
4. **One lever per change** — each optimization isolated and independently benchmarked
|
||||||
|
5. **Revert threshold** — any optimization <1.1x speedup reverted per audit rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline Measurements (warm cache, release build)
|
||||||
|
|
||||||
|
| Command | Latency | Notes |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| `who --path src/core/db.rs` (expert) | 2200ms | **Hotspot** |
|
||||||
|
| `who --active` | 83-93ms | Acceptable |
|
||||||
|
| `who workload` | 22ms | Fast |
|
||||||
|
| `stats` | 107-112ms | **Hotspot** |
|
||||||
|
| `search "authentication"` | 1030ms | **Hotspot** (library-level) |
|
||||||
|
| `list issues -n 50` | ~40ms | Fast |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimization 1: INDEXED BY for DiffNote Queries
|
||||||
|
|
||||||
|
**Target:** `src/cli/commands/who.rs` — expert and reviews query paths
|
||||||
|
|
||||||
|
**Problem:** SQLite query planner chose `idx_notes_system` (38% selectivity, 106K rows) over `idx_notes_diffnote_path_created` (9.3% selectivity, 26K rows) for path-filtered DiffNote queries. The partial index `WHERE noteable_type = 'MergeRequest' AND type = 'DiffNote'` is far more selective but the planner's cost model didn't pick it.
|
||||||
|
|
||||||
|
**Change:** Added `INDEXED BY idx_notes_diffnote_path_created` to all 8 SQL queries across `query_expert`, `query_expert_details`, `query_reviews`, `build_path_query` (probes 1 & 2), and `suffix_probe`.
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
|
||||||
|
| Query | Before | After | Speedup |
|
||||||
|
|-------|--------|-------|---------|
|
||||||
|
| expert (specific path) | 2200ms | 56-58ms | **38x** |
|
||||||
|
| expert (broad path) | 2200ms | 83ms | **26x** |
|
||||||
|
| reviews | 1800ms | 24ms | **75x** |
|
||||||
|
|
||||||
|
**Isomorphism proof:** `INDEXED BY` only changes which index the planner uses, not the query semantics. Same rows matched, same ordering, same output. Verified by golden output comparison across 5+ runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimization 2: Conditional Aggregates in Stats
|
||||||
|
|
||||||
|
**Target:** `src/cli/commands/stats.rs`
|
||||||
|
|
||||||
|
**Problem:** 12+ sequential `COUNT(*)` queries each requiring a full table scan of `documents` (61K rows). Each scan touched the same pages but couldn't share work.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Documents: 5 sequential COUNTs -> 1 query with `SUM(CASE WHEN ... THEN 1 END)`
|
||||||
|
- FTS count: `SELECT COUNT(*) FROM documents_fts` (virtual table, slow) -> `SELECT COUNT(*) FROM documents_fts_docsize` (shadow B-tree table, 19x faster)
|
||||||
|
- Embeddings: 2 queries -> 1 with `COUNT(DISTINCT document_id), COUNT(*)`
|
||||||
|
- Dirty sources: 2 queries -> 1 with conditional aggregates
|
||||||
|
- Pending fetches: 2 queries -> 1 each (discussions, dependents)
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
|
||||||
|
| Metric | Before | After | Speedup |
|
||||||
|
|--------|--------|-------|---------|
|
||||||
|
| Warm median | 112ms | 66ms | **1.70x** |
|
||||||
|
| Cold | 1220ms | ~700ms | ~1.7x |
|
||||||
|
|
||||||
|
**Golden output verified:**
|
||||||
|
|
||||||
|
```
|
||||||
|
total:61652, issues:8241, mrs:10018, discussions:43393, truncated:63
|
||||||
|
fts:61652, embedded:61652, chunks:88161
|
||||||
|
```
|
||||||
|
|
||||||
|
All values match exactly across before/after runs.
|
||||||
|
|
||||||
|
**Isomorphism proof:** `SUM(CASE WHEN x THEN 1 END)` is algebraically identical to `COUNT(*) WHERE x`. The FTS5 shadow table `documents_fts_docsize` has exactly one row per FTS document by SQLite specification, so `COUNT(*)` on it equals the virtual table count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Investigation: Two-Phase FTS Search (REVERTED)
|
||||||
|
|
||||||
|
**Target:** `src/search/fts.rs`, `src/cli/commands/search.rs`
|
||||||
|
|
||||||
|
**Hypothesis:** FTS5 `snippet()` generation is expensive. Splitting search into Phase 1 (score-only MATCH+bm25) and Phase 2 (snippet for filtered results only) should reduce work.
|
||||||
|
|
||||||
|
**Implementation:** Created `fetch_fts_snippets()` that retrieves snippets only for post-filter document IDs via `json_each()` join.
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
|
||||||
|
| Metric | Before | After | Improvement |
|
||||||
|
|--------|--------|-------|-------------|
|
||||||
|
| search (limit 20) | 1030ms | 995ms | 3.5% |
|
||||||
|
|
||||||
|
**Decision:** Reverted. Per audit rules, <1.1x speedup does not justify added code complexity.
|
||||||
|
|
||||||
|
**Root cause:** The bottleneck is not snippet generation but `MATCH` + `bm25()` scoring itself. Profiling showed `strspn` (FTS5 tokenizer) and `memmove` as the top CPU consumers. The same query runs in 30ms on system sqlite3 but 1030ms in rusqlite's bundled SQLite — a ~125x gap despite both being SQLite 3.51.x compiled at -O3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Library-Level Finding: Bundled SQLite FTS5 Performance
|
||||||
|
|
||||||
|
**Observation:** FTS5 MATCH+bm25 queries are ~125x slower in rusqlite's bundled SQLite vs system sqlite3.
|
||||||
|
|
||||||
|
| Environment | Query Time | Notes |
|
||||||
|
|-------------|-----------|-------|
|
||||||
|
| System sqlite3 (macOS) | 30ms (with snippet), 8ms (without) | Same .db file |
|
||||||
|
| rusqlite bundled | 1030ms | `features = ["bundled"]`, OPT_LEVEL=3 |
|
||||||
|
|
||||||
|
**Profiler data (macOS `sample`):**
|
||||||
|
- Top hotspot: `strspn` in FTS5 tokenizer
|
||||||
|
- Secondary: `memmove` in FTS5 internals
|
||||||
|
- Scaling: ~5ms per result (limit 5 = 497ms, limit 20 = 995ms)
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
- Bundled SQLite compiled without platform-specific optimizations (SIMD, etc.)
|
||||||
|
- Different memory allocator behavior
|
||||||
|
- Missing compile-time tuning flags
|
||||||
|
|
||||||
|
**Recommendation for future:** Investigate switching from `features = ["bundled"]` to system SQLite linkage, or audit the bundled compile flags in the `libsqlite3-sys` build script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exploration Agent Findings (Informational)
|
||||||
|
|
||||||
|
Four parallel exploration agents surveyed the entire codebase. Key findings beyond what was already addressed:
|
||||||
|
|
||||||
|
### Ingestion Pipeline
|
||||||
|
- Serial DB writes in async context (acceptable — rusqlite is synchronous)
|
||||||
|
- Label ingestion uses individual inserts (potential batch optimization, low priority)
|
||||||
|
|
||||||
|
### CLI / GitLab Client
|
||||||
|
- GraphQL client recreated per call (`client.rs:98-100`) — caches connection pool, minor
|
||||||
|
- Double JSON deserialization in GraphQL responses — medium priority
|
||||||
|
- N+1 subqueries in `list` command (`list.rs:408-423`) — 4 correlated subqueries per row
|
||||||
|
|
||||||
|
### Search / Embedding
|
||||||
|
- No N+1 patterns, no O(n^2) algorithms
|
||||||
|
- Chunking is O(n) single-pass with proper UTF-8 safety
|
||||||
|
- Ollama concurrency model is sound (parallel HTTP, serial DB writes)
|
||||||
|
|
||||||
|
### Database / Documents
|
||||||
|
- O(n^2) prefix sum in `truncation.rs` — low traffic path
|
||||||
|
- String allocation patterns in extractors — micro-optimization territory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opportunity Matrix
|
||||||
|
|
||||||
|
| Candidate | Impact | Confidence | Effort | Score | Status |
|
||||||
|
|-----------|--------|------------|--------|-------|--------|
|
||||||
|
| INDEXED BY for DiffNote | Very High | High | Low | **9.0** | Shipped |
|
||||||
|
| Stats conditional aggregates | Medium | High | Low | **7.0** | Shipped |
|
||||||
|
| Bundled SQLite FTS5 | Very High | Medium | High | 5.0 | Documented |
|
||||||
|
| List N+1 subqueries | Medium | Medium | Medium | 4.0 | Backlog |
|
||||||
|
| GraphQL double deser | Low | Medium | Low | 3.5 | Backlog |
|
||||||
|
| Truncation O(n^2) | Low | High | Low | 3.0 | Backlog |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/cli/commands/who.rs` | INDEXED BY hints on 8 SQL queries |
|
||||||
|
| `src/cli/commands/stats.rs` | Conditional aggregates, FTS5 shadow table, merged queries |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
- All 603 tests pass
|
||||||
|
- `cargo clippy --all-targets -- -D warnings` clean
|
||||||
|
- `cargo fmt --check` clean
|
||||||
|
- Golden output verified for both optimizations
|
||||||
174
docs/prd-per-note-search.feedback-1.md
Normal file
174
docs/prd-per-note-search.feedback-1.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
Highest-impact gaps I see in the current plan:
|
||||||
|
|
||||||
|
1. `for-issue` / `for-mr` filtering is ambiguous across projects and can return incorrect rows.
|
||||||
|
2. `lore notes` has no pagination contract, so large exports and deterministic resumption are weak.
|
||||||
|
3. Migration `022` is high-risk (table rebuild + FTS + junction tables) without explicit integrity gates.
|
||||||
|
4. Note-doc freshness is incomplete for upstream note deletions and parent metadata changes (labels/title).
|
||||||
|
|
||||||
|
Below are my best revisions, each with rationale and a git-diff-style plan edit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
1. **Add gated rollout + rollback controls**
|
||||||
|
Rationale: You can still “ship together” while reducing blast radius. This makes recovery fast if note-doc generation causes DB/embedding pressure.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Design
|
||||||
|
-Two phases, shipped together as one feature:
|
||||||
|
+Two phases, shipped together as one feature, but with runtime gates:
|
||||||
|
+
|
||||||
|
+- `feature.notes_cli` (Phase 1 surface)
|
||||||
|
+- `feature.note_documents` (Phase 2 indexing/extraction path)
|
||||||
|
+
|
||||||
|
+Rollout order:
|
||||||
|
+1) Enable `notes_cli`
|
||||||
|
+2) Run note-doc backfill in bounded batches
|
||||||
|
+3) Enable `note_documents` for continuous updates
|
||||||
|
+
|
||||||
|
+Rollback:
|
||||||
|
+- Disabling `feature.note_documents` stops new note-doc generation without affecting issue/MR/discussion docs.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add keyset pagination + deterministic ordering**
|
||||||
|
Rationale: Needed for year-long reviewer analysis and reliable “continue where I left off” behavior under concurrent updates.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ pub struct NoteListFilters<'a> {
|
||||||
|
pub limit: usize,
|
||||||
|
+ pub cursor: Option<&'a str>, // keyset token "<sort_ms>:<id>"
|
||||||
|
+ pub include_total_count: bool, // avoid COUNT(*) in hot paths
|
||||||
|
@@
|
||||||
|
- pub sort: &'a str, // "created" (default) | "updated"
|
||||||
|
+ pub sort: &'a str, // "created" | "updated"
|
||||||
|
@@ query_notes SQL
|
||||||
|
-ORDER BY {sort_column} {order}
|
||||||
|
+ORDER BY {sort_column} {order}, n.id {order}
|
||||||
|
LIMIT ?
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Make `for-issue` / `for-mr` project-scoped**
|
||||||
|
Rationale: IIDs are not globally unique. Requiring project avoids false positives and hard-to-debug cross-project leakage.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ pub struct NotesArgs {
|
||||||
|
- #[arg(long = "for-issue", help_heading = "Filters", conflicts_with = "for_mr")]
|
||||||
|
+ #[arg(long = "for-issue", help_heading = "Filters", conflicts_with = "for_mr", requires = "project")]
|
||||||
|
pub for_issue: Option<i64>,
|
||||||
|
@@
|
||||||
|
- #[arg(long = "for-mr", help_heading = "Filters", conflicts_with = "for_issue")]
|
||||||
|
+ #[arg(long = "for-mr", help_heading = "Filters", conflicts_with = "for_issue", requires = "project")]
|
||||||
|
pub for_mr: Option<i64>,
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Upgrade path filtering semantics**
|
||||||
|
Rationale: Review comments often reference renames/moves. Restricting to `position_new_path` misses relevant notes.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ pub struct NotesArgs {
|
||||||
|
- /// Filter by file path (trailing / for prefix match)
|
||||||
|
+ /// Filter by file path
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
+ /// Path mode: exact|prefix|glob
|
||||||
|
+ #[arg(long = "path-mode", value_parser = ["exact","prefix","glob"], default_value = "exact", help_heading = "Filters")]
|
||||||
|
+ pub path_mode: String,
|
||||||
|
+ /// Match against old path as well as new path
|
||||||
|
+ #[arg(long = "match-old-path", help_heading = "Filters")]
|
||||||
|
+ pub match_old_path: bool,
|
||||||
|
@@ query_notes filter mappings
|
||||||
|
-- `path` ... n.position_new_path ...
|
||||||
|
+- `path` applies to `n.position_new_path` and optionally `n.position_old_path`.
|
||||||
|
+- `glob` mode translates `*`/`?` to SQL LIKE with escaping.
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Add explicit performance indexes (new migration)**
|
||||||
|
Rationale: `notes` becomes a first-class query surface; without indexes, filters degrade quickly at 10k+ note scale.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Phase 1: `lore notes` Command
|
||||||
|
+### Work Chunk 1E: Query Performance Indexes
|
||||||
|
+**Files:** `migrations/023_notes_query_indexes.sql`, `src/core/db.rs`
|
||||||
|
+
|
||||||
|
+Add indexes:
|
||||||
|
+- `notes(project_id, created_at DESC, id DESC)`
|
||||||
|
+- `notes(author_username, created_at DESC, id DESC) WHERE is_system = 0`
|
||||||
|
+- `notes(discussion_id)`
|
||||||
|
+- `notes(position_new_path)`
|
||||||
|
+- `notes(position_old_path)`
|
||||||
|
+- `discussions(issue_id)`
|
||||||
|
+- `discussions(merge_request_id)`
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Harden migration 022 with transactional integrity checks**
|
||||||
|
Rationale: This is the riskiest part of the plan. Add hard fail-fast checks so corruption cannot silently pass.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ### Work Chunk 2A: Schema Migration (022)
|
||||||
|
+Migration safety requirements:
|
||||||
|
+- Execute in a single `BEGIN IMMEDIATE ... COMMIT` transaction.
|
||||||
|
+- Capture and compare pre/post row counts for `documents`, `document_labels`, `document_paths`, `dirty_sources`.
|
||||||
|
+- Run `PRAGMA foreign_key_check` and abort on any violation.
|
||||||
|
+- Run `PRAGMA integrity_check` and abort on non-`ok`.
|
||||||
|
+- Rebuild FTS and assert `documents_fts` rowcount equals `documents` rowcount.
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Add note deletion + parent-change propagation**
|
||||||
|
Rationale: Current plan handles create/update ingestion but not all staleness paths. Without this, note documents drift.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Phase 2: Per-Note Documents
|
||||||
|
+### Work Chunk 2G: Freshness Propagation
|
||||||
|
+**Files:** `src/ingestion/discussions.rs`, `src/ingestion/mr_discussions.rs`, `src/documents/regenerator.rs`
|
||||||
|
+
|
||||||
|
+Rules:
|
||||||
|
+- If a previously stored note is missing from upstream payload, delete local note row and enqueue `(note, id)` for document deletion.
|
||||||
|
+- When parent issue/MR title or labels change, enqueue descendant note docs dirty (notes inherit parent metadata).
|
||||||
|
+- Keep idempotent behavior for repeated syncs.
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Separate FTS coverage from embedding coverage**
|
||||||
|
Rationale: Biggest cost/perf risk is embeddings. Index all notes in FTS, but embed selectively with policy knobs.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Estimated Document Volume Impact
|
||||||
|
-FTS5 handles this comfortably. Embedding generation time scales linearly (~4x increase).
|
||||||
|
+FTS5 handles this comfortably. Embedding generation is policy-controlled:
|
||||||
|
+- FTS: index all non-system note docs
|
||||||
|
+- Embeddings default: only notes with body length >= 40 chars (configurable)
|
||||||
|
+- Add config: `documents.note_embeddings.min_chars`, `documents.note_embeddings.enabled`
|
||||||
|
+- Prioritize unresolved DiffNotes before other notes during embedding backfill
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **Bring structured reviewer profiling into scope (not narrative reporting)**
|
||||||
|
Rationale: This directly serves the stated use case and makes the feature compelling immediately.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Non-Goals
|
||||||
|
-- Adding a "reviewer profile" report command (that's a downstream use case built on this infrastructure)
|
||||||
|
+- Generating free-form narrative reviewer reports.
|
||||||
|
+ A structured profiling command is in scope.
|
||||||
|
+
|
||||||
|
+## Phase 3: Structured Reviewer Profiling
|
||||||
|
+Add `lore notes profile --author <user> --since <window>` returning:
|
||||||
|
+- top commented paths
|
||||||
|
+- top parent labels
|
||||||
|
+- unresolved-comment ratio
|
||||||
|
+- note-type distribution
|
||||||
|
+- median comment length
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Add operational SLOs + robot-mode status for note pipeline**
|
||||||
|
Rationale: Reliability improves when regressions are observable, not inferred from failures.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Verification Checklist
|
||||||
|
+Operational checks:
|
||||||
|
+- `lore -J stats` includes per-`source_type` document counts (including `note`)
|
||||||
|
+- Add queue lag metrics: oldest dirty note age, retry backlog size
|
||||||
|
+- Add extraction error breakdown by `source_type`
|
||||||
|
+- Add smoke assertion: disabling `feature.note_documents` leaves other source regeneration unaffected
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you want, I can produce a single consolidated revised PRD draft (fully merged text, not just diffs) as the next step.
|
||||||
200
docs/prd-per-note-search.feedback-2.md
Normal file
200
docs/prd-per-note-search.feedback-2.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
Below are the strongest revisions I’d make, excluding everything in your `## Rejected Recommendations` list.
|
||||||
|
|
||||||
|
1. **Add a Phase 0 for stable note identity before any note-doc generation**
|
||||||
|
Rationale: your current plan still allows note document churn because Issue discussion ingestion is delete/reinsert-based. That makes local `notes.id` unstable, causing unnecessary dirtying/regeneration and potential stale-doc edge cases. Stabilizing identity first (upsert-by-GitLab-ID + sweep stale) improves correctness and cuts repeated work.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Design
|
||||||
|
-Two phases, shipped together as one feature:
|
||||||
|
+Three phases, shipped together as one feature:
|
||||||
|
+- **Phase 0 (Foundation):** Stable note identity in local DB (upsert + sweep, no delete/reinsert churn)
|
||||||
|
- **Phase 1 (Option A):** `lore notes` command — direct SQL query over the `notes` table with rich filtering
|
||||||
|
- **Phase 2 (Option B):** Per-note documents — each non-system note becomes its own searchable document in the FTS/embedding pipeline
|
||||||
|
@@
|
||||||
|
+## Phase 0: Stable Note Identity
|
||||||
|
+
|
||||||
|
+### Work Chunk 0A: Upsert/Sweep for Issue Discussion Notes
|
||||||
|
+**Files:** `src/ingestion/discussions.rs`, `migrations/022_notes_identity_index.sql`, `src/core/db.rs`
|
||||||
|
+**Implementation:**
|
||||||
|
+- Add unique index: `UNIQUE(project_id, gitlab_id)` on `notes`
|
||||||
|
+- Replace delete/reinsert issue-note flow with upsert + `last_seen_at` sweep (same durability model as MR note sweep)
|
||||||
|
+- Ensure `insert_note/upsert_note` returns the stable local row id for both insert and update paths
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Replace `source_type` CHECK constraints with a registry table + FK in migration**
|
||||||
|
Rationale: table CHECKs force full table rebuild for every new source type forever. A `source_types` table with FK keeps DB-level integrity and future extensibility without rebuilding `documents`/`dirty_sources` every time. This is a major architecture hardening win.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ### Work Chunk 2A: Schema Migration (023)
|
||||||
|
-Current migration ... CHECK constraints limiting `source_type` ...
|
||||||
|
+Current migration ... CHECK constraints limiting `source_type` ...
|
||||||
|
+Revision: migrate to `source_types` registry table + FK constraints.
|
||||||
|
@@
|
||||||
|
-1. `dirty_sources` — add `'note'` to source_type CHECK
|
||||||
|
-2. `documents` — add `'note'` to source_type CHECK
|
||||||
|
+1. Create `source_types(name TEXT PRIMARY KEY)` and seed: `issue, merge_request, discussion, note`
|
||||||
|
+2. Rebuild `dirty_sources` and `documents` to replace CHECK with `REFERENCES source_types(name)`
|
||||||
|
+3. Future source-type additions become `INSERT INTO source_types(name) VALUES (?)` (no table rebuild)
|
||||||
|
@@
|
||||||
|
+#### Additional integrity tests
|
||||||
|
+#[test]
|
||||||
|
+fn test_source_types_registry_contains_note() { ... }
|
||||||
|
+#[test]
|
||||||
|
+fn test_documents_source_type_fk_enforced() { ... }
|
||||||
|
+#[test]
|
||||||
|
+fn test_dirty_sources_source_type_fk_enforced() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Mark note documents dirty only when note semantics actually changed**
|
||||||
|
Rationale: current loops mark every non-system note dirty every sync. With 8k+ notes this creates avoidable queue pressure and regeneration time. Change-aware dirtying (inserted/changed only) gives major performance and stability improvements.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ### Work Chunk 2D: Regenerator & Dirty Tracking Integration
|
||||||
|
-for note in notes {
|
||||||
|
- let local_note_id = insert_note(&tx, local_discussion_id, ¬e, None)?;
|
||||||
|
- if !note.is_system {
|
||||||
|
- dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, local_note_id)?;
|
||||||
|
- }
|
||||||
|
-}
|
||||||
|
+for note in notes {
|
||||||
|
+ let outcome = upsert_note(&tx, local_discussion_id, ¬e, None)?;
|
||||||
|
+ if !note.is_system && outcome.changed_semantics {
|
||||||
|
+ dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.local_note_id)?;
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
@@
|
||||||
|
+// changed_semantics should include: body, note_type, path/line positions, resolvable/resolved/resolved_by, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Expand filters to support real analysis windows and resolution state**
|
||||||
|
Rationale: reviewer profiling usually needs bounded windows and both resolved/unresolved views. Current `unresolved: bool` is too narrow and one-sided. Add `--until` and tri-state resolution filtering for better analytical power.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ pub struct NoteListFilters<'a> {
|
||||||
|
- pub since: Option<&'a str>,
|
||||||
|
+ pub since: Option<&'a str>,
|
||||||
|
+ pub until: Option<&'a str>,
|
||||||
|
@@
|
||||||
|
- pub unresolved: bool,
|
||||||
|
+ pub resolution: &'a str, // "any" (default) | "unresolved" | "resolved"
|
||||||
|
@@
|
||||||
|
- pub author: Option<&'a str>,
|
||||||
|
+ pub author: Option<&'a str>, // case-insensitive match
|
||||||
|
@@
|
||||||
|
- // Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
|
||||||
|
+ // Filter by start time (7d, 2w, 1m, or YYYY-MM-DD)
|
||||||
|
pub since: Option<String>,
|
||||||
|
+ /// Filter by end time (7d, 2w, 1m, or YYYY-MM-DD)
|
||||||
|
+ #[arg(long, help_heading = "Filters")]
|
||||||
|
+ pub until: Option<String>,
|
||||||
|
@@
|
||||||
|
- /// Only show unresolved review comments
|
||||||
|
- pub unresolved: bool,
|
||||||
|
+ /// Resolution filter: any, unresolved, resolved
|
||||||
|
+ #[arg(long, value_parser = ["any", "unresolved", "resolved"], default_value = "any", help_heading = "Filters")]
|
||||||
|
+ pub resolution: String,
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Broaden index strategy to match actual query shapes, not just author queries**
|
||||||
|
Rationale: `idx_notes_user_created` helps one path, but common usage also includes project+time scans and unresolved filters. Add two more partial composites for high-selectivity paths.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ### Work Chunk 1E: Composite Query Index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_user_created
|
||||||
|
ON notes(project_id, author_username, created_at DESC, id DESC)
|
||||||
|
WHERE is_system = 0;
|
||||||
|
+
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_notes_project_created
|
||||||
|
+ON notes(project_id, created_at DESC, id DESC)
|
||||||
|
+WHERE is_system = 0;
|
||||||
|
+
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_notes_unresolved_project_created
|
||||||
|
+ON notes(project_id, created_at DESC, id DESC)
|
||||||
|
+WHERE is_system = 0 AND resolvable = 1 AND resolved = 0;
|
||||||
|
@@
|
||||||
|
+#[test]
|
||||||
|
+fn test_notes_query_plan_uses_project_created_index_for_default_listing() { ... }
|
||||||
|
+#[test]
|
||||||
|
+fn test_notes_query_plan_uses_unresolved_index_when_resolution_unresolved() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Improve per-note document payload with structured metadata header + minimal thread context**
|
||||||
|
Rationale: isolated single-note docs can lose meaning. A small structured header plus lightweight context (parent + one preceding note excerpt) improves semantic retrieval quality substantially without re-bundling full threads.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ### Work Chunk 2C: Note Document Extractor
|
||||||
|
-// 6. Format content:
|
||||||
|
-// [[Note]] {note_type or "Comment"} on {parent_type_prefix}: {parent_title}
|
||||||
|
-// Project: {path_with_namespace}
|
||||||
|
-// URL: {url}
|
||||||
|
-// Author: @{author}
|
||||||
|
-// Date: {format_date(created_at)}
|
||||||
|
-// Labels: {labels_json}
|
||||||
|
-// File: {position_new_path}:{position_new_line} (if DiffNote)
|
||||||
|
-//
|
||||||
|
-// --- Body ---
|
||||||
|
-//
|
||||||
|
-// {body}
|
||||||
|
+// 6. Format content with machine-readable header:
|
||||||
|
+// [[Note]]
|
||||||
|
+// source_type: note
|
||||||
|
+// note_gitlab_id: {gitlab_id}
|
||||||
|
+// project: {path_with_namespace}
|
||||||
|
+// parent_type: {Issue|MergeRequest}
|
||||||
|
+// parent_iid: {iid}
|
||||||
|
+// note_type: {DiffNote|DiscussionNote|Comment}
|
||||||
|
+// author: @{author}
|
||||||
|
+// created_at: {iso8601}
|
||||||
|
+// resolved: {true|false}
|
||||||
|
+// path: {position_new_path}:{position_new_line}
|
||||||
|
+// url: {url}
|
||||||
|
+//
|
||||||
|
+// --- Context ---
|
||||||
|
+// parent_title: {title}
|
||||||
|
+// previous_note_excerpt: {optional, max 200 chars}
|
||||||
|
+//
|
||||||
|
+// --- Body ---
|
||||||
|
+// {body}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Add first-class export modes for downstream profiling pipelines**
|
||||||
|
Rationale: this makes the feature much more useful immediately (LLM prompts, notebook analysis, external scripts) without adding a profiling command. It stays within your non-goals and increases adoption.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ pub struct NotesArgs {
|
||||||
|
+ /// Output format
|
||||||
|
+ #[arg(long, value_parser = ["table", "json", "jsonl", "csv"], default_value = "table", help_heading = "Output")]
|
||||||
|
+ pub format: String,
|
||||||
|
@@
|
||||||
|
- if robot_mode {
|
||||||
|
+ if robot_mode || args.format == "json" || args.format == "jsonl" || args.format == "csv" {
|
||||||
|
print_list_notes_json(...)
|
||||||
|
} else {
|
||||||
|
print_list_notes(&result);
|
||||||
|
}
|
||||||
|
@@ ### Work Chunk 1C: Human & Robot Output Formatting
|
||||||
|
+Add `print_list_notes_csv()` and `print_list_notes_jsonl()`:
|
||||||
|
+- CSV columns mirror `NoteListRowJson` field names
|
||||||
|
+- JSONL emits one note object per line for streaming pipelines
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Strengthen verification with idempotence + migration data-preservation checks**
|
||||||
|
Rationale: this feature touches ingestion, migrations, indexing, and regeneration. Add explicit idempotence/perf checks so regressions surface early.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ ## Verification Checklist
|
||||||
|
cargo test
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo fmt --check
|
||||||
|
+cargo test test_note_ingestion_idempotent_across_two_syncs
|
||||||
|
+cargo test test_note_document_count_stable_after_second_generate_docs_full
|
||||||
|
@@
|
||||||
|
+lore sync
|
||||||
|
+lore generate-docs --full
|
||||||
|
+lore -J stats > /tmp/stats1.json
|
||||||
|
+lore generate-docs --full
|
||||||
|
+lore -J stats > /tmp/stats2.json
|
||||||
|
+# assert note doc count unchanged and dirty queue drains to zero
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can turn this into a fully rewritten PRD v2 draft with these changes merged in-place and renumbered work chunks end-to-end.
|
||||||
162
docs/prd-per-note-search.feedback-3.md
Normal file
162
docs/prd-per-note-search.feedback-3.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
These are the highest-impact revisions I’d make. They avoid everything in your `## Rejected Recommendations` list.
|
||||||
|
|
||||||
|
1. Add immediate note-document deletion propagation (don’t wait for `generate-docs --full`)
|
||||||
|
Why: right now, deleted notes can leave stale `source_type='note'` documents until a full rebuild. That creates incorrect search/reporting results and weakens trust in the dataset.
|
||||||
|
```diff
|
||||||
|
@@ Phase 0: Stable Note Identity
|
||||||
|
+### Work Chunk 0B: Immediate Deletion Propagation
|
||||||
|
+
|
||||||
|
+When sweep deletes stale notes, propagate deletion to documents in the same transaction.
|
||||||
|
+Do not rely on eventual cleanup via `generate-docs --full`.
|
||||||
|
+
|
||||||
|
+#### Tests to Write First
|
||||||
|
+#[test]
|
||||||
|
+fn test_issue_note_sweep_deletes_note_documents_immediately() { ... }
|
||||||
|
+#[test]
|
||||||
|
+fn test_mr_note_sweep_deletes_note_documents_immediately() { ... }
|
||||||
|
+
|
||||||
|
+#### Implementation
|
||||||
|
+Use `DELETE ... RETURNING id, is_system` in note sweep functions.
|
||||||
|
+For returned non-system note ids:
|
||||||
|
+1) `DELETE FROM documents WHERE source_type='note' AND source_id=?`
|
||||||
|
+2) `DELETE FROM dirty_sources WHERE source_type='note' AND source_id=?`
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add one-time upgrade backfill for existing notes (migration 024)
|
||||||
|
Why: existing DBs will otherwise only get note-documents for changed/new notes. Historical notes remain invisible unless users manually run full rebuild.
|
||||||
|
```diff
|
||||||
|
@@ Phase 2: Per-Note Documents
|
||||||
|
+### Work Chunk 2H: Backfill Existing Notes After Upgrade (Migration 024)
|
||||||
|
+
|
||||||
|
+Create migration `024_note_dirty_backfill.sql`:
|
||||||
|
+INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||||
|
+SELECT 'note', n.id, unixepoch('now') * 1000
|
||||||
|
+FROM notes n
|
||||||
|
+LEFT JOIN documents d
|
||||||
|
+ ON d.source_type='note' AND d.source_id=n.id
|
||||||
|
+WHERE n.is_system=0 AND d.id IS NULL
|
||||||
|
+ON CONFLICT(source_type, source_id) DO NOTHING;
|
||||||
|
+
|
||||||
|
+Add migration test asserting idempotence and expected queue size.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Fix `--since/--until` semantics and validation
|
||||||
|
Why: reusing `parse_since` for `until` creates ambiguous windows and off-by-boundary behavior; your own example `--since 90d --until 180d` is chronologically reversed.
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1A: Data Types & Query Layer
|
||||||
|
- since: parse_since(since_str) then n.created_at >= ?
|
||||||
|
- until: parse_since(until_str) then n.created_at <= ?
|
||||||
|
+ since: parse_since_start_bound(since_str) then n.created_at >= ?
|
||||||
|
+ until: parse_until_end_bound(until_str) then n.created_at <= ?
|
||||||
|
+ Validate since <= until; otherwise return a clear user error.
|
||||||
|
+
|
||||||
|
+#### Tests to Write First
|
||||||
|
+#[test] fn test_query_notes_invalid_time_window_rejected() { ... }
|
||||||
|
+#[test] fn test_query_notes_until_date_is_end_of_day_inclusive() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Separate semantic-change detection from housekeeping updates
|
||||||
|
Why: current proposed `WHERE` includes `updated_at`, which will cause unnecessary dirty churn. You want `last_seen_at` to always refresh, but regeneration only when searchable semantics changed.
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 0A: Upsert/Sweep for Issue Discussion Notes
|
||||||
|
- OR notes.updated_at IS NOT excluded.updated_at
|
||||||
|
+ -- updated_at-only changes should not mark semantic dirty
|
||||||
|
+
|
||||||
|
+Perform two-step logic:
|
||||||
|
+1) Upsert always updates persistence/housekeeping fields (`updated_at`, `last_seen_at`).
|
||||||
|
+2) `changed_semantics` is computed only from fields used by note documents/search filters
|
||||||
|
+ (body, note_type, resolved flags, paths, author, parent linkage).
|
||||||
|
+
|
||||||
|
+#### Tests to Write First
|
||||||
|
+#[test]
|
||||||
|
+fn test_issue_note_upsert_updated_at_only_does_not_mark_semantic_change() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Make indexes align with actual query collation and join strategy
|
||||||
|
Why: `author` uses `COLLATE NOCASE`; without collation-aware index, SQLite can skip index use. Also, IID filters via scalar subqueries are harder for planner than direct join predicates.
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1E: Composite Query Index
|
||||||
|
-CREATE INDEX ... ON notes(project_id, author_username, created_at DESC, id DESC) WHERE is_system = 0;
|
||||||
|
+CREATE INDEX ... ON notes(project_id, author_username COLLATE NOCASE, created_at DESC, id DESC) WHERE is_system = 0;
|
||||||
|
+
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_discussions_issue_id ON discussions(issue_id);
|
||||||
|
+CREATE INDEX IF NOT EXISTS idx_discussions_mr_id ON discussions(merge_request_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1A: query_notes()
|
||||||
|
- d.issue_id = (SELECT id FROM issues WHERE iid = ? AND project_id = ?)
|
||||||
|
+ i.iid = ? AND i.project_id = ?
|
||||||
|
- d.merge_request_id = (SELECT id FROM merge_requests WHERE iid = ? AND project_id = ?)
|
||||||
|
+ m.iid = ? AND m.project_id = ?
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Replace manual CSV escaping with `csv` crate
|
||||||
|
Why: manual RFC4180 escaping is fragile (quotes/newlines/multi-byte edge cases). This is exactly where a mature library reduces long-term bug risk.
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1C: Human & Robot Output Formatting
|
||||||
|
- Uses a minimal CSV writer (no external dependency — the format is simple enough for manual escaping).
|
||||||
|
+ Uses `csv::Writer` for RFC4180-compliant escaping and stable output across edge cases.
|
||||||
|
+
|
||||||
|
+#### Tests to Write First
|
||||||
|
+#[test] fn test_csv_output_multiline_and_quotes_roundtrip() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Add `--contains` lexical body filter to `lore notes`
|
||||||
|
Why: useful middle ground between metadata filtering and semantic search; great for reviewer-pattern mining without requiring FTS query syntax.
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1B: CLI Arguments & Command Wiring
|
||||||
|
+/// Filter by case-insensitive substring in note body
|
||||||
|
+#[arg(long, help_heading = "Filters")]
|
||||||
|
+pub contains: Option<String>;
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1A: NoteListFilters
|
||||||
|
+ pub contains: Option<&'a str>,
|
||||||
|
@@ query_notes dynamic filters
|
||||||
|
+ if contains.is_some() {
|
||||||
|
+ where_clauses.push("n.body LIKE ? COLLATE NOCASE");
|
||||||
|
+ params.push(format!("%{}%", escape_like(contains.unwrap())));
|
||||||
|
+ }
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Reduce note-document embedding noise by slimming metadata header
|
||||||
|
Why: current verbose key-value header repeats low-signal tokens and consumes embedding budget. Keep context, but bias tokens toward actual review text.
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 2C: Note Document Extractor
|
||||||
|
- Build content with structured metadata header:
|
||||||
|
- [[Note]]
|
||||||
|
- source_type: note
|
||||||
|
- note_gitlab_id: ...
|
||||||
|
- project: ...
|
||||||
|
- ...
|
||||||
|
- --- Body ---
|
||||||
|
- {body}
|
||||||
|
+ Build content with compact, high-signal layout:
|
||||||
|
+ [[Note]]
|
||||||
|
+ @{author} on {Issue#|MR!}{iid} in {project_path}
|
||||||
|
+ path: {path:line} (only when available)
|
||||||
|
+ state: {resolved|unresolved} (only when resolvable)
|
||||||
|
+
|
||||||
|
+ {body}
|
||||||
|
+
|
||||||
|
+Keep detailed metadata in structured document columns/labels/paths/url,
|
||||||
|
+not repeated in verbose text.
|
||||||
|
```
|
||||||
|
|
||||||
|
9. Add explicit performance regression checks for the new hot paths
|
||||||
|
Why: this feature increases document volume ~4x; you should pin acceptable query behavior now so future changes don’t silently degrade.
|
||||||
|
```diff
|
||||||
|
@@ Verification Checklist
|
||||||
|
+Performance/plan checks:
|
||||||
|
+1) `EXPLAIN QUERY PLAN` for:
|
||||||
|
+ - author+since query
|
||||||
|
+ - project+date query
|
||||||
|
+ - for-mr / for-issue query
|
||||||
|
+2) Seed 50k-note synthetic fixture and assert:
|
||||||
|
+ - `lore notes --author ... --limit 100` stays under agreed local threshold
|
||||||
|
+ - `lore search --type note ...` remains deterministic and completes successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can also provide a fully merged “iteration 3” PRD text with these edits applied end-to-end so you can drop it in directly.
|
||||||
187
docs/prd-per-note-search.feedback-4.md
Normal file
187
docs/prd-per-note-search.feedback-4.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
1. **Canonical note identity for documents: use `notes.gitlab_id` as `source_id`**
|
||||||
|
Why this is better: the current plan still couples document identity to local row IDs. Even with upsert+sweep, local IDs are a storage artifact and can be reused in edge cases. Using GitLab note IDs as canonical document IDs makes regeneration, backfill, and deletion propagation more stable and portable.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/PRD.md
|
||||||
|
+++ b/PRD.md
|
||||||
|
@@ Phase 0: Stable Note Identity
|
||||||
|
-Phase 2 depends on `notes.id` as the `source_id` for note documents.
|
||||||
|
+Phase 2 uses `notes.gitlab_id` as the `source_id` for note documents.
|
||||||
|
+`notes.id` remains an internal relational key only.
|
||||||
|
|
||||||
|
@@ Work Chunk 0A
|
||||||
|
pub struct NoteUpsertOutcome {
|
||||||
|
pub local_note_id: i64,
|
||||||
|
+ pub document_source_id: i64, // notes.gitlab_id
|
||||||
|
pub changed_semantics: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ Work Chunk 2D
|
||||||
|
-if !note.is_system && outcome.changed_semantics {
|
||||||
|
- dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.local_note_id)?;
|
||||||
|
+if !note.is_system && outcome.changed_semantics {
|
||||||
|
+ dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.document_source_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ Work Chunk 2E
|
||||||
|
-SELECT 'note', n.id, ?1
|
||||||
|
+SELECT 'note', n.gitlab_id, ?1
|
||||||
|
|
||||||
|
@@ Work Chunk 2H
|
||||||
|
-ON d.source_type = 'note' AND d.source_id = n.id
|
||||||
|
+ON d.source_type = 'note' AND d.source_id = n.gitlab_id
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Prevent false deletions on partial/incomplete syncs**
|
||||||
|
Why this is better: sweep-based deletion is correct only when a discussion’s notes were fully fetched. If a page fails mid-fetch, current logic can incorrectly delete valid notes. Add an explicit “fetch complete” guard before sweep.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/PRD.md
|
||||||
|
+++ b/PRD.md
|
||||||
|
@@ Phase 0
|
||||||
|
+### Work Chunk 0C: Sweep Safety Guard (Partial Fetch Protection)
|
||||||
|
+
|
||||||
|
+Only run stale-note sweep when note pagination completed successfully for that discussion.
|
||||||
|
+If fetch is partial/interrupted, skip sweep and keep prior notes intact.
|
||||||
|
|
||||||
|
+#### Tests to Write First
|
||||||
|
+#[test]
|
||||||
|
+fn test_partial_fetch_does_not_sweep_notes() { /* ... */ }
|
||||||
|
+
|
||||||
|
+#[test]
|
||||||
|
+fn test_complete_fetch_runs_sweep_notes() { /* ... */ }
|
||||||
|
|
||||||
|
+#### Implementation
|
||||||
|
+if discussion_fetch_complete {
|
||||||
|
+ sweep_stale_issue_notes(...)?;
|
||||||
|
+} else {
|
||||||
|
+ tracing::warn!("Skipping stale sweep for discussion {} due to partial fetch", discussion_gitlab_id);
|
||||||
|
+}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Make deletion propagation set-based (not per-note loop)**
|
||||||
|
Why this is better: the current per-note DELETE loop is O(N) statements and gets slow on large threads. A temp-table/CTE set-based delete is faster, simpler to reason about, and remains atomic.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/PRD.md
|
||||||
|
+++ b/PRD.md
|
||||||
|
@@ Work Chunk 0B Implementation
|
||||||
|
- for note_id in stale_note_ids {
|
||||||
|
- conn.execute("DELETE FROM documents WHERE source_type = 'note' AND source_id = ?", [note_id])?;
|
||||||
|
- conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note' AND source_id = ?", [note_id])?;
|
||||||
|
- }
|
||||||
|
+ CREATE TEMP TABLE _stale_note_source_ids(source_id INTEGER PRIMARY KEY) WITHOUT ROWID;
|
||||||
|
+ INSERT INTO _stale_note_source_ids
|
||||||
|
+ SELECT gitlab_id
|
||||||
|
+ FROM notes
|
||||||
|
+ WHERE discussion_id = ? AND last_seen_at < ? AND is_system = 0;
|
||||||
|
+
|
||||||
|
+ DELETE FROM notes
|
||||||
|
+ WHERE discussion_id = ? AND last_seen_at < ?;
|
||||||
|
+
|
||||||
|
+ DELETE FROM documents
|
||||||
|
+ WHERE source_type = 'note'
|
||||||
|
+ AND source_id IN (SELECT source_id FROM _stale_note_source_ids);
|
||||||
|
+
|
||||||
|
+ DELETE FROM dirty_sources
|
||||||
|
+ WHERE source_type = 'note'
|
||||||
|
+ AND source_id IN (SELECT source_id FROM _stale_note_source_ids);
|
||||||
|
+
|
||||||
|
+ DROP TABLE _stale_note_source_ids;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Fix project-scoping and time-window semantics in `lore notes`**
|
||||||
|
Why this is better: the plan currently has a contradiction: clap `requires = "project"` blocks use of `defaultProject`, while query layer says default fallback is allowed. Also, `since/until` parsing should use one shared “now” to avoid subtle drift and inverted windows.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/PRD.md
|
||||||
|
+++ b/PRD.md
|
||||||
|
@@ Work Chunk 1B NotesArgs
|
||||||
|
-#[arg(long = "for-issue", ..., requires = "project")]
|
||||||
|
+#[arg(long = "for-issue", ...)]
|
||||||
|
pub for_issue: Option<i64>;
|
||||||
|
|
||||||
|
-#[arg(long = "for-mr", ..., requires = "project")]
|
||||||
|
+#[arg(long = "for-mr", ...)]
|
||||||
|
pub for_mr: Option<i64>;
|
||||||
|
|
||||||
|
@@ Work Chunk 1A Query Notes
|
||||||
|
-- `since`: `parse_since(since_str)` then `n.created_at >= ?`
|
||||||
|
-- `until`: `parse_since(until_str)` then `n.created_at <= ?`
|
||||||
|
+- Parse `since` and `until` with a single anchored `now_ms` captured once per command.
|
||||||
|
+- If user supplies `YYYY-MM-DD` for `--until`, interpret as end-of-day (23:59:59.999 UTC).
|
||||||
|
+- Validate `since <= until` after both parse with same anchor.
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Add an analytics mode (not a profile command): `lore notes --aggregate`**
|
||||||
|
Why this is better: this directly supports the stated use case (review patterns) without introducing the rejected “profile report” command. It keeps scope narrow and reuses existing filters.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/PRD.md
|
||||||
|
+++ b/PRD.md
|
||||||
|
@@ Phase 1
|
||||||
|
+### Work Chunk 1F: Aggregation Mode for Notes Listing
|
||||||
|
+
|
||||||
|
+Add optional aggregation on top of `lore notes`:
|
||||||
|
+- `--aggregate author|note_type|path|resolution`
|
||||||
|
+- `--top N` (default 20)
|
||||||
|
+
|
||||||
|
+Behavior:
|
||||||
|
+- Reuses all existing filters (`--since`, `--project`, `--for-mr`, etc.)
|
||||||
|
+- Returns grouped counts (+ percentage of filtered corpus)
|
||||||
|
+- Works in table/json/jsonl/csv
|
||||||
|
+
|
||||||
|
+Non-goal alignment:
|
||||||
|
+- This is not a narrative “reviewer profile” command.
|
||||||
|
+- It is a query primitive for downstream analysis.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Prevent note backfill from starving other document regeneration**
|
||||||
|
Why this is better: after migration/backfill, note dirty entries can dominate the queue and delay issue/MR/discussion updates. Add source-type fairness in regenerator scheduling.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/PRD.md
|
||||||
|
+++ b/PRD.md
|
||||||
|
@@ Work Chunk 2D
|
||||||
|
+#### Scheduling Revision
|
||||||
|
+Process dirty sources with weighted fairness instead of strict FIFO:
|
||||||
|
+- issue: 3
|
||||||
|
+- merge_request: 3
|
||||||
|
+- discussion: 2
|
||||||
|
+- note: 1
|
||||||
|
+
|
||||||
|
+Implementation sketch:
|
||||||
|
+- fetch next batch by source_type buckets
|
||||||
|
+- interleave according to weights
|
||||||
|
+- preserve retry semantics per source
|
||||||
|
|
||||||
|
+#### Tests to Write First
|
||||||
|
+#[test]
|
||||||
|
+fn test_note_backfill_does_not_starve_issue_and_mr_regeneration() { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Harden migration 023: remove invalid SQL assertions and move integrity checks to tests**
|
||||||
|
Why this is better: `RAISE(ABORT, ...)` in standalone `SELECT` is not valid SQLite usage outside triggers/check expressions. Keep migration SQL minimal/portable and enforce invariants in migration tests.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- a/PRD.md
|
||||||
|
+++ b/PRD.md
|
||||||
|
@@ Work Chunk 2A Migration SQL
|
||||||
|
--- Step 10: Integrity verification
|
||||||
|
-SELECT CASE
|
||||||
|
- WHEN ... THEN RAISE(ABORT, '...')
|
||||||
|
-END;
|
||||||
|
+-- Step 10 removed from SQL migration.
|
||||||
|
+-- Integrity verification is enforced in migration tests:
|
||||||
|
+-- 1) pre/post row-count equality
|
||||||
|
+-- 2) `PRAGMA foreign_key_check` is empty
|
||||||
|
+-- 3) documents_fts row count matches documents row count after rebuild
|
||||||
|
|
||||||
|
@@ Work Chunk 2A Tests
|
||||||
|
+#[test]
|
||||||
|
+fn test_migration_023_integrity_checks_pass() {
|
||||||
|
+ // pre/post counts, foreign_key_check empty, fts parity
|
||||||
|
+}
|
||||||
|
```
|
||||||
|
|
||||||
|
These 7 revisions improve correctness under failure, reduce churn risk, improve large-sync performance, and make the feature materially more useful for reviewer-analysis workflows without reintroducing any rejected recommendations.
|
||||||
190
docs/prd-per-note-search.feedback-5.md
Normal file
190
docs/prd-per-note-search.feedback-5.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
Here are the highest-impact revisions I’d make. None of these repeat anything in your `## Rejected Recommendations`.
|
||||||
|
|
||||||
|
1. **Add immutable reviewer identity (`author_id`) as a first-class key**
|
||||||
|
Why this improves the plan: the PRD’s core use case is year-scale reviewer profiling. Usernames are mutable in GitLab, so username-only filtering will fragment one reviewer into multiple identities over time. Adding `author_id` closes that correctness hole and makes historical analysis reliable.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Problem Statement
|
||||||
|
-1. **Query individual notes by author** — the `--author` filter on `lore search` only matches the first note's author per discussion thread
|
||||||
|
+1. **Query individual notes by reviewer identity** — support both mutable username and immutable GitLab `author_id` for stable longitudinal analysis
|
||||||
|
|
||||||
|
@@ Phase 0: Stable Note Identity
|
||||||
|
+### Work Chunk 0D: Immutable Author Identity Capture
|
||||||
|
+**Files:** `migrations/025_notes_author_id.sql`, `src/ingestion/discussions.rs`, `src/ingestion/mr_discussions.rs`, `src/cli/commands/list.rs`
|
||||||
|
+
|
||||||
|
+#### Implementation
|
||||||
|
+- Add nullable `notes.author_id INTEGER` and backfill from future syncs.
|
||||||
|
+- Populate `author_id` from GitLab note payload (`note.author.id`) on both issue and MR note ingestion paths.
|
||||||
|
+- Add `--author-id <int>` filter to `lore notes`.
|
||||||
|
+- Keep `--author` for ergonomics; when both provided, require both to match.
|
||||||
|
+
|
||||||
|
+#### Indexing
|
||||||
|
+- Add `idx_notes_author_id_created ON notes(project_id, author_id, created_at DESC, id DESC) WHERE is_system = 0;`
|
||||||
|
+
|
||||||
|
+#### Tests
|
||||||
|
+- `test_query_notes_filter_author_id_survives_username_change`
|
||||||
|
+- `test_query_notes_author_and_author_id_intersection`
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Strengthen partial-fetch safety from a boolean to an explicit fetch state contract**
|
||||||
|
Why this improves the plan: `fetch_complete: bool` is easy to misuse and fragile under retries/crashes. A run-scoped state model makes sweep correctness auditable and prevents accidental deletions when ingestion aborts midway.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Phase 0: Stable Note Identity
|
||||||
|
-### Work Chunk 0C: Sweep Safety Guard (Partial Fetch Protection)
|
||||||
|
+### Work Chunk 0C: Sweep Safety Guard with Run-Scoped Fetch State
|
||||||
|
|
||||||
|
@@ Implementation
|
||||||
|
-Add a `fetch_complete` parameter to the discussion ingestion functions. Only run the stale-note sweep when the fetch completed successfully:
|
||||||
|
+Add a run-scoped fetch state:
|
||||||
|
+- `FetchState::Complete`
|
||||||
|
+- `FetchState::Partial`
|
||||||
|
+- `FetchState::Failed`
|
||||||
|
+
|
||||||
|
+Only run sweep on `FetchState::Complete`.
|
||||||
|
+Persist `run_seen_at` once per sync run and pass unchanged through all discussion/note upserts.
|
||||||
|
+Require `run_seen_at` monotonicity per discussion before sweep (skip and warn otherwise).
|
||||||
|
|
||||||
|
@@ Tests to Write First
|
||||||
|
+#[test]
|
||||||
|
+fn test_failed_fetch_never_sweeps_even_after_partial_upserts() { ... }
|
||||||
|
+#[test]
|
||||||
|
+fn test_non_monotonic_run_seen_at_skips_sweep() { ... }
|
||||||
|
+#[test]
|
||||||
|
+fn test_retry_after_failed_fetch_then_complete_sweeps_correctly() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add DB-level cleanup triggers for note-document referential integrity**
|
||||||
|
Why this improves the plan: Work Chunk 0B handles the sweep path, but not every possible delete path. DB triggers give defense-in-depth so stale note docs cannot survive even if a future code path deletes notes differently.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 0B: Immediate Deletion Propagation
|
||||||
|
-Update both sweep functions to propagate deletion to documents and dirty_sources using set-based SQL
|
||||||
|
+Keep set-based SQL in sweep functions, and add DB-level cleanup triggers as a safety net.
|
||||||
|
|
||||||
|
@@ Work Chunk 2A: Schema Migration (023)
|
||||||
|
+-- Cleanup trigger: deleting a non-system note must delete note document + dirty queue row
|
||||||
|
+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;
|
||||||
|
+
|
||||||
|
+-- Cleanup trigger: if note flips to system, remove its document artifacts
|
||||||
|
+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;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Eliminate N+1 extraction cost with parent metadata caching in regeneration**
|
||||||
|
Why this improves the plan: backfilling ~8k notes with per-note parent/label lookups creates avoidable query amplification. Batch caching turns repeated joins into one-time lookups per parent entity and materially reduces rebuild time.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Phase 2: Per-Note Documents
|
||||||
|
+### Work Chunk 2I: Batch Parent Metadata Cache for Note Regeneration
|
||||||
|
+**Files:** `src/documents/regenerator.rs`, `src/documents/extractor.rs`
|
||||||
|
+
|
||||||
|
+#### Implementation
|
||||||
|
+- Add `NoteExtractionContext` cache keyed by `(noteable_type, parent_id)` containing:
|
||||||
|
+ - parent iid/title/url
|
||||||
|
+ - parent labels
|
||||||
|
+ - project path
|
||||||
|
+- In batch regeneration, prefetch parent metadata for note IDs in the current chunk.
|
||||||
|
+- Use cached metadata in `extract_note_document()` to avoid repeated parent/label queries.
|
||||||
|
+
|
||||||
|
+#### Tests
|
||||||
|
+- `test_note_regeneration_uses_parent_cache_consistently`
|
||||||
|
+- `test_note_regeneration_cache_hit_preserves_hash_determinism`
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Add embedding dedup cache keyed by semantic text hash**
|
||||||
|
Why this improves the plan: note docs will contain repeated short comments (“LGTM”, “nit: …”). Current doc-level hashing includes metadata, so identical semantic comments still re-embed many times. A semantic embedding hash cache cuts cost and speeds full rebuild/backfill without changing search behavior.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Phase 2: Per-Note Documents
|
||||||
|
+### Work Chunk 2J: Semantic Embedding Dedup for Notes
|
||||||
|
+**Files:** `migrations/026_embedding_cache.sql`, embedding pipeline module(s), `src/documents/extractor.rs`
|
||||||
|
+
|
||||||
|
+#### Implementation
|
||||||
|
+- Compute `embedding_text` for notes as: normalized note body + compact stable context (`parent_type`, `path`, `resolution`), excluding volatile fields.
|
||||||
|
+- Compute `embedding_hash = sha256(embedding_text)`.
|
||||||
|
+- Before embedding generation, lookup existing vector by `(model, embedding_hash)`.
|
||||||
|
+- Reuse cached vector when present; only call embedding model on misses.
|
||||||
|
+
|
||||||
|
+#### Tests
|
||||||
|
+- `test_identical_note_bodies_reuse_embedding_vector`
|
||||||
|
+- `test_embedding_hash_changes_when_semantic_context_changes`
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Add deterministic review-signal tags as derived labels**
|
||||||
|
Why this improves the plan: this makes output immediately more useful for reviewer-pattern analysis without adding a profile command (which is explicitly out of scope). It increases practical value of both `lore notes` and `lore search --type note` with low complexity.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Non-Goals
|
||||||
|
-- Adding a "reviewer profile" report command (that's a downstream use case built on this infrastructure)
|
||||||
|
+- Adding a "reviewer profile" report command (downstream), while allowing low-level derived signal tags as indexing primitives
|
||||||
|
|
||||||
|
@@ Phase 2: Per-Note Documents
|
||||||
|
+### Work Chunk 2K: Derived Review Signal Labels
|
||||||
|
+**Files:** `src/documents/extractor.rs`
|
||||||
|
+
|
||||||
|
+#### Implementation
|
||||||
|
+- Derive deterministic labels from note text + metadata:
|
||||||
|
+ - `signal:nit`
|
||||||
|
+ - `signal:blocking`
|
||||||
|
+ - `signal:security`
|
||||||
|
+ - `signal:performance`
|
||||||
|
+ - `signal:testing`
|
||||||
|
+- Attach via existing `document_labels` flow for note documents.
|
||||||
|
+- No new CLI mode required; existing label filters can consume these labels.
|
||||||
|
+
|
||||||
|
+#### Tests
|
||||||
|
+- `test_note_document_derives_signal_labels_nit`
|
||||||
|
+- `test_note_document_derives_signal_labels_security`
|
||||||
|
+- `test_signal_label_derivation_is_deterministic`
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Add high-precision note targeting filters (`--note-id`, `--gitlab-note-id`, `--discussion-id`)**
|
||||||
|
Why this improves the plan: debugging, incident response, and reproducibility all benefit from exact addressing. This is especially useful when validating sync correctness and cross-checking a specific note/document lifecycle.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ Work Chunk 1B: CLI Arguments & Command Wiring
|
||||||
|
pub struct NotesArgs {
|
||||||
|
+ /// Filter by local note row id
|
||||||
|
+ #[arg(long = "note-id", help_heading = "Filters")]
|
||||||
|
+ pub note_id: Option<i64>,
|
||||||
|
+
|
||||||
|
+ /// Filter by GitLab note id
|
||||||
|
+ #[arg(long = "gitlab-note-id", help_heading = "Filters")]
|
||||||
|
+ pub gitlab_note_id: Option<i64>,
|
||||||
|
+
|
||||||
|
+ /// Filter by local discussion id
|
||||||
|
+ #[arg(long = "discussion-id", help_heading = "Filters")]
|
||||||
|
+ pub discussion_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ Work Chunk 1A: Filter struct
|
||||||
|
pub struct NoteListFilters<'a> {
|
||||||
|
+ pub note_id: Option<i64>,
|
||||||
|
+ pub gitlab_note_id: Option<i64>,
|
||||||
|
+ pub discussion_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ Tests to Write First
|
||||||
|
+#[test]
|
||||||
|
+fn test_query_notes_filter_note_id_exact() { ... }
|
||||||
|
+#[test]
|
||||||
|
+fn test_query_notes_filter_gitlab_note_id_exact() { ... }
|
||||||
|
+#[test]
|
||||||
|
+fn test_query_notes_filter_discussion_id_exact() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, I can produce a single consolidated “iteration 5” PRD diff that merges these into your exact section ordering and updates the dependency graph/migration numbering end-to-end.
|
||||||
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.
|
||||||
2518
docs/prd-per-note-search.md
Normal file
2518
docs/prd-per-note-search.md
Normal file
File diff suppressed because it is too large
Load Diff
541
docs/user-journeys.md
Normal file
541
docs/user-journeys.md
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
# Lore CLI User Journeys
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Map realistic workflows for both human users and AI agents to identify gaps in the command surface and optimization opportunities. Each journey starts with a **problem** and traces the commands needed to reach a **resolution**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Human User Flows
|
||||||
|
|
||||||
|
### H1. Morning Standup Prep
|
||||||
|
|
||||||
|
**Problem:** "What happened since yesterday? I need to know what moved before standup."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore sync -q # Refresh data (quiet, no noise)
|
||||||
|
lore issues -s opened --since 1d # Issues that changed overnight
|
||||||
|
lore mrs -s opened --since 1d # MRs that moved
|
||||||
|
lore who @me # My current workload snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No single "activity feed" command. User runs 3 queries to get what should be one view. No `--since 1d` shorthand for "since yesterday." No `@me` alias for the authenticated user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H2. Sprint Planning: What's Ready to Pick Up?
|
||||||
|
|
||||||
|
**Problem:** "We're planning the next sprint. What's open, unassigned, and actionable?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore issues -s opened -p myproject # All open issues
|
||||||
|
lore issues -s opened -l "ready" # Issues labeled ready
|
||||||
|
lore issues -s opened --has-due # Issues with deadlines approaching
|
||||||
|
lore count issues -p myproject # How many total?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to filter by "unassigned" issues (missing `--no-assignee` flag). No way to sort by due date. No way to see priority/weight. Can't combine filters like "opened AND no assignee AND has due date."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H3. Investigating a Production Incident
|
||||||
|
|
||||||
|
**Problem:** "Deploy broke prod. I need the full timeline of what changed around the deploy."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore sync -q # Get latest
|
||||||
|
lore timeline "deploy" --since 7d # What happened around deploys
|
||||||
|
lore search "deploy" --type mr # MRs mentioning deploy
|
||||||
|
lore mrs 456 # Inspect the suspicious MR
|
||||||
|
lore who --overlap src/deploy/ # Who else touches deploy code
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Timeline is keyword-based, not event-based. Can't filter by "MRs merged in the last 24 hours" directly. No way to see which MRs were merged between two dates (release diff). Would benefit from `lore mrs -s merged --since 1d`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H4. Preparing to Review Someone's MR
|
||||||
|
|
||||||
|
**Problem:** "I was assigned to review MR !789. I need context before diving in."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore mrs 789 # Read the MR description + discussions
|
||||||
|
lore mrs 789 -o # Open in browser for the actual diff
|
||||||
|
lore who src/features/auth/ # Who are the experts in this area?
|
||||||
|
lore search "auth refactor" --type issue # Related issues for background
|
||||||
|
lore timeline "authentication" # History of auth changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to see the file list touched by an MR from the CLI (data is stored in `mr_file_changes` but not surfaced). No way to link an MR back to its closing issue(s) from the MR detail view. The cross-reference data exists in `entity_references` but isn't shown in `mrs <iid>` output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H5. Onboarding to an Unfamiliar Code Area
|
||||||
|
|
||||||
|
**Problem:** "I'm new to the team and need to understand how the billing module works."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore search "billing" -n 20 # What exists about billing?
|
||||||
|
lore who src/billing/ # Who knows billing best?
|
||||||
|
lore timeline "billing" --depth 2 # History of billing changes
|
||||||
|
lore mrs -s merged -l billing --since 6m # Recent merged billing work
|
||||||
|
lore issues -s opened -l billing # Outstanding billing issues
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to get a "module overview" in one command. The search spans issues, MRs, and discussions but doesn't summarize by category. No way to see the most-discussed or most-referenced entities (high-signal items for understanding).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H6. Finding the Right Reviewer for My PR
|
||||||
|
|
||||||
|
**Problem:** "I'm about to submit a PR touching auth and payments. Who should review?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore who src/features/auth/ # Auth experts
|
||||||
|
lore who src/features/payments/ # Payment experts
|
||||||
|
lore who @candidate1 # Check candidate1's workload
|
||||||
|
lore who @candidate2 # Check candidate2's workload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to query multiple paths at once (`lore who src/auth/ src/payments/`). No way to find the intersection of expertise. No workload-aware recommendation ("who knows this AND has bandwidth"). Four separate commands for what should be one decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H7. Understanding Why a Feature Was Built This Way
|
||||||
|
|
||||||
|
**Problem:** "This code is weird. Why was it implemented like this? What was the original discussion?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore search "feature-name rationale" # Search for decision context
|
||||||
|
lore timeline "feature-name" --depth 2 # Full history with cross-refs
|
||||||
|
lore issues 234 # Read the original issue
|
||||||
|
lore mrs 567 # Read the implementation MR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to search within a specific issue's or MR's discussion notes. The search covers documents (titles + descriptions) but per-note search isn't available yet (PRD exists). No way to navigate "issue 234 was closed by MR 567" without manually knowing both IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H8. Checking Team Workload Before Assigning Work
|
||||||
|
|
||||||
|
**Problem:** "I need to assign this urgent bug. Who has the least on their plate?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore who @alice # Alice's workload
|
||||||
|
lore who @bob # Bob's workload
|
||||||
|
lore who @carol # Carol's workload
|
||||||
|
lore who @dave # Dave's workload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No team-level workload view. Must query each person individually. No way to list "all assignees and their open issue counts." No concept of a team roster. Would benefit from `lore who --team` or `lore workload`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H9. Preparing Release Notes
|
||||||
|
|
||||||
|
**Problem:** "We're cutting a release. I need to summarize what's in this version."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore mrs -s merged --since 2w -p myproject # MRs merged since last release
|
||||||
|
lore issues -s closed --since 2w -p myproject # Issues closed since last release
|
||||||
|
lore mrs -s merged -l feature --since 2w # Feature MRs specifically
|
||||||
|
lore mrs -s merged -l bugfix --since 2w # Bugfix MRs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to filter by milestone (for version-based releases). Wait -- `issues` has `-m` for milestone but `mrs` does not. No changelog generation. No "what closed between tag A and tag B." No grouping by label for release note categories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H10. Finding and Closing Stale Issues
|
||||||
|
|
||||||
|
**Problem:** "Our backlog is bloated. Which issues haven't been touched in months?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore issues -s opened --sort updated --asc -n 50 # Oldest-updated first
|
||||||
|
# Then manually inspect each one...
|
||||||
|
lore issues 42 # Is this still relevant?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No `--before` or `--updated-before` filter (only `--since` exists). Can sort ascending but can't filter "not updated in 90 days." No staleness indicator. No bulk operations concept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H11. Understanding a Bug's Full History
|
||||||
|
|
||||||
|
**Problem:** "Bug #321 keeps getting reopened. I need to understand its entire lifecycle."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore issues 321 # Read the issue
|
||||||
|
lore timeline "bug-keyword" -p myproject # Try to find timeline events
|
||||||
|
# But timeline is keyword-based, not entity-based...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to get a timeline for a specific entity by IID. `lore timeline` requires a keyword query, not an entity reference. Would benefit from `lore timeline --issue 321` or `lore timeline --mr 456` to get the event history of a specific entity directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H12. Identifying Who to Ask About Failing Tests
|
||||||
|
|
||||||
|
**Problem:** "CI tests are failing in `src/lib/parser.rs`. Who last touched this?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore who src/lib/parser.rs # Expert lookup
|
||||||
|
lore who --overlap src/lib/parser.rs # Who else has touched it
|
||||||
|
lore search "parser" --type mr --since 2w # Recent MRs touching parser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Expert mode uses DiffNote analysis (code review comments), not actual file change tracking. The `mr_file_changes` table has the real data but `who` doesn't use it for attribution. Could be much more accurate with file-change-based expertise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H13. Tracking a Feature Across Multiple MRs
|
||||||
|
|
||||||
|
**Problem:** "The 'dark mode' feature spans 5 MRs. I need to see them all together."
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore mrs -l dark-mode # MRs with the label
|
||||||
|
lore issues -l dark-mode # Related issues
|
||||||
|
lore timeline "dark mode" --depth 2 # Cross-referenced events
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Works reasonably well with labels as the grouping mechanism. But if the team didn't label consistently, there's no way to discover related MRs by content similarity. No "related items" view that combines issues + MRs + discussions for a topic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H14. Checking if a Similar Fix Was Already Attempted
|
||||||
|
|
||||||
|
**Problem:** "Before I implement this fix, was something similar tried before?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore search "memory leak connection pool" # Semantic search
|
||||||
|
lore search "connection pool" --type mr -s all # Wait, no state filter on search
|
||||||
|
lore mrs -s closed -l bugfix # Closed bugfix MRs (coarse)
|
||||||
|
lore timeline "connection pool" # Historical context
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Search doesn't have a `--state` filter. Can't search only closed/merged items. The semantic search is powerful but can't be combined with entity state. Would benefit from `--state merged` on search to find past attempts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H15. Reviewing Discussions That Need My Attention
|
||||||
|
|
||||||
|
**Problem:** "Which discussion threads am I involved in that are still unresolved?"
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore who --active # All active unresolved discussions
|
||||||
|
lore who --active --since 30d # Wider window
|
||||||
|
# But can't filter to "discussions I'm in"...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** `--active` shows all unresolved discussions, not filtered by participant. No way to say "show me discussions where @me participated." No notification/mention tracking. No "my unresolved threads" view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: AI Agent Flows
|
||||||
|
|
||||||
|
### A1. Context Gathering Before Code Modification
|
||||||
|
|
||||||
|
**Problem:** Agent is about to modify `src/features/auth/session.rs` and needs full context.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J health # Pre-flight check
|
||||||
|
lore -J who src/features/auth/ # Who knows this area
|
||||||
|
lore -J search "auth session" -n 10 # Related issues/MRs
|
||||||
|
lore -J mrs -s merged --since 3m -l auth # Recent auth changes
|
||||||
|
lore -J who --overlap src/features/auth/session.rs # Concurrent work risk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to check "are there open MRs touching this file right now?" The overlap mode shows historical touches, not active branches. An agent needs to know about in-flight changes to avoid conflicts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A2. Auto-Triaging an Incoming Issue
|
||||||
|
|
||||||
|
**Problem:** Agent receives a new issue and needs to categorize it, find related work, and suggest assignees.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues 999 # Read the new issue
|
||||||
|
lore -J search "$(extract_keywords)" --explain # Find similar past issues
|
||||||
|
lore -J who src/affected/path/ # Suggest experts as assignees
|
||||||
|
lore -J issues -s opened -l same-label # Check for duplicates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to get just the description text for programmatic keyword extraction. `issues <iid>` returns full detail including discussions. Agent must parse the full response to extract the description for a secondary search. Would benefit from `--fields description` on detail view. No duplicate detection built in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A3. Generating Sprint Status Report
|
||||||
|
|
||||||
|
**Problem:** Agent needs to produce a weekly status report for the team.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues -s closed --since 1w --fields minimal # Completed work
|
||||||
|
lore -J issues -s opened --status "In progress" # In-flight work
|
||||||
|
lore -J mrs -s merged --since 1w --fields minimal # Merged PRs
|
||||||
|
lore -J mrs -s opened -D --fields minimal # Open non-draft MRs
|
||||||
|
lore -J count issues # Totals
|
||||||
|
lore -J count mrs # MR totals
|
||||||
|
lore -J who --active --since 1w # Discussions needing attention
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Seven separate queries for one report. No `lore summary` or `lore report` command. No way to get "issues transitioned from X to Y this week" (state change history exists in events but isn't queryable). No velocity metric (issues closed per week trend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A4. Finding Relevant Prior Art Before Implementing
|
||||||
|
|
||||||
|
**Problem:** Agent is implementing a caching layer and wants to find if similar patterns exist in the codebase's GitLab history.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J search "caching" --mode hybrid -n 20 --explain
|
||||||
|
lore -J search "cache invalidation" --mode hybrid -n 10
|
||||||
|
lore -J search "redis" --mode lexical --type discussion # Exact term in discussions
|
||||||
|
lore -J timeline "cache" --since 1y # Wait, max is 1y? Let's try 12m
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to search discussion notes individually (per-note search). Discussions are aggregated into documents, so individual note-level matches are lost. The `--explain` flag helps but doesn't show which specific note matched. No `--since 1y` or `--since 12m` duration format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A5. Building Context for PR Description
|
||||||
|
|
||||||
|
**Problem:** Agent wrote code and needs to generate a PR description that references relevant issues.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J search "feature description keywords" --type issue
|
||||||
|
lore -J issues -s opened -l feature-label --fields iid,title,web_url
|
||||||
|
# Cross-reference: which issues does this MR close?
|
||||||
|
# No command for this -- must manually scan search results
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to query the `entity_references` table directly. Agent can't ask "which issues reference MR !456" or "which issues contain 'closes #123' in their text." The data exists but isn't exposed as a query surface. Would benefit from `lore refs --mr 456` or `lore refs --issue 123`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A6. Identifying Affected Experts for Review Assignment
|
||||||
|
|
||||||
|
**Problem:** Agent needs to automatically assign reviewers based on the files changed in an MR.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J mrs 456 # Get MR details
|
||||||
|
# Parse file paths from response... but file changes aren't in the output
|
||||||
|
lore -J who src/path/from/mr/ # Query each path
|
||||||
|
lore -J who src/another/path/ # One at a time...
|
||||||
|
lore -J who @candidate --fields minimal # Check workload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** MR detail view (`mrs <iid>`) doesn't include the file change list from `mr_file_changes`. Agent can't programmatically extract which files an MR touches. Must fall back to GitLab API or guess from description. The `who` command doesn't accept multiple paths. No "auto-reviewer" suggestion combining expertise + availability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A7. Incident Investigation and Timeline Reconstruction
|
||||||
|
|
||||||
|
**Problem:** Agent needs to reconstruct what happened during an outage for a postmortem.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J timeline "outage" --since 3d --depth 2 --expand-mentions
|
||||||
|
lore -J search "error 500" --since 3d
|
||||||
|
lore -J mrs -s merged --since 3d -p production-service
|
||||||
|
lore -J issues --status "In progress" -p production-service
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Timeline is keyword-seeded, which means if the outage wasn't described with that exact term, seeds may miss it. No way to seed a timeline from an entity ID (e.g., "start from issue #321 and expand outward"). No severity/priority filter. No way to correlate with merge times.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A8. Cross-Project Impact Assessment
|
||||||
|
|
||||||
|
**Problem:** Agent needs to understand how a breaking API change in project A affects projects B and C.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J search "api-endpoint-name" -p project-a
|
||||||
|
lore -J search "api-endpoint-name" -p project-b
|
||||||
|
lore -J search "api-endpoint-name" -p project-c
|
||||||
|
# Or without project filter to search everywhere:
|
||||||
|
lore -J search "api-endpoint-name" -n 50
|
||||||
|
lore -J timeline "api-endpoint-name" --depth 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Cross-project references in entity_references are tracked but the timeline shows unresolved references for entities not synced locally. No way to see a cross-project dependency map. Search works across projects but doesn't group results by project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A9. Automated Stale Issue Recommendations
|
||||||
|
|
||||||
|
**Problem:** Agent runs weekly to identify issues that should be closed or re-prioritized.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues -s opened --sort updated --asc -n 100 # Oldest first
|
||||||
|
# For each issue, check:
|
||||||
|
lore -J issues <iid> # Read details
|
||||||
|
lore -J search "<issue title keywords>" # Any recent activity?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No `--updated-before` filter, so agent must fetch all and filter client-side. No way to detect "issue has no assignee AND no activity in 90 days." The 100-issue limit means pagination is needed for large backlogs, but there's no cursor/offset pagination -- only `--limit`. Agent must do N+1 queries to inspect each candidate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A10. Code Review Preparation (File-Level Context)
|
||||||
|
|
||||||
|
**Problem:** Agent is reviewing MR !789 and needs to understand the history of each changed file.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J mrs 789 # Get MR details
|
||||||
|
# Can't get file list from output...
|
||||||
|
# Fall back to search by MR title keywords
|
||||||
|
lore -J search "feature-from-mr" --type mr
|
||||||
|
lore -J who src/guessed/path/ # Expertise for each file
|
||||||
|
lore -J who --overlap src/guessed/path/ # Concurrent changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Same as A6 -- `mr_file_changes` data isn't exposed. Agent is blind to the actual files in the MR unless it parses the description or uses the GitLab API directly. This is the single biggest gap for automated code review workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A11. Building a Knowledge Graph of Entity Relationships
|
||||||
|
|
||||||
|
**Problem:** Agent wants to map how issues, MRs, and discussions are connected for a feature.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J search "feature-name" -n 30
|
||||||
|
lore -J timeline "feature-name" --depth 2 --max-entities 100
|
||||||
|
# Timeline shows expanded entities and cross-refs, but...
|
||||||
|
# No way to query entity_references directly
|
||||||
|
# No way to get "all entities that reference issue #123"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** The `entity_references` table (closes, related, mentioned) is used internally by timeline but isn't queryable as a standalone command. Agent can't ask "what closes issue #123?" or "what does MR !456 reference?" No graph export. Would enable powerful dependency mapping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A12. Release Readiness Assessment
|
||||||
|
|
||||||
|
**Problem:** Agent needs to verify all issues in milestone "v2.0" are closed and MRs are merged.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues -m "v2.0" -s opened # Any open issues in milestone?
|
||||||
|
lore -J issues -m "v2.0" -s closed # Closed issues
|
||||||
|
# MRs don't have milestone filter...
|
||||||
|
lore -J mrs -s opened -l "v2.0" # Try label as proxy
|
||||||
|
lore -J who --active -p myproject # Unresolved discussions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** MRs don't have a `--milestone` filter (issues do). No way to check "all MRs linked to issues in milestone v2.0" -- would require joining `entity_references` with issue milestone. No release checklist concept. No way to verify "every issue in this milestone has a closing MR."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A13. Answering "What Changed?" Between Two Points
|
||||||
|
|
||||||
|
**Problem:** Agent needs to diff project state between two dates for a stakeholder report.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues -s closed --since 2w --fields minimal # Recently closed
|
||||||
|
lore -J issues -s opened --since 2w --fields minimal # Recently opened
|
||||||
|
lore -J mrs -s merged --since 2w --fields minimal # Recently merged
|
||||||
|
# But no way to get "issues that CHANGED STATE" in a window
|
||||||
|
# An issue opened 3 months ago but closed yesterday won't appear in --since 2w for issues -s opened
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** `--since` filters by `updated_at`, not by "state changed at." An issue closed yesterday but created 6 months ago would appear in `issues -s closed --since 1d` (because updated_at changed), but the semantics are subtle. No explicit "state transitions in time window" query. The resource_state_events table has this data but it's not exposed as a filter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A14. Meeting Prep: Summarize Recent Activity for a Stakeholder
|
||||||
|
|
||||||
|
**Problem:** Agent needs to prepare a 2-minute summary for a project sponsor meeting.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J count issues -p project # Current totals
|
||||||
|
lore -J count mrs -p project # MR totals
|
||||||
|
lore -J issues -s closed --since 1w -p project --fields minimal
|
||||||
|
lore -J mrs -s merged --since 1w -p project --fields minimal
|
||||||
|
lore -J issues -s opened --status "In progress" -p project
|
||||||
|
lore -J who --active -p project --since 1w
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** Six queries, same as A3. No summary/dashboard command. Agent must synthesize all responses. No trend data (is the open issue count growing or shrinking?). No "highlights" extraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A15. Determining If Work Is Safe to Start (Conflict Detection)
|
||||||
|
|
||||||
|
**Problem:** Agent is about to start work on an issue and needs to check nobody else is already working on it.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
lore -J issues 123 # Read the issue
|
||||||
|
# Check assignees from response
|
||||||
|
lore -J mrs -s opened -A other-person # Are they working on related MRs?
|
||||||
|
lore -J who --overlap src/target/path/ # Anyone actively touching these files?
|
||||||
|
lore -J search "issue-123-keywords" --type mr -s opened # Wait, search has no --state
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gap identified:** No way to check "is there an open MR that closes issue #123?" -- the entity_references data exists but isn't queryable. Search doesn't support `--state` filter. No "conflict detection" or "in-flight work" check. Agent must do multiple queries and manually correlate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3: Gap Summary
|
||||||
|
|
||||||
|
### Critical Gaps (high impact, blocks common workflows)
|
||||||
|
|
||||||
|
| # | Gap | Affected Flows | Suggested Command/Flag |
|
||||||
|
|---|-----|----------------|----------------------|
|
||||||
|
| 1 | **MR file changes not surfaced** | H4, A6, A10 | `lore mrs <iid> --files` or include in detail view |
|
||||||
|
| 2 | **Entity references not queryable** | H7, A5, A11, A15 | `lore refs --issue 123` / `lore refs --mr 456` |
|
||||||
|
| 3 | **Per-note search missing** | H7, A4 | `lore search --granularity note` (PRD exists) |
|
||||||
|
| 4 | **No entity-based timeline** | H11, A7 | `lore timeline --issue 321` / `lore timeline --mr 456` |
|
||||||
|
| 5 | **No @me / current-user alias** | H1, H15 | Resolve from auth token automatically |
|
||||||
|
|
||||||
|
### Important Gaps (significant friction, multiple workarounds needed)
|
||||||
|
|
||||||
|
| # | Gap | Affected Flows | Suggested Command/Flag |
|
||||||
|
|---|-----|----------------|----------------------|
|
||||||
|
| 6 | **No activity feed / summary** | H1, A3, A14 | `lore activity --since 1d` or `lore summary` |
|
||||||
|
| 7 | **No multi-path who query** | H6, A6 | `lore who src/path1/ src/path2/` |
|
||||||
|
| 8 | **No --state filter on search** | H14, A15 | `lore search --state merged` |
|
||||||
|
| 9 | **MRs missing --milestone filter** | H9, A12 | `lore mrs -m "v2.0"` |
|
||||||
|
| 10 | **No --no-assignee / --unassigned** | H2 | `lore issues --no-assignee` |
|
||||||
|
| 11 | **No --updated-before filter** | H10, A9 | `lore issues --before 90d` or `--stale 90d` |
|
||||||
|
| 12 | **No team workload view** | H8 | `lore who --team` or `lore workload` |
|
||||||
|
|
||||||
|
### Nice-to-Have Gaps (would improve agent efficiency)
|
||||||
|
|
||||||
|
| # | Gap | Affected Flows | Suggested Command/Flag |
|
||||||
|
|---|-----|----------------|----------------------|
|
||||||
|
| 13 | **No pagination/offset** | A9 | `--offset 100` for large result sets |
|
||||||
|
| 14 | **No detail --fields on show** | A2 | `lore issues 999 --fields description` |
|
||||||
|
| 15 | **No cross-project grouping** | A8 | `lore search --group-by project` |
|
||||||
|
| 16 | **No trend/velocity metrics** | A3, A14 | `lore trends issues --period week` |
|
||||||
|
| 17 | **No --for-issue on mrs** | A12, A15 | `lore mrs --closes 123` (query entity_refs) |
|
||||||
|
| 18 | **1y/12m duration not supported** | A4 | Support `1y`, `12m`, `365d` in --since |
|
||||||
|
| 19 | **No discussion participant filter** | H15 | `lore who --active --participant @me` |
|
||||||
|
| 20 | **No sort by due date** | H2 | `lore issues --sort due` |
|
||||||
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.
|
||||||
|
|||||||
214
plans/tui-prd-v2-frankentui.feedback-10.md
Normal file
214
plans/tui-prd-v2-frankentui.feedback-10.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
I found 9 high-impact revisions that materially improve correctness, robustness, and usability without reintroducing anything in `## Rejected Recommendations`.
|
||||||
|
|
||||||
|
### 1. Prevent stale async overwrites on **all** screens (not just search)
|
||||||
|
Right now, only `SearchExecuted` is generation-guarded. `IssueListLoaded`, `MrListLoaded`, `IssueDetailLoaded`, etc. can still race and overwrite newer state after rapid navigation/filtering. This is the biggest correctness risk in the current design.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ message.rs
|
||||||
|
- IssueListLoaded(Vec<IssueRow>),
|
||||||
|
+ IssueListLoaded { generation: u64, rows: Vec<IssueRow> },
|
||||||
|
@@
|
||||||
|
- MrListLoaded(Vec<MrRow>),
|
||||||
|
+ MrListLoaded { generation: u64, rows: Vec<MrRow> },
|
||||||
|
@@
|
||||||
|
- IssueDetailLoaded { key: EntityKey, detail: IssueDetail },
|
||||||
|
- MrDetailLoaded { key: EntityKey, detail: MrDetail },
|
||||||
|
+ IssueDetailLoaded { generation: u64, key: EntityKey, detail: IssueDetail },
|
||||||
|
+ MrDetailLoaded { generation: u64, key: EntityKey, detail: MrDetail },
|
||||||
|
|
||||||
|
@@ update()
|
||||||
|
- Msg::IssueListLoaded(result) => {
|
||||||
|
+ Msg::IssueListLoaded { generation, rows } => {
|
||||||
|
+ if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation) {
|
||||||
|
+ return Cmd::none();
|
||||||
|
+ }
|
||||||
|
self.state.set_loading(false);
|
||||||
|
- self.state.issue_list.set_result(result);
|
||||||
|
+ self.state.issue_list.set_result(rows);
|
||||||
|
Cmd::none()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Make cancellation safe with task-owned SQLite interrupt handles
|
||||||
|
The plan mentions `sqlite3_interrupt()` but uses pooled shared reader connections. Interrupting a shared connection can cancel unrelated work. Use per-task reader leases and store `InterruptHandle` in `TaskHandle`.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ DbManager
|
||||||
|
- readers: Vec<Mutex<Connection>>,
|
||||||
|
+ readers: Vec<Mutex<Connection>>,
|
||||||
|
+ // task-scoped interrupt handles prevent cross-task cancellation bleed
|
||||||
|
+ // each dispatched query receives an owned ReaderLease
|
||||||
|
|
||||||
|
+pub struct ReaderLease {
|
||||||
|
+ conn: Connection,
|
||||||
|
+ interrupt: rusqlite::InterruptHandle,
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+impl DbManager {
|
||||||
|
+ pub fn lease_reader(&self) -> Result<ReaderLease, LoreError> { ... }
|
||||||
|
+}
|
||||||
|
|
||||||
|
@@ TaskHandle
|
||||||
|
pub struct TaskHandle {
|
||||||
|
pub key: TaskKey,
|
||||||
|
pub generation: u64,
|
||||||
|
pub cancel: Arc<CancelToken>,
|
||||||
|
+ pub interrupt: Option<rusqlite::InterruptHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ cancellation
|
||||||
|
-Query interruption: ... fires sqlite3_interrupt() on the connection.
|
||||||
|
+Query interruption: cancel triggers the task's owned InterruptHandle only.
|
||||||
|
+No shared-connection interrupt is permitted.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Harden keyset pagination for multi-project and sort changes
|
||||||
|
`updated_at + iid` cursor is not enough when rows share timestamps across projects or sort mode changes. This can duplicate/skip rows.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ issue_list.rs
|
||||||
|
-pub struct IssueCursor {
|
||||||
|
- pub updated_at: i64,
|
||||||
|
- pub iid: i64,
|
||||||
|
-}
|
||||||
|
+pub struct IssueCursor {
|
||||||
|
+ pub sort_field: SortField,
|
||||||
|
+ pub sort_order: SortOrder,
|
||||||
|
+ pub updated_at: Option<i64>,
|
||||||
|
+ pub created_at: Option<i64>,
|
||||||
|
+ pub iid: i64,
|
||||||
|
+ pub project_id: i64, // deterministic tie-breaker
|
||||||
|
+ pub filter_hash: u64, // invalidates stale cursors on filter mutation
|
||||||
|
+}
|
||||||
|
|
||||||
|
@@ pagination section
|
||||||
|
-Windowed keyset pagination ...
|
||||||
|
+Windowed keyset pagination uses deterministic tuple ordering:
|
||||||
|
+`ORDER BY <primary_sort>, project_id, iid`.
|
||||||
|
+Cursor is rejected if `filter_hash` or sort tuple mismatches current query.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Replace ad-hoc filter parsing with a small typed DSL
|
||||||
|
Current `split_whitespace()` parser is brittle and silently lossy. Add quoted values, negation, and strict parse errors.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ filter_bar.rs
|
||||||
|
- fn parse_tokens(&mut self) {
|
||||||
|
- let text = self.input.value().to_string();
|
||||||
|
- self.tokens = text.split_whitespace().map(|chunk| { ... }).collect();
|
||||||
|
- }
|
||||||
|
+ fn parse_tokens(&mut self) {
|
||||||
|
+ // grammar (v1):
|
||||||
|
+ // term := [ "-" ] (field ":" value | quoted_text | bare_text)
|
||||||
|
+ // value := quoted | unquoted
|
||||||
|
+ // examples:
|
||||||
|
+ // state:opened label:"P1 blocker" -author:bot since:14d
|
||||||
|
+ self.tokens = filter_dsl::parse(self.input.value())?;
|
||||||
|
+ }
|
||||||
|
|
||||||
|
@@ section 8 / keybindings-help
|
||||||
|
+Filter parser surfaces actionable inline diagnostics with cursor position,
|
||||||
|
+and never silently drops unknown fields.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Add render caches for markdown/tree shaping
|
||||||
|
Markdown and tree shaping are currently recomputed on every frame in several snippets. Cache render artifacts by `(entity, width, theme, content_hash)` to protect frame time.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ module structure
|
||||||
|
+ render_cache.rs # Width/theme/content-hash keyed cache for markdown + tree layouts
|
||||||
|
|
||||||
|
@@ Assumptions / Performance
|
||||||
|
+Detail and search preview rendering uses memoized render artifacts.
|
||||||
|
+Cache invalidation triggers: content hash change, terminal width change, theme change.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Use one-shot timers for debounce/prefix timeout
|
||||||
|
`Every` is periodic; it wakes repeatedly and can produce edge-case repeated firings. One-shot subscriptions are cleaner and cheaper.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ subscriptions()
|
||||||
|
- if self.state.search.debounce_pending() {
|
||||||
|
- subs.push(Box::new(
|
||||||
|
- Every::with_id(3, Duration::from_millis(200), move || {
|
||||||
|
- Msg::SearchDebounceFired { generation }
|
||||||
|
- })
|
||||||
|
- ));
|
||||||
|
- }
|
||||||
|
+ if self.state.search.debounce_pending() {
|
||||||
|
+ subs.push(Box::new(
|
||||||
|
+ After::with_id(3, Duration::from_millis(200), move || {
|
||||||
|
+ Msg::SearchDebounceFired { generation }
|
||||||
|
+ })
|
||||||
|
+ ));
|
||||||
|
+ }
|
||||||
|
|
||||||
|
@@ InputMode GoPrefix timeout
|
||||||
|
-The tick subscription compares clock instant...
|
||||||
|
+GoPrefix timeout is a one-shot `After(500ms)` tied to prefix generation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. New feature: list “Quick Peek” panel (`Space`) for triage speed
|
||||||
|
This adds immediate value without v2-level scope. Users can inspect selected issue/MR metadata/snippet without entering detail and coming back.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ 5.2 Issue List
|
||||||
|
-Interaction: Enter detail
|
||||||
|
+Interaction: Enter detail, Space quick-peek (toggle right preview pane)
|
||||||
|
|
||||||
|
@@ 5.4 MR List
|
||||||
|
+Quick Peek mode mirrors Issue List: metadata + first discussion snippet + cross-refs.
|
||||||
|
|
||||||
|
@@ 8.2 List Screens
|
||||||
|
| `Enter` | Open selected item |
|
||||||
|
+| `Space` | Toggle Quick Peek panel for selected row |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Upgrade compatibility handshake from integer to machine-readable contract
|
||||||
|
Single integer compat is too coarse for real drift detection. Keep it simple but structured.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ Nightly Rust Strategy / Compatibility contract
|
||||||
|
- 1. Binary compat version (`lore-tui --compat-version`) — integer check ...
|
||||||
|
+ 1. Binary compat contract (`lore-tui --compat-json`) — JSON:
|
||||||
|
+ `{ "protocol": 1, "compat_version": 2, "min_schema": 14, "max_schema": 16, "build": "..." }`
|
||||||
|
+ `lore` validates protocol + compat + schema range before spawn.
|
||||||
|
|
||||||
|
@@ CLI integration
|
||||||
|
-fn validate_tui_compat(...) { ... --compat-version ... }
|
||||||
|
+fn validate_tui_compat(...) { ... --compat-json ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Fix sync stream bug and formalize progress coalescing
|
||||||
|
The current snippet calls `try_send` for progress twice in one callback path and depth math is wrong. Also progress spam should be coalesced by lane.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/PRD.md b/PRD.md
|
||||||
|
@@ start_sync_task()
|
||||||
|
- let current_depth = 2048 - tx.try_send(Msg::SyncProgress(event.clone()))
|
||||||
|
- .err().map_or(0, |_| 1);
|
||||||
|
- max_queue_depth = max_queue_depth.max(current_depth);
|
||||||
|
- if tx.try_send(Msg::SyncProgress(event.clone())).is_err() {
|
||||||
|
+ // coalesce by lane key at <=30Hz; one send attempt per flush
|
||||||
|
+ coalescer.update(event.clone());
|
||||||
|
+ if let Some(batch) = coalescer.flush_ready() {
|
||||||
|
+ if tx.try_send(Msg::SyncProgressBatch(batch)).is_err() {
|
||||||
|
dropped_count += 1;
|
||||||
|
let _ = tx.try_send(Msg::SyncBackpressureDrop);
|
||||||
|
+ } else {
|
||||||
|
+ max_queue_depth = max_queue_depth.max(observed_queue_depth());
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you want, I can produce a single consolidated patch-style rewrite of Sections `4.x`, `5.2/5.4`, `8.2`, `9.3`, and `10.x` so you can drop it directly into iteration 10.
|
||||||
177
plans/tui-prd-v2-frankentui.feedback-11.md
Normal file
177
plans/tui-prd-v2-frankentui.feedback-11.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
I reviewed the full PRD and avoided everything listed under `## Rejected Recommendations`.
|
||||||
|
These are the highest-impact revisions I’d make.
|
||||||
|
|
||||||
|
1. Stable list pagination via snapshot fences
|
||||||
|
Why this improves the plan: your keyset cursor is deterministic for sort/filter, but still vulnerable to duplicates/skips if sync writes land between page fetches. Add a per-browse snapshot fence so one browse session sees a stable dataset.
|
||||||
|
Tradeoff: newest rows are hidden until refresh, which is correct for deterministic triage.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 5.2 Issue List
|
||||||
|
- **Pagination:** Windowed keyset pagination with explicit cursor state.
|
||||||
|
+ **Pagination:** Windowed keyset pagination with explicit cursor state.
|
||||||
|
+ **Snapshot fence:** On list entry, capture `snapshot_upper_updated_at` (ms) and pin all
|
||||||
|
+ list-page queries to `updated_at <= snapshot_upper_updated_at`. This guarantees no duplicate
|
||||||
|
+ or skipped rows during scrolling even if sync writes occur concurrently.
|
||||||
|
+ A "new data available" badge appears when a newer sync completes; `r` refreshes the fence.
|
||||||
|
|
||||||
|
@@ 5.4 MR List
|
||||||
|
- **Pagination:** Same windowed keyset pagination strategy as Issue List.
|
||||||
|
+ **Pagination:** Same strategy plus snapshot fence (`updated_at <= snapshot_upper_updated_at`)
|
||||||
|
+ for deterministic cross-page traversal under concurrent sync writes.
|
||||||
|
|
||||||
|
@@ 4.7 Navigation Stack Implementation
|
||||||
|
+ Browsing sessions carry a per-screen `BrowseSnapshot` token to preserve stable ordering
|
||||||
|
+ until explicit refresh or screen re-entry.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Query budgets and soft deadlines
|
||||||
|
Why this improves the plan: currently “slow query” is handled mostly by cancellation and stale-drop. Add explicit latency budgets so UI responsiveness stays predictable under worst-case filters.
|
||||||
|
Tradeoff: sometimes user gets partial/truncated results first, followed by full results on retry/refine.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 4.5 Async Action System
|
||||||
|
+ #### 4.5.2 Query Budgets and Soft Deadlines
|
||||||
|
+ Each query type gets a budget:
|
||||||
|
+ - list window fetch: 120ms target, 250ms hard deadline
|
||||||
|
+ - detail phase-1 metadata: 75ms target, 150ms hard deadline
|
||||||
|
+ - search lexical/hybrid: 250ms hard deadline
|
||||||
|
+ On hard deadline breach, return `QueryDegraded { truncated: true }` and show inline badge:
|
||||||
|
+ "results truncated; refine filter or press r to retry full".
|
||||||
|
+ Implementation uses SQLite progress handler + per-task interrupt deadline.
|
||||||
|
|
||||||
|
@@ 9.3 Phase 0 — Toolchain Gate
|
||||||
|
+ 26. Query deadline behavior validated: hard deadline cancels query and renders degraded badge
|
||||||
|
+ without blocking input loop.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Targeted cache invalidation and prewarm after sync
|
||||||
|
Why this improves the plan: `invalidate_all()` after sync throws away hot detail cache and hurts the exact post-sync workflow you optimized for. Invalidate only changed keys and prewarm likely-next entities.
|
||||||
|
Tradeoff: slightly more bookkeeping in sync result handling.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 4.1 Module Structure
|
||||||
|
- entity_cache.rs # Bounded LRU cache ... Invalidated on sync completion.
|
||||||
|
+ entity_cache.rs # Bounded LRU cache with selective invalidation by changed EntityKey
|
||||||
|
+ # and optional post-sync prewarm of top changed entities.
|
||||||
|
|
||||||
|
@@ 4.4 App — Implementing the Model Trait (Msg::SyncCompleted)
|
||||||
|
- // Invalidate entity cache — synced data may have changed.
|
||||||
|
- self.entity_cache.invalidate_all();
|
||||||
|
+ // Selective invalidation: evict only changed entities from sync delta.
|
||||||
|
+ self.entity_cache.invalidate_keys(&result.changed_entity_keys);
|
||||||
|
+ // Prewarm top N changed/new entities for immediate post-sync triage.
|
||||||
|
+ self.enqueue_cache_prewarm(&result.changed_entity_keys);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Exact “what changed” navigation without new DB tables
|
||||||
|
Why this improves the plan: your summary currently uses timestamp filter; this can include unrelated updates and miss edge cases. Keep an in-memory delta ledger per sync run and navigate by exact IDs.
|
||||||
|
Tradeoff: small memory overhead per run; no schema migration required.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 5.9 Sync (Summary mode)
|
||||||
|
-- `i` navigates to Issue List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison)
|
||||||
|
-- `m` navigates to MR List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison)
|
||||||
|
+- `i` navigates to Issue List filtered by exact issue IDs changed in this sync run
|
||||||
|
+- `m` navigates to MR List filtered by exact MR IDs changed in this sync run
|
||||||
|
+ (fallback to timestamp filter only if run delta not available)
|
||||||
|
|
||||||
|
@@ 10.1 New Files
|
||||||
|
+crates/lore-tui/src/sync_delta_ledger.rs # In-memory per-run exact changed/new IDs (issues/MRs/discussions)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Adaptive render governor (runtime performance safety)
|
||||||
|
Why this improves the plan: capability detection is static; you also need dynamic adaptation when frame time/backpressure worsens (SSH, tmux nesting, huge logs).
|
||||||
|
Tradeoff: visual richness may step down automatically under load.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 3.4.1 Capability-Adaptive Rendering
|
||||||
|
+#### 3.4.2 Adaptive Render Governor
|
||||||
|
+Runtime monitors frame time and stream pressure:
|
||||||
|
+- if frame p95 > 40ms or sync drops spike, switch to lighter profile:
|
||||||
|
+ plain markdown, reduced tree guides, slower spinner tick, less frequent repaint.
|
||||||
|
+- when stable for N seconds, restore previous profile.
|
||||||
|
+CLI override:
|
||||||
|
+`lore tui --render-profile=auto|quality|balanced|speed`
|
||||||
|
|
||||||
|
@@ 9.3 Phase 0 — Toolchain Gate
|
||||||
|
+27. Frame-time governor validated: under induced load, UI remains responsive and input latency
|
||||||
|
+stays within p95 < 75ms while auto-downgrading render profile.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. First-run/data-not-ready screen (not an init wizard)
|
||||||
|
Why this improves the plan: empty DB or missing indexes will otherwise feel broken. A dedicated read-only readiness screen improves first impression and self-recovery.
|
||||||
|
Tradeoff: one extra lightweight screen/state.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 4.3 Core Types (Screen enum)
|
||||||
|
Sync,
|
||||||
|
Stats,
|
||||||
|
Doctor,
|
||||||
|
+ Bootstrap,
|
||||||
|
|
||||||
|
@@ 5.11 Doctor / Stats (Info Screens)
|
||||||
|
+### 5.12 Bootstrap (Data Readiness)
|
||||||
|
+Shown when no synced projects/documents are present or required indexes are missing.
|
||||||
|
+Displays concise readiness checks and exact CLI commands to recover:
|
||||||
|
+`lore sync`, `lore migrate`, `lore --robot doctor`.
|
||||||
|
+Read-only; no auto-execution.
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Global project scope pinning across screens
|
||||||
|
Why this improves the plan: users repeatedly apply the same project filter across dashboard/list/search/timeline/who. Add a global scope pin to reduce repetitive filtering and speed triage.
|
||||||
|
Tradeoff: must show clear “scope active” indicator to avoid confusion.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 4.1 Module Structure
|
||||||
|
+ scope.rs # Global project scope context (all-projects or pinned project set)
|
||||||
|
|
||||||
|
@@ 8.1 Global (Available Everywhere)
|
||||||
|
+| `P` | Open project scope picker / toggle global scope pin |
|
||||||
|
|
||||||
|
@@ 4.10 State Module — Complete
|
||||||
|
+pub global_scope: ScopeContext, // Applies to dashboard/list/search/timeline/who queries
|
||||||
|
|
||||||
|
@@ 10.11 Action Module — Query Bridge
|
||||||
|
- pub fn fetch_issues(conn: &Connection, filter: &IssueFilter) -> Result<Vec<IssueListRow>, LoreError>
|
||||||
|
+ pub fn fetch_issues(conn: &Connection, scope: &ScopeContext, filter: &IssueFilter) -> Result<Vec<IssueListRow>, LoreError>
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Concurrency correctness tests for pagination and cancellation races
|
||||||
|
Why this improves the plan: current reliability tests are good, but missing a direct test for duplicate/skip behavior under concurrent sync writes while paginating.
|
||||||
|
Tradeoff: additional integration test complexity.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 9.2 Phases (Phase 5.5 — Reliability Test Pack)
|
||||||
|
+ Concurrent pagination/write race tests :p55j, after p55h, 1d
|
||||||
|
+ Query deadline cancellation race tests :p55k, after p55j, 0.5d
|
||||||
|
|
||||||
|
@@ 9.3 Phase 0 — Toolchain Gate
|
||||||
|
+28. Concurrent pagination/write test proves no duplicates/skips within a pinned browse snapshot.
|
||||||
|
+29. Cancellation race test proves no cross-task interrupt bleed and no stuck loading state.
|
||||||
|
```
|
||||||
|
|
||||||
|
9. URL opening policy v2: allowlisted GitLab entity paths
|
||||||
|
Why this improves the plan: host validation is necessary but not always sufficient. Restrict default browser opens to known GitLab entity paths and require confirmation for unusual paths on same host.
|
||||||
|
Tradeoff: occasional extra prompt for uncommon but valid URLs.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/docs/plans/gitlore-tui-prd-v2.md b/docs/plans/gitlore-tui-prd-v2.md
|
||||||
|
@@ 3.1 Risk Matrix
|
||||||
|
-| 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). Unknown same-host paths require explicit confirm modal. |
|
||||||
|
|
||||||
|
@@ 10.4.1 Terminal Safety — Untrusted Text Sanitization
|
||||||
|
- pub fn is_safe_url(url: &str, allowed_origins: &[AllowedOrigin]) -> bool
|
||||||
|
+ pub fn classify_safe_url(url: &str, policy: &UrlPolicy) -> UrlSafety
|
||||||
|
+ // UrlSafety::{AllowedEntityPath, AllowedButUnrecognizedPath, Blocked}
|
||||||
|
```
|
||||||
|
|
||||||
|
These 9 changes are additive, avoid previously rejected ideas, and materially improve determinism, responsiveness, post-sync usefulness, and safety without forcing a big architecture reset.
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
plan: true
|
plan: true
|
||||||
title: "Gitlore TUI PRD v2 - FrankenTUI"
|
title: "Gitlore TUI PRD v2 - FrankenTUI"
|
||||||
status: iterating
|
status: iterating
|
||||||
iteration: 9
|
iteration: 11
|
||||||
target_iterations: 10
|
target_iterations: 10
|
||||||
beads_revision: 0
|
beads_revision: 0
|
||||||
related_plans: []
|
related_plans: []
|
||||||
created: 2026-02-11
|
created: 2026-02-11
|
||||||
updated: 2026-02-11
|
updated: 2026-02-12
|
||||||
---
|
---
|
||||||
|
|
||||||
# Gitlore TUI — Product Requirements Document
|
# Gitlore TUI — Product Requirements Document
|
||||||
@@ -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
|
||||||
@@ -288,7 +288,9 @@ crates/lore-tui/src/
|
|||||||
safety.rs # sanitize_for_terminal(), safe_url_policy()
|
safety.rs # sanitize_for_terminal(), safe_url_policy()
|
||||||
redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting to disk
|
redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting to disk
|
||||||
session.rs # Versioned session state persistence + corruption quarantine
|
session.rs # Versioned session state persistence + corruption quarantine
|
||||||
entity_cache.rs # Bounded LRU cache for detail payloads (IssueDetail, MrDetail). Keyed by EntityKey. Invalidated on sync completion. Enables near-instant reopen during Enter/Esc drill-in/out workflows without re-querying.
|
scope.rs # Global project scope context: all-projects or pinned project set. Applied to dashboard/list/search/timeline/who queries. Persisted in session state.
|
||||||
|
entity_cache.rs # Bounded LRU cache for detail payloads (IssueDetail, MrDetail). Keyed by EntityKey. Selective invalidation by changed EntityKey set on sync completion (not blanket invalidate_all). Optional post-sync prewarm of top changed entities for immediate triage. Enables near-instant reopen during Enter/Esc drill-in/out workflows without re-querying.
|
||||||
|
render_cache.rs # Width/theme/content-hash keyed cache for expensive render artifacts (markdown → styled text, discussion tree shaping). Invalidation triggers: content hash change, terminal width change, theme change. Prevents per-frame recomputation of markdown parsing and tree layout.
|
||||||
crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task snapshot for crash diagnostics. Captured by panic hook for post-mortem debugging.
|
crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task snapshot for crash diagnostics. Captured by panic hook for post-mortem debugging.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -359,20 +361,24 @@ pub enum Msg {
|
|||||||
CommandPaletteSelect(usize),
|
CommandPaletteSelect(usize),
|
||||||
|
|
||||||
// Issue list
|
// Issue list
|
||||||
IssueListLoaded(Vec<IssueRow>),
|
/// Generation-guarded: stale results from superseded filter/nav are dropped.
|
||||||
|
IssueListLoaded { generation: u64, rows: Vec<IssueRow> },
|
||||||
IssueListFilterChanged(IssueFilter),
|
IssueListFilterChanged(IssueFilter),
|
||||||
IssueListSortChanged(SortField, SortOrder),
|
IssueListSortChanged(SortField, SortOrder),
|
||||||
IssueSelected(EntityKey),
|
IssueSelected(EntityKey),
|
||||||
|
|
||||||
// MR list
|
// MR list
|
||||||
MrListLoaded(Vec<MrRow>),
|
/// Generation-guarded: stale results from superseded filter/nav are dropped.
|
||||||
|
MrListLoaded { generation: u64, rows: Vec<MrRow> },
|
||||||
MrListFilterChanged(MrFilter),
|
MrListFilterChanged(MrFilter),
|
||||||
MrSelected(EntityKey),
|
MrSelected(EntityKey),
|
||||||
|
|
||||||
// Detail views
|
// Detail views
|
||||||
IssueDetailLoaded { key: EntityKey, detail: IssueDetail },
|
/// Generation-guarded: prevents stale detail overwrites after rapid navigation.
|
||||||
MrDetailLoaded { key: EntityKey, detail: MrDetail },
|
IssueDetailLoaded { generation: u64, key: EntityKey, detail: IssueDetail },
|
||||||
DiscussionsLoaded(Vec<Discussion>),
|
/// Generation-guarded: prevents stale detail overwrites after rapid navigation.
|
||||||
|
MrDetailLoaded { generation: u64, key: EntityKey, detail: MrDetail },
|
||||||
|
DiscussionsLoaded { generation: u64, discussions: Vec<Discussion> },
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
SearchQueryChanged(String),
|
SearchQueryChanged(String),
|
||||||
@@ -395,6 +401,9 @@ pub enum Msg {
|
|||||||
// Sync
|
// Sync
|
||||||
SyncStarted,
|
SyncStarted,
|
||||||
SyncProgress(ProgressEvent),
|
SyncProgress(ProgressEvent),
|
||||||
|
/// Coalesced batch of progress events (one per lane key).
|
||||||
|
/// Reduces render pressure by batching at <=30Hz per lane.
|
||||||
|
SyncProgressBatch(Vec<ProgressEvent>),
|
||||||
SyncLogLine(String),
|
SyncLogLine(String),
|
||||||
SyncBackpressureDrop,
|
SyncBackpressureDrop,
|
||||||
SyncCompleted(SyncResult),
|
SyncCompleted(SyncResult),
|
||||||
@@ -454,6 +463,7 @@ pub enum Screen {
|
|||||||
Sync,
|
Sync,
|
||||||
Stats,
|
Stats,
|
||||||
Doctor,
|
Doctor,
|
||||||
|
Bootstrap,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composite key for entity identity across multi-project datasets.
|
/// Composite key for entity identity across multi-project datasets.
|
||||||
@@ -553,7 +563,7 @@ impl Default for InputMode {
|
|||||||
// crates/lore-tui/src/app.rs
|
// crates/lore-tui/src/app.rs
|
||||||
|
|
||||||
use ftui_runtime::program::{Model, Cmd, TaskSpec};
|
use ftui_runtime::program::{Model, Cmd, TaskSpec};
|
||||||
use ftui_runtime::subscription::{Subscription, Every};
|
use ftui_runtime::subscription::{Subscription, Every, After};
|
||||||
use ftui_core::event::{Event, KeyEvent, KeyCode, KeyEventKind, Modifiers};
|
use ftui_core::event::{Event, KeyEvent, KeyCode, KeyEventKind, Modifiers};
|
||||||
use ftui_render::frame::Frame;
|
use ftui_render::frame::Frame;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
@@ -626,6 +636,20 @@ pub struct DbManager {
|
|||||||
next_reader: AtomicUsize,
|
next_reader: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A task-scoped reader lease that owns an interrupt handle for safe cancellation.
|
||||||
|
/// Unlike interrupting a shared pooled connection (which can cancel unrelated work),
|
||||||
|
/// each dispatched query receives its own ReaderLease. The InterruptHandle stored in
|
||||||
|
/// TaskHandle targets only this lease's connection, preventing cross-task cancellation bleed.
|
||||||
|
pub struct ReaderLease<'a> {
|
||||||
|
conn: std::sync::MutexGuard<'a, Connection>,
|
||||||
|
/// Owned interrupt handle — safe to fire without affecting other tasks.
|
||||||
|
pub interrupt: rusqlite::InterruptHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ReaderLease<'a> {
|
||||||
|
pub fn conn(&self) -> &Connection { &self.conn }
|
||||||
|
}
|
||||||
|
|
||||||
impl DbManager {
|
impl DbManager {
|
||||||
pub fn new(db_path: &Path, reader_count: usize) -> Result<Self, LoreError> {
|
pub fn new(db_path: &Path, reader_count: usize) -> Result<Self, LoreError> {
|
||||||
let mut readers = Vec::with_capacity(reader_count);
|
let mut readers = Vec::with_capacity(reader_count);
|
||||||
@@ -663,6 +687,19 @@ impl DbManager {
|
|||||||
.map_err(|e| LoreError::Internal(format!("writer lock poisoned: {e}")))?;
|
.map_err(|e| LoreError::Internal(format!("writer lock poisoned: {e}")))?;
|
||||||
f(&conn)
|
f(&conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lease a reader connection with a task-owned interrupt handle.
|
||||||
|
/// The returned `ReaderLease` holds the mutex guard and provides
|
||||||
|
/// an `InterruptHandle` that can be stored in `TaskHandle` for
|
||||||
|
/// safe per-task cancellation. This prevents cross-task interrupt bleed
|
||||||
|
/// that would occur with shared-connection `sqlite3_interrupt()`.
|
||||||
|
pub fn lease_reader(&self) -> Result<ReaderLease<'_>, LoreError> {
|
||||||
|
let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % self.readers.len();
|
||||||
|
let conn = self.readers[idx].lock()
|
||||||
|
.map_err(|e| LoreError::Internal(format!("reader lock poisoned: {e}")))?;
|
||||||
|
let interrupt = conn.get_interrupt_handle();
|
||||||
|
Ok(ReaderLease { conn, interrupt })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoreApp {
|
impl LoreApp {
|
||||||
@@ -786,9 +823,11 @@ impl LoreApp {
|
|||||||
}),
|
}),
|
||||||
Screen::IssueList => {
|
Screen::IssueList => {
|
||||||
let filter = self.state.issue_list.current_filter();
|
let filter = self.state.issue_list.current_filter();
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueList));
|
||||||
|
let generation = handle.generation;
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
|
||||||
Ok(result) => Msg::IssueListLoaded(result),
|
Ok(rows) => Msg::IssueListLoaded { generation, rows },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -797,21 +836,26 @@ impl LoreApp {
|
|||||||
// Check entity cache first — enables near-instant reopen
|
// Check entity cache first — enables near-instant reopen
|
||||||
// during Enter/Esc drill-in/out workflows.
|
// during Enter/Esc drill-in/out workflows.
|
||||||
if let Some(cached) = self.entity_cache.get_issue(key) {
|
if let Some(cached) = self.entity_cache.get_issue(key) {
|
||||||
return Cmd::msg(Msg::IssueDetailLoaded { key: key.clone(), detail: cached.clone() });
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueDetail(key.clone())));
|
||||||
|
return Cmd::msg(Msg::IssueDetailLoaded { generation: handle.generation, key: key.clone(), detail: cached.clone() });
|
||||||
}
|
}
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueDetail(key.clone())));
|
||||||
|
let generation = handle.generation;
|
||||||
let key = key.clone();
|
let key = key.clone();
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_issue_detail(conn, &key)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_issue_detail(conn, &key)) {
|
||||||
Ok(detail) => Msg::IssueDetailLoaded { key, detail },
|
Ok(detail) => Msg::IssueDetailLoaded { generation, key, detail },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Screen::MrList => {
|
Screen::MrList => {
|
||||||
let filter = self.state.mr_list.current_filter();
|
let filter = self.state.mr_list.current_filter();
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrList));
|
||||||
|
let generation = handle.generation;
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
|
||||||
Ok(result) => Msg::MrListLoaded(result),
|
Ok(rows) => Msg::MrListLoaded { generation, rows },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -819,12 +863,15 @@ impl LoreApp {
|
|||||||
Screen::MrDetail(key) => {
|
Screen::MrDetail(key) => {
|
||||||
// Check entity cache first
|
// Check entity cache first
|
||||||
if let Some(cached) = self.entity_cache.get_mr(key) {
|
if let Some(cached) = self.entity_cache.get_mr(key) {
|
||||||
return Cmd::msg(Msg::MrDetailLoaded { key: key.clone(), detail: cached.clone() });
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrDetail(key.clone())));
|
||||||
|
return Cmd::msg(Msg::MrDetailLoaded { generation: handle.generation, key: key.clone(), detail: cached.clone() });
|
||||||
}
|
}
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrDetail(key.clone())));
|
||||||
|
let generation = handle.generation;
|
||||||
let key = key.clone();
|
let key = key.clone();
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_mr_detail(conn, &key)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_mr_detail(conn, &key)) {
|
||||||
Ok(detail) => Msg::MrDetailLoaded { key, detail },
|
Ok(detail) => Msg::MrDetailLoaded { generation, key, detail },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -895,9 +942,11 @@ impl LoreApp {
|
|||||||
Screen::IssueList => {
|
Screen::IssueList => {
|
||||||
let filter = self.state.issue_list.current_filter();
|
let filter = self.state.issue_list.current_filter();
|
||||||
let db = Arc::clone(&self.db);
|
let db = Arc::clone(&self.db);
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::FilterRequery(Screen::IssueList));
|
||||||
|
let generation = handle.generation;
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
|
||||||
Ok(result) => Msg::IssueListLoaded(result),
|
Ok(rows) => Msg::IssueListLoaded { generation, rows },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -905,9 +954,11 @@ impl LoreApp {
|
|||||||
Screen::MrList => {
|
Screen::MrList => {
|
||||||
let filter = self.state.mr_list.current_filter();
|
let filter = self.state.mr_list.current_filter();
|
||||||
let db = Arc::clone(&self.db);
|
let db = Arc::clone(&self.db);
|
||||||
|
let handle = self.task_supervisor.submit(TaskKey::FilterRequery(Screen::MrList));
|
||||||
|
let generation = handle.generation;
|
||||||
Cmd::task(move || {
|
Cmd::task(move || {
|
||||||
match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
|
match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
|
||||||
Ok(result) => Msg::MrListLoaded(result),
|
Ok(rows) => Msg::MrListLoaded { generation, rows },
|
||||||
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
Err(e) => Msg::Error(AppError::Internal(e.to_string())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -961,16 +1012,19 @@ impl LoreApp {
|
|||||||
if cancel_token.load(std::sync::atomic::Ordering::Relaxed) {
|
if cancel_token.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
return; // Early exit — orchestrator handles partial state
|
return; // Early exit — orchestrator handles partial state
|
||||||
}
|
}
|
||||||
// Track queue depth for stream stats
|
// Coalesce progress events by lane key at <=30Hz to reduce
|
||||||
let current_depth = 2048 - tx.try_send(Msg::SyncProgress(event.clone()))
|
// render pressure. Each lane (project x resource_type) keeps
|
||||||
.err().map_or(0, |_| 1);
|
// only its latest progress snapshot. The coalescer flushes
|
||||||
max_queue_depth = max_queue_depth.max(current_depth);
|
// a batch when 33ms have elapsed since last flush.
|
||||||
if tx.try_send(Msg::SyncProgress(event.clone())).is_err() {
|
coalescer.update(event.clone());
|
||||||
// Channel full — drop this progress update rather than
|
if let Some(batch) = coalescer.flush_ready() {
|
||||||
|
if tx.try_send(Msg::SyncProgressBatch(batch)).is_err() {
|
||||||
|
// Channel full — drop this batch rather than
|
||||||
// blocking the sync thread. Track for stats.
|
// blocking the sync thread. Track for stats.
|
||||||
dropped_count += 1;
|
dropped_count += 1;
|
||||||
let _ = tx.try_send(Msg::SyncBackpressureDrop);
|
let _ = tx.try_send(Msg::SyncBackpressureDrop);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let _ = tx.try_send(Msg::SyncLogLine(format!("{event:?}")));
|
let _ = tx.try_send(Msg::SyncLogLine(format!("{event:?}")));
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1143,23 +1197,35 @@ impl Model for LoreApp {
|
|||||||
self.state.dashboard.update(data);
|
self.state.dashboard.update(data);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::IssueListLoaded(result) => {
|
Msg::IssueListLoaded { generation, rows } => {
|
||||||
|
if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation) {
|
||||||
|
return Cmd::none(); // Stale — superseded by newer nav/filter
|
||||||
|
}
|
||||||
self.state.set_loading(false);
|
self.state.set_loading(false);
|
||||||
self.state.issue_list.set_result(result);
|
self.state.issue_list.set_result(rows);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::IssueDetailLoaded { key, detail } => {
|
Msg::IssueDetailLoaded { generation, key, detail } => {
|
||||||
|
if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::IssueDetail(key.clone())), generation) {
|
||||||
|
return Cmd::none(); // Stale — user navigated away
|
||||||
|
}
|
||||||
self.state.set_loading(false);
|
self.state.set_loading(false);
|
||||||
self.entity_cache.put_issue(key, detail.clone());
|
self.entity_cache.put_issue(key, detail.clone());
|
||||||
self.state.issue_detail.set(detail);
|
self.state.issue_detail.set(detail);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::MrListLoaded(result) => {
|
Msg::MrListLoaded { generation, rows } => {
|
||||||
|
if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::MrList), generation) {
|
||||||
|
return Cmd::none(); // Stale — superseded by newer nav/filter
|
||||||
|
}
|
||||||
self.state.set_loading(false);
|
self.state.set_loading(false);
|
||||||
self.state.mr_list.set_result(result);
|
self.state.mr_list.set_result(rows);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::MrDetailLoaded { key, detail } => {
|
Msg::MrDetailLoaded { generation, key, detail } => {
|
||||||
|
if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::MrDetail(key.clone())), generation) {
|
||||||
|
return Cmd::none(); // Stale — user navigated away
|
||||||
|
}
|
||||||
self.state.set_loading(false);
|
self.state.set_loading(false);
|
||||||
self.entity_cache.put_mr(key, detail.clone());
|
self.entity_cache.put_mr(key, detail.clone());
|
||||||
self.state.mr_detail.set(detail);
|
self.state.mr_detail.set(detail);
|
||||||
@@ -1219,6 +1285,12 @@ impl Model for LoreApp {
|
|||||||
self.state.sync.update_progress(event);
|
self.state.sync.update_progress(event);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
|
Msg::SyncProgressBatch(events) => {
|
||||||
|
for event in events {
|
||||||
|
self.state.sync.update_progress(event);
|
||||||
|
}
|
||||||
|
Cmd::none()
|
||||||
|
}
|
||||||
Msg::SyncLogLine(line) => {
|
Msg::SyncLogLine(line) => {
|
||||||
self.state.sync.push_log(line);
|
self.state.sync.push_log(line);
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
@@ -1234,10 +1306,15 @@ impl Model for LoreApp {
|
|||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::SyncCompleted(result) => {
|
Msg::SyncCompleted(result) => {
|
||||||
self.state.sync.complete(result);
|
self.state.sync.complete(&result);
|
||||||
// Invalidate entity cache — synced data may have changed.
|
// Selective invalidation: evict only changed entities from sync delta.
|
||||||
self.entity_cache.invalidate_all();
|
self.entity_cache.invalidate_keys(&result.changed_entity_keys);
|
||||||
Cmd::none()
|
// Prewarm top N changed/new entities for immediate post-sync triage.
|
||||||
|
// This is lazy — enqueues Cmd::task fetches, doesn't block the event loop.
|
||||||
|
let prewarm_cmds = self.enqueue_cache_prewarm(&result.changed_entity_keys);
|
||||||
|
// Notify list screens that new data is available (snapshot fence refresh badge).
|
||||||
|
self.state.notify_data_changed();
|
||||||
|
prewarm_cmds
|
||||||
}
|
}
|
||||||
Msg::SyncFailed(err) => {
|
Msg::SyncFailed(err) => {
|
||||||
self.state.sync.fail(err);
|
self.state.sync.fail(err);
|
||||||
@@ -1416,21 +1493,23 @@ impl Model for LoreApp {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go-prefix timeout enforcement: tick even when nothing is loading.
|
// Go-prefix timeout: one-shot After(500ms) tied to the prefix start.
|
||||||
// Without this, GoPrefix mode can get "stuck" when idle (no other
|
// Uses After (one-shot) instead of Every (periodic) — the prefix
|
||||||
// events to drive the Tick that checks the 500ms timeout).
|
// either completes with a valid key or times out exactly once.
|
||||||
if matches!(self.input_mode, InputMode::GoPrefix { .. }) {
|
if matches!(self.input_mode, InputMode::GoPrefix { .. }) {
|
||||||
subs.push(Box::new(
|
subs.push(Box::new(
|
||||||
Every::with_id(2, Duration::from_millis(50), || Msg::Tick)
|
After::with_id(2, Duration::from_millis(500), || Msg::Tick)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search debounce timer: fires SearchDebounceFired after 200ms.
|
// Search debounce timer: one-shot fires SearchDebounceFired after 200ms.
|
||||||
// Only active when a debounce is pending (armed by keystroke).
|
// Only active when a debounce is pending (armed by keystroke).
|
||||||
|
// Uses After (one-shot) instead of Every (periodic) to avoid repeated
|
||||||
|
// firings from a periodic timer — one debounce = one fire.
|
||||||
if self.state.search.debounce_pending() {
|
if self.state.search.debounce_pending() {
|
||||||
let generation = self.state.search.debounce_generation();
|
let generation = self.state.search.debounce_generation();
|
||||||
subs.push(Box::new(
|
subs.push(Box::new(
|
||||||
Every::with_id(3, Duration::from_millis(200), move || {
|
After::with_id(3, Duration::from_millis(200), move || {
|
||||||
Msg::SearchDebounceFired { generation }
|
Msg::SearchDebounceFired { generation }
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
@@ -1485,7 +1564,7 @@ pub fn with_read_snapshot<T>(
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Query interruption:** Long-running queries register interrupt checks tied to `CancelToken` to avoid >1s uninterruptible stalls during rapid navigation/filtering. When the user navigates away from a detail screen before queries complete, the cancel token fires `sqlite3_interrupt()` on the connection.
|
**Query interruption:** Long-running queries use task-owned `ReaderLease` interrupt handles (from `DbManager::lease_reader()`) to avoid >1s uninterruptible stalls during rapid navigation/filtering. When the user navigates away from a detail screen before queries complete, the `TaskHandle`'s owned `InterruptHandle` fires `sqlite3_interrupt()` on that specific leased connection — never on a shared pool connection. This prevents cross-task cancellation bleed where interrupting one query accidentally cancels an unrelated query on the same pooled connection.
|
||||||
|
|
||||||
#### 4.5.1 Task Supervisor (Dedup + Cancellation + Priority)
|
#### 4.5.1 Task Supervisor (Dedup + Cancellation + Priority)
|
||||||
|
|
||||||
@@ -1549,6 +1628,10 @@ pub struct TaskHandle {
|
|||||||
pub key: TaskKey,
|
pub key: TaskKey,
|
||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
pub cancel: Arc<CancelToken>,
|
pub cancel: Arc<CancelToken>,
|
||||||
|
/// Per-task SQLite interrupt handle. When set, cancellation fires
|
||||||
|
/// this handle instead of interrupting shared pool connections.
|
||||||
|
/// Prevents cross-task cancellation bleed.
|
||||||
|
pub interrupt: Option<rusqlite::InterruptHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The TaskSupervisor manages active tasks, deduplicates by key, and tracks
|
/// The TaskSupervisor manages active tasks, deduplicates by key, and tracks
|
||||||
@@ -1756,6 +1839,11 @@ pub struct NavigationStack {
|
|||||||
/// This mirrors vim's jump list behavior.
|
/// This mirrors vim's jump list behavior.
|
||||||
jump_list: Vec<Screen>,
|
jump_list: Vec<Screen>,
|
||||||
jump_index: usize,
|
jump_index: usize,
|
||||||
|
/// Browse snapshot token: each list/search screen carries a per-screen
|
||||||
|
/// `BrowseSnapshot` that preserves stable ordering until explicit refresh
|
||||||
|
/// or screen re-entry. This works with the snapshot fence to ensure
|
||||||
|
/// deterministic pagination during concurrent sync writes.
|
||||||
|
browse_snapshots: HashMap<ScreenKind, BrowseSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NavigationStack {
|
impl NavigationStack {
|
||||||
@@ -1979,9 +2067,21 @@ Insights are computed from local data during dashboard load. Each insight row is
|
|||||||
**Data source:** `lore issues` query against SQLite
|
**Data source:** `lore issues` query against SQLite
|
||||||
**Columns:** Configurable — iid, title, state, author, labels, milestone, updated_at
|
**Columns:** Configurable — iid, title, state, author, labels, milestone, updated_at
|
||||||
**Sorting:** Click column header or Tab to cycle (iid, updated, created)
|
**Sorting:** Click column header or Tab to cycle (iid, updated, created)
|
||||||
**Filtering:** Interactive filter bar with field:value syntax
|
**Filtering:** Interactive filter bar with typed DSL parser. Grammar (v1):
|
||||||
|
- `term := [ "-" ] (field ":" value | quoted_text | bare_text)`
|
||||||
|
- `value := quoted | unquoted`
|
||||||
|
- Examples: `state:opened label:"P1 blocker" -author:bot since:14d`
|
||||||
|
- Negation prefix (`-`) excludes matches for that term
|
||||||
|
- Quoted values allow spaces in filter values
|
||||||
|
- Parser surfaces inline diagnostics with cursor position for parse errors — never silently drops unknown fields
|
||||||
**Pagination:** Windowed keyset pagination with explicit cursor state. The list state maintains `window` (current visible rows), `next_cursor` / `prev_cursor` (keyset boundary values for forward/back navigation), `prefetching` flag (background fetch of next window in progress), and a fixed `window_size` (default 200 rows). First paint uses current window only; no full-result materialization. Virtual scrolling within the window for smooth UX. When the user scrolls past ~80% of the window, the next window is prefetched in the background.
|
**Pagination:** Windowed keyset pagination with explicit cursor state. The list state maintains `window` (current visible rows), `next_cursor` / `prev_cursor` (keyset boundary values for forward/back navigation), `prefetching` flag (background fetch of next window in progress), and a fixed `window_size` (default 200 rows). First paint uses current window only; no full-result materialization. Virtual scrolling within the window for smooth UX. When the user scrolls past ~80% of the window, the next window is prefetched in the background.
|
||||||
|
|
||||||
|
**Snapshot fence:** On list entry, capture `snapshot_upper_updated_at` (current max `updated_at` in the result set) and pin all list-page queries to `updated_at <= snapshot_upper_updated_at`. This guarantees no duplicate or skipped rows during scrolling even if sync writes occur concurrently. A "new data available" badge appears when a newer sync completes; `r` refreshes the fence and re-queries from the top.
|
||||||
|
|
||||||
|
**Quick Peek (`Space`):** Toggle a right-side preview pane showing the selected item's metadata, first discussion snippet, and cross-references without entering the full detail view. This enables rapid triage scanning — the user can evaluate issues at a glance without the Enter/Esc cycle. The peek pane uses the same progressive hydration as detail views (metadata first, discussions lazy). The pane width adapts to terminal breakpoints (hidden at Xs/Sm, 40% width at Md+).
|
||||||
|
|
||||||
|
**Cursor determinism:** Keyset pagination uses deterministic tuple ordering: `ORDER BY <primary_sort>, project_id, iid`. The cursor struct includes the current `sort_field`, `sort_order`, `project_id` (tie-breaker for multi-project datasets where rows share timestamps), and a `filter_hash: u64` (hash of the active filter state). On cursor resume, the cursor is rejected if `filter_hash` or sort tuple mismatches the current query — this prevents stale cursors from producing duplicate/skipped rows after the user changes sort mode or filters mid-browse.
|
||||||
|
|
||||||
### 5.3 Issue Detail
|
### 5.3 Issue Detail
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -2052,7 +2152,9 @@ Identical structure to Issue List with MR-specific columns:
|
|||||||
| Author | MR author |
|
| Author | MR author |
|
||||||
| Updated | Relative time |
|
| Updated | Relative time |
|
||||||
|
|
||||||
**Pagination:** Same windowed keyset pagination strategy as Issue List (window=200, background prefetch).
|
**Pagination:** Same windowed keyset pagination strategy as Issue List (window=200, background prefetch, deterministic cursor with `project_id` tie-breaker and `filter_hash` invalidation). Same snapshot fence (`updated_at <= snapshot_upper_updated_at`) for deterministic cross-page traversal under concurrent sync writes.
|
||||||
|
|
||||||
|
**Quick Peek (`Space`):** Same as Issue List — toggle right preview pane showing MR metadata, first discussion snippet, and cross-references for rapid triage without entering detail view.
|
||||||
|
|
||||||
**Additional filters:** `--draft`, `--no-draft`, `--target-branch`, `--source-branch`, `--reviewer`
|
**Additional filters:** `--draft`, `--no-draft`, `--target-branch`, `--source-branch`, `--reviewer`
|
||||||
|
|
||||||
@@ -2294,8 +2396,8 @@ The Sync screen has two modes: **running** (progress + log) and **summary** (pos
|
|||||||
|
|
||||||
**Summary mode:**
|
**Summary mode:**
|
||||||
- Shows delta counts (new, updated) for each entity type
|
- Shows delta counts (new, updated) for each entity type
|
||||||
- `i` navigates to Issue List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison)
|
- `i` navigates to Issue List filtered by exact issue IDs changed in this sync run (from in-memory `SyncDeltaLedger`). Falls back to timestamp filter via `sync_status.last_completed_at` only if run delta is not available (e.g., after app restart).
|
||||||
- `m` navigates to MR List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison)
|
- `m` navigates to MR List filtered by exact MR IDs changed in this sync run (from in-memory `SyncDeltaLedger`). Falls back to timestamp filter only if run delta is not available.
|
||||||
- `r` restarts sync
|
- `r` restarts sync
|
||||||
|
|
||||||
### 5.10 Command Palette (Overlay)
|
### 5.10 Command Palette (Overlay)
|
||||||
@@ -2349,6 +2451,21 @@ The Sync screen has two modes: **running** (progress + log) and **summary** (pos
|
|||||||
- Does NOT auto-execute commands — the user always runs them manually for safety
|
- Does NOT auto-execute commands — the user always runs them manually for safety
|
||||||
- Scrollable with j/k, Esc to go back
|
- Scrollable with j/k, Esc to go back
|
||||||
|
|
||||||
|
### 5.12 Bootstrap (Data Readiness)
|
||||||
|
|
||||||
|
Shown automatically when the TUI detects no synced projects/documents or required indexes are missing. This is a read-only screen — it never auto-executes commands.
|
||||||
|
|
||||||
|
Displays concise readiness checks with pass/fail indicators:
|
||||||
|
- Synced projects present?
|
||||||
|
- Issues/MRs populated?
|
||||||
|
- FTS index built?
|
||||||
|
- Embedding index built? (optional — warns but doesn't block)
|
||||||
|
- Required migration version met?
|
||||||
|
|
||||||
|
For each failing check, shows the exact CLI command to recover (e.g., `lore sync`, `lore migrate`, `lore --robot doctor`). The user exits the TUI and runs the commands manually.
|
||||||
|
|
||||||
|
This prevents the "blank screen" first-run experience where a user launches `lore tui` before syncing data and sees an empty dashboard with no indication of what to do next.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. User Flows
|
## 6. User Flows
|
||||||
@@ -2483,8 +2600,8 @@ graph TD
|
|||||||
style F fill:#51cf66,stroke:#333,color:#fff
|
style F fill:#51cf66,stroke:#333,color:#fff
|
||||||
```
|
```
|
||||||
|
|
||||||
**Keystrokes:** `i` → `j/k` to scan → `Enter` to peek → `Esc` to return → continue scanning
|
**Keystrokes:** `i` → `j/k` to scan → `Space` to Quick Peek (or `Enter` for full detail) → `Esc` to return → continue scanning
|
||||||
**State preservation:** After pressing Esc from Issue Detail, the cursor returns to exactly the same row in the list. Filter state and scroll offset are preserved. This tight Enter/Esc loop is the most common daily workflow.
|
**State preservation:** After pressing Esc from Issue Detail, the cursor returns to exactly the same row in the list. Filter state and scroll offset are preserved. This tight Enter/Esc loop is the most common daily workflow. Quick Peek (`Space`) makes triage even faster — preview metadata and first discussion snippet without leaving the list.
|
||||||
|
|
||||||
### 6.8 Flow: "Jump between screens without returning to Dashboard"
|
### 6.8 Flow: "Jump between screens without returning to Dashboard"
|
||||||
|
|
||||||
@@ -2591,6 +2708,7 @@ graph TD
|
|||||||
| `Ctrl+O` | Jump backward in jump list (entity hops) |
|
| `Ctrl+O` | Jump backward in jump list (entity hops) |
|
||||||
| `Alt+o` | Jump forward in jump list (entity hops) |
|
| `Alt+o` | Jump forward in jump list (entity hops) |
|
||||||
| `Ctrl+R` | Reset session state for current screen (clear filters, scroll to top) |
|
| `Ctrl+R` | Reset session state for current screen (clear filters, scroll to top) |
|
||||||
|
| `P` | Open project scope picker / toggle global scope pin. When a scope is pinned, all list/search/timeline/who queries are filtered to that project set. A visible `[scope: project/path]` indicator appears in the status bar. |
|
||||||
| `Ctrl+C` | Quit (force) |
|
| `Ctrl+C` | Quit (force) |
|
||||||
|
|
||||||
### 8.2 List Screens (Issues, MRs, Search Results)
|
### 8.2 List Screens (Issues, MRs, Search Results)
|
||||||
@@ -2600,6 +2718,7 @@ graph TD
|
|||||||
| `j` / `↓` | Move selection down |
|
| `j` / `↓` | Move selection down |
|
||||||
| `k` / `↑` | Move selection up |
|
| `k` / `↑` | Move selection up |
|
||||||
| `Enter` | Open selected item |
|
| `Enter` | Open selected item |
|
||||||
|
| `Space` | Toggle Quick Peek panel for selected row |
|
||||||
| `G` | Jump to bottom |
|
| `G` | Jump to bottom |
|
||||||
| `g` `g` | Jump to top |
|
| `g` `g` | Jump to top |
|
||||||
| `Tab` / `f` | Focus filter bar |
|
| `Tab` / `f` | Focus filter bar |
|
||||||
@@ -2614,7 +2733,7 @@ graph TD
|
|||||||
3. Global shortcuts — `q`, `H`, `?`, `o`, `Ctrl+C`, `Ctrl+P`, `Esc`, `g` prefix
|
3. Global shortcuts — `q`, `H`, `?`, `o`, `Ctrl+C`, `Ctrl+P`, `Esc`, `g` prefix
|
||||||
4. Screen-local shortcuts — per-screen key handlers (the table above)
|
4. Screen-local shortcuts — per-screen key handlers (the table above)
|
||||||
|
|
||||||
**Go-prefix timeout:** 500ms from first `g` press, enforced by `InputMode::GoPrefix { started_at }` state checked on each tick via `clock.now_instant()`. If no valid continuation key arrives within 500ms, the prefix cancels and a brief "g--" flash clears from the status bar. The tick subscription compares the injected Clock's current instant against `started_at` — no separate timer task needed. Using `InputMode` instead of ad-hoc boolean flags makes the state machine explicit and deterministic. Feedback is immediate — the status bar shows "g--" within the same frame as the keypress.
|
**Go-prefix timeout:** 500ms from first `g` press, enforced by a one-shot `After(500ms)` subscription tied to the prefix generation. If no valid continuation key arrives within 500ms, the timer fires a single `Msg::Tick` which checks `InputMode::GoPrefix { started_at }` via `clock.now_instant()` and cancels the prefix. A brief "g--" flash clears from the status bar. Using `After` (one-shot) instead of `Every` (periodic) avoids unnecessary repeated ticks. Using `InputMode` instead of ad-hoc boolean flags makes the state machine explicit and deterministic. Feedback is immediate — the status bar shows "g--" within the same frame as the keypress.
|
||||||
|
|
||||||
**Terminal keybinding safety notes:**
|
**Terminal keybinding safety notes:**
|
||||||
- `Ctrl+I` is NOT used — it is indistinguishable from `Tab` in most terminals (both send `\x09`). Jump-forward uses `Alt+o` instead.
|
- `Ctrl+I` is NOT used — it is indistinguishable from `Tab` in most terminals (both send `\x09`). Jump-forward uses `Alt+o` instead.
|
||||||
@@ -2783,6 +2902,8 @@ gantt
|
|||||||
Event fuzz tests (key/resize/paste, deterministic seed replay):p55g, after p55e, 1d
|
Event fuzz tests (key/resize/paste, deterministic seed replay):p55g, after p55e, 1d
|
||||||
Deterministic clock/render tests:p55i, after p55g, 0.5d
|
Deterministic clock/render tests:p55i, after p55g, 0.5d
|
||||||
30-minute soak test (no panic/leak):p55h, after p55i, 1d
|
30-minute soak test (no panic/leak):p55h, after p55i, 1d
|
||||||
|
Concurrent pagination/write race tests :p55j, after p55h, 1d
|
||||||
|
Query cancellation race tests :p55k, after p55j, 0.5d
|
||||||
|
|
||||||
section Phase 5.6 — CLI/TUI Parity Pack
|
section Phase 5.6 — CLI/TUI Parity Pack
|
||||||
Dashboard count parity tests :p56a, after p55h, 0.5d
|
Dashboard count parity tests :p56a, after p55h, 0.5d
|
||||||
@@ -2802,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:** ~47 implementation days across 9 phases (increased from ~43 to account for Phase 2.5 vertical slice gate, entity cache, crash context ring buffer, timer-based debounce, and expanded success criteria 24-25).
|
**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
|
||||||
|
|
||||||
@@ -2848,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.
|
||||||
|
|
||||||
@@ -2912,7 +3035,12 @@ crates/lore-tui/src/theme.rs # ftui Theme config
|
|||||||
crates/lore-tui/src/action.rs # Query bridge functions (uses lore core)
|
crates/lore-tui/src/action.rs # Query bridge functions (uses lore core)
|
||||||
crates/lore-tui/src/db_manager.rs # DbManager: closure-based read pool (with_reader) + dedicated writer (with_writer). Prevents lock-poison panics and accidental long-held guards.
|
crates/lore-tui/src/db_manager.rs # DbManager: closure-based read pool (with_reader) + dedicated writer (with_writer). Prevents lock-poison panics and accidental long-held guards.
|
||||||
crates/lore-tui/src/task_supervisor.rs # TaskSupervisor: unified submit() → TaskHandle API with dedup, cancellation, generation IDs, and priority lanes
|
crates/lore-tui/src/task_supervisor.rs # TaskSupervisor: unified submit() → TaskHandle API with dedup, cancellation, generation IDs, and priority lanes
|
||||||
crates/lore-tui/src/entity_cache.rs # Bounded LRU cache for IssueDetail/MrDetail payloads. Keyed by EntityKey. Invalidated on sync completion. Enables near-instant reopen during Enter/Esc drill-in/out workflows.
|
crates/lore-tui/src/entity_cache.rs # Bounded LRU cache for IssueDetail/MrDetail payloads. Keyed by EntityKey. Selective invalidation by changed EntityKey set (not blanket invalidate_all). Optional post-sync prewarm of top changed entities. Enables near-instant reopen during Enter/Esc drill-in/out workflows.
|
||||||
|
crates/lore-tui/src/render_cache.rs # Width/theme/content-hash keyed cache for expensive render artifacts (markdown → styled text, discussion tree shaping). Prevents per-frame recomputation.
|
||||||
|
crates/lore-tui/src/filter_dsl.rs # Typed filter bar DSL parser: quoted values, negation prefix, field:value syntax, inline diagnostics with cursor position. Replaces brittle split_whitespace() parsing.
|
||||||
|
crates/lore-tui/src/progress_coalescer.rs # Per-lane progress event coalescer. Batches progress updates at <=30Hz per lane key (project x resource_type) to reduce render pressure during sync.
|
||||||
|
crates/lore-tui/src/sync_delta_ledger.rs # In-memory per-run exact changed/new entity IDs (issues, MRs, discussions). Populated from SyncCompleted result. Used by Sync Summary mode for exact "what changed" navigation without new DB tables. Cleared on next sync run start.
|
||||||
|
crates/lore-tui/src/scope.rs # Global project scope context (AllProjects or pinned project set). Flows through all query bridge functions. Persisted in session state. `P` keybinding opens scope picker overlay.
|
||||||
crates/lore-tui/src/crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task/build snapshot. Captured by panic hook for post-mortem crash diagnostics with retention policy (latest 20 files).
|
crates/lore-tui/src/crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task/build snapshot. Captured by panic hook for post-mortem crash diagnostics with retention policy (latest 20 files).
|
||||||
crates/lore-tui/src/safety.rs # sanitize_for_terminal(), safe_url_policy()
|
crates/lore-tui/src/safety.rs # sanitize_for_terminal(), safe_url_policy()
|
||||||
crates/lore-tui/src/redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting
|
crates/lore-tui/src/redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting
|
||||||
@@ -3389,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).
|
||||||
@@ -4285,6 +4444,7 @@ pub struct AppState {
|
|||||||
pub command_palette: CommandPaletteState,
|
pub command_palette: CommandPaletteState,
|
||||||
|
|
||||||
// Cross-cutting state
|
// Cross-cutting state
|
||||||
|
pub global_scope: ScopeContext, // Applies to dashboard/list/search/timeline/who queries. Default: AllProjects.
|
||||||
pub load_state: ScreenLoadStateMap,
|
pub load_state: ScreenLoadStateMap,
|
||||||
pub error_toast: Option<String>,
|
pub error_toast: Option<String>,
|
||||||
pub show_help: bool,
|
pub show_help: bool,
|
||||||
@@ -5445,15 +5605,20 @@ pub fn fetch_dashboard(conn: &Connection) -> Result<DashboardData, LoreError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch issues, converting TUI IssueFilter → CLI ListFilters.
|
/// Fetch issues, converting TUI IssueFilter → CLI ListFilters.
|
||||||
|
/// The `scope` parameter applies global project pinning — when a scope is active,
|
||||||
|
/// it overrides any per-filter project selection, ensuring cross-screen consistency.
|
||||||
pub fn fetch_issues(
|
pub fn fetch_issues(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
scope: &ScopeContext,
|
||||||
filter: &IssueFilter,
|
filter: &IssueFilter,
|
||||||
) -> Result<Vec<IssueListRow>, LoreError> {
|
) -> Result<Vec<IssueListRow>, LoreError> {
|
||||||
// Convert TUI filter to CLI filter format.
|
// Convert TUI filter to CLI filter format.
|
||||||
// The CLI already has query_issues() — we just need to bridge the types.
|
// The CLI already has query_issues() — we just need to bridge the types.
|
||||||
|
// Global scope overrides per-filter project when active.
|
||||||
|
let effective_project = scope.effective_project(filter.project.as_deref());
|
||||||
let cli_filter = ListFilters {
|
let cli_filter = ListFilters {
|
||||||
limit: filter.limit,
|
limit: filter.limit,
|
||||||
project: filter.project.as_deref(),
|
project: effective_project.as_deref(),
|
||||||
state: filter.state.as_deref(),
|
state: filter.state.as_deref(),
|
||||||
author: filter.author.as_deref(),
|
author: filter.author.as_deref(),
|
||||||
assignee: filter.assignee.as_deref(),
|
assignee: filter.assignee.as_deref(),
|
||||||
@@ -7806,3 +7971,13 @@ Recommendations from external review (feedback-8, ChatGPT) that were evaluated a
|
|||||||
Recommendations from external review (feedback-9, ChatGPT) that were evaluated and declined:
|
Recommendations from external review (feedback-9, ChatGPT) that were evaluated and declined:
|
||||||
|
|
||||||
- **Search Facets panel (entity type counts, top labels/projects/authors with one-key apply)** — rejected as feature scope expansion for v1. The concept (three-pane layout with facet counts and quick-apply shortcuts like `1/2/3` for type facets, `l` for label cycling) is compelling and would make search more actionable for triage workflows. However, it requires: new aggregate queries for facet counting that must perform well across all three data tiers, a third layout pane that breaks the current two-pane split design, new keybinding slots (`1/2/3/l`) that could conflict with future list navigation, and per-query facet recalculation that adds latency. The existing search with explicit field-based filters is sufficient for v1. Facets are a strong v2 candidate — once search has production mileage and users report wanting faster triage filtering, the aggregate query patterns and UI layout can be designed with real usage data.
|
- **Search Facets panel (entity type counts, top labels/projects/authors with one-key apply)** — rejected as feature scope expansion for v1. The concept (three-pane layout with facet counts and quick-apply shortcuts like `1/2/3` for type facets, `l` for label cycling) is compelling and would make search more actionable for triage workflows. However, it requires: new aggregate queries for facet counting that must perform well across all three data tiers, a third layout pane that breaks the current two-pane split design, new keybinding slots (`1/2/3/l`) that could conflict with future list navigation, and per-query facet recalculation that adds latency. The existing search with explicit field-based filters is sufficient for v1. Facets are a strong v2 candidate — once search has production mileage and users report wanting faster triage filtering, the aggregate query patterns and UI layout can be designed with real usage data.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -79,33 +79,43 @@ pub fn run_stats(config: &Config, check: bool, repair: bool, dry_run: bool) -> R
|
|||||||
|
|
||||||
let mut result = StatsResult::default();
|
let mut result = StatsResult::default();
|
||||||
|
|
||||||
result.documents.total = count_query(&conn, "SELECT COUNT(*) FROM documents")?;
|
// Single-scan conditional aggregate: 5 sequential COUNT(*) → 1 table scan
|
||||||
result.documents.issues = count_query(
|
let (total, issues, mrs, discussions, truncated) = conn
|
||||||
&conn,
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'issue'",
|
"SELECT COUNT(*),
|
||||||
)?;
|
COALESCE(SUM(CASE WHEN source_type = 'issue' THEN 1 END), 0),
|
||||||
result.documents.merge_requests = count_query(
|
COALESCE(SUM(CASE WHEN source_type = 'merge_request' THEN 1 END), 0),
|
||||||
&conn,
|
COALESCE(SUM(CASE WHEN source_type = 'discussion' THEN 1 END), 0),
|
||||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'merge_request'",
|
COALESCE(SUM(CASE WHEN is_truncated = 1 THEN 1 END), 0)
|
||||||
)?;
|
FROM documents",
|
||||||
result.documents.discussions = count_query(
|
[],
|
||||||
&conn,
|
|row| {
|
||||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'discussion'",
|
Ok((
|
||||||
)?;
|
row.get::<_, i64>(0)?,
|
||||||
result.documents.truncated = count_query(
|
row.get::<_, i64>(1)?,
|
||||||
&conn,
|
row.get::<_, i64>(2)?,
|
||||||
"SELECT COUNT(*) FROM documents WHERE is_truncated = 1",
|
row.get::<_, i64>(3)?,
|
||||||
)?;
|
row.get::<_, i64>(4)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or((0, 0, 0, 0, 0));
|
||||||
|
result.documents.total = total;
|
||||||
|
result.documents.issues = issues;
|
||||||
|
result.documents.merge_requests = mrs;
|
||||||
|
result.documents.discussions = discussions;
|
||||||
|
result.documents.truncated = truncated;
|
||||||
|
|
||||||
if table_exists(&conn, "embedding_metadata") {
|
if table_exists(&conn, "embedding_metadata") {
|
||||||
let embedded = count_query(
|
// Single scan: COUNT(DISTINCT) + COUNT(*) in one pass
|
||||||
&conn,
|
let (embedded, chunks) = conn
|
||||||
"SELECT COUNT(DISTINCT document_id) FROM embedding_metadata WHERE last_error IS NULL",
|
.query_row(
|
||||||
)?;
|
"SELECT COUNT(DISTINCT document_id), COUNT(*)
|
||||||
let chunks = count_query(
|
FROM embedding_metadata WHERE last_error IS NULL",
|
||||||
&conn,
|
[],
|
||||||
"SELECT COUNT(*) FROM embedding_metadata WHERE last_error IS NULL",
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
)?;
|
)
|
||||||
|
.unwrap_or((0, 0));
|
||||||
result.embeddings.embedded_documents = embedded;
|
result.embeddings.embedded_documents = embedded;
|
||||||
result.embeddings.total_chunks = chunks;
|
result.embeddings.total_chunks = chunks;
|
||||||
result.embeddings.coverage_pct = if result.documents.total > 0 {
|
result.embeddings.coverage_pct = if result.documents.total > 0 {
|
||||||
@@ -115,41 +125,57 @@ pub fn run_stats(config: &Config, check: bool, repair: bool, dry_run: bool) -> R
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
result.fts.indexed = count_query(&conn, "SELECT COUNT(*) FROM documents_fts")?;
|
// FTS5 shadow table is a regular B-tree with one row per document —
|
||||||
|
// 19x faster than scanning the virtual table for COUNT(*)
|
||||||
|
result.fts.indexed = count_query(&conn, "SELECT COUNT(*) FROM documents_fts_docsize")?;
|
||||||
|
|
||||||
result.queues.dirty_sources = count_query(
|
// Single scan: 2 conditional counts on dirty_sources
|
||||||
&conn,
|
let (ds_pending, ds_failed) = conn
|
||||||
"SELECT COUNT(*) FROM dirty_sources WHERE last_error IS NULL",
|
.query_row(
|
||||||
)?;
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
||||||
result.queues.dirty_sources_failed = count_query(
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0)
|
||||||
&conn,
|
FROM dirty_sources",
|
||||||
"SELECT COUNT(*) FROM dirty_sources WHERE last_error IS NOT NULL",
|
[],
|
||||||
)?;
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
result.queues.dirty_sources = ds_pending;
|
||||||
|
result.queues.dirty_sources_failed = ds_failed;
|
||||||
|
|
||||||
if table_exists(&conn, "pending_discussion_fetches") {
|
if table_exists(&conn, "pending_discussion_fetches") {
|
||||||
result.queues.pending_discussion_fetches = count_query(
|
let (pdf_pending, pdf_failed) = conn
|
||||||
&conn,
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_discussion_fetches WHERE last_error IS NULL",
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
||||||
)?;
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0)
|
||||||
result.queues.pending_discussion_fetches_failed = count_query(
|
FROM pending_discussion_fetches",
|
||||||
&conn,
|
[],
|
||||||
"SELECT COUNT(*) FROM pending_discussion_fetches WHERE last_error IS NOT NULL",
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
)?;
|
)
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
result.queues.pending_discussion_fetches = pdf_pending;
|
||||||
|
result.queues.pending_discussion_fetches_failed = pdf_failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if table_exists(&conn, "pending_dependent_fetches") {
|
if table_exists(&conn, "pending_dependent_fetches") {
|
||||||
result.queues.pending_dependent_fetches = count_query(
|
let (pf_pending, pf_failed, pf_stuck) = conn
|
||||||
&conn,
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE last_error IS NULL",
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
||||||
)?;
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0),
|
||||||
result.queues.pending_dependent_fetches_failed = count_query(
|
COALESCE(SUM(CASE WHEN locked_at IS NOT NULL THEN 1 END), 0)
|
||||||
&conn,
|
FROM pending_dependent_fetches",
|
||||||
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE last_error IS NOT NULL",
|
[],
|
||||||
)?;
|
|row| {
|
||||||
result.queues.pending_dependent_fetches_stuck = count_query(
|
Ok((
|
||||||
&conn,
|
row.get::<_, i64>(0)?,
|
||||||
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL",
|
row.get::<_, i64>(1)?,
|
||||||
)?;
|
row.get::<_, i64>(2)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or((0, 0, 0));
|
||||||
|
result.queues.pending_dependent_fetches = pf_pending;
|
||||||
|
result.queues.pending_dependent_fetches_failed = pf_failed;
|
||||||
|
result.queues.pending_dependent_fetches_stuck = pf_stuck;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::field_reassign_with_default)]
|
#[allow(clippy::field_reassign_with_default)]
|
||||||
|
|||||||
@@ -473,9 +473,11 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> R
|
|||||||
let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
||||||
|
|
||||||
// Probe 1: exact file exists in DiffNotes OR mr_file_changes (project-scoped)
|
// Probe 1: exact file exists in DiffNotes OR mr_file_changes (project-scoped)
|
||||||
|
// Exact-match probes already use the partial index, but LIKE probes below
|
||||||
|
// benefit from the INDEXED BY hint (same planner issue as expert query).
|
||||||
let exact_exists = conn
|
let exact_exists = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT 1 FROM notes
|
"SELECT 1 FROM notes INDEXED BY idx_notes_diffnote_path_created
|
||||||
WHERE note_type = 'DiffNote'
|
WHERE note_type = 'DiffNote'
|
||||||
AND is_system = 0
|
AND is_system = 0
|
||||||
AND position_new_path = ?1
|
AND position_new_path = ?1
|
||||||
@@ -501,7 +503,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> R
|
|||||||
let escaped = escape_like(trimmed);
|
let escaped = escape_like(trimmed);
|
||||||
let pat = format!("{escaped}/%");
|
let pat = format!("{escaped}/%");
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT 1 FROM notes
|
"SELECT 1 FROM notes INDEXED BY idx_notes_diffnote_path_created
|
||||||
WHERE note_type = 'DiffNote'
|
WHERE note_type = 'DiffNote'
|
||||||
AND is_system = 0
|
AND is_system = 0
|
||||||
AND position_new_path LIKE ?1 ESCAPE '\\'
|
AND position_new_path LIKE ?1 ESCAPE '\\'
|
||||||
@@ -597,7 +599,8 @@ fn suffix_probe(conn: &Connection, suffix: &str, project_id: Option<i64>) -> Res
|
|||||||
|
|
||||||
let mut stmt = conn.prepare_cached(
|
let mut stmt = conn.prepare_cached(
|
||||||
"SELECT DISTINCT full_path FROM (
|
"SELECT DISTINCT full_path FROM (
|
||||||
SELECT position_new_path AS full_path FROM notes
|
SELECT position_new_path AS full_path
|
||||||
|
FROM notes INDEXED BY idx_notes_diffnote_path_created
|
||||||
WHERE note_type = 'DiffNote'
|
WHERE note_type = 'DiffNote'
|
||||||
AND is_system = 0
|
AND is_system = 0
|
||||||
AND (position_new_path LIKE ?1 ESCAPE '\\' OR position_new_path = ?2)
|
AND (position_new_path LIKE ?1 ESCAPE '\\' OR position_new_path = ?2)
|
||||||
@@ -658,6 +661,13 @@ fn query_expert(
|
|||||||
} else {
|
} else {
|
||||||
"= ?1"
|
"= ?1"
|
||||||
};
|
};
|
||||||
|
// When scanning DiffNotes with a LIKE prefix, SQLite's planner picks the
|
||||||
|
// low-selectivity idx_notes_system (38% of rows) instead of the much more
|
||||||
|
// selective partial index idx_notes_diffnote_path_created (9.3% of rows).
|
||||||
|
// INDEXED BY forces the correct index: measured 64x speedup (1.22s → 0.019s).
|
||||||
|
// For exact matches SQLite already picks the partial index, but the hint
|
||||||
|
// is harmless and keeps behavior consistent.
|
||||||
|
let notes_indexed_by = "INDEXED BY idx_notes_diffnote_path_created";
|
||||||
let author_w = scoring.author_weight;
|
let author_w = scoring.author_weight;
|
||||||
let reviewer_w = scoring.reviewer_weight;
|
let reviewer_w = scoring.reviewer_weight;
|
||||||
let note_b = scoring.note_bonus;
|
let note_b = scoring.note_bonus;
|
||||||
@@ -672,7 +682,7 @@ fn query_expert(
|
|||||||
n.id AS note_id,
|
n.id AS note_id,
|
||||||
n.created_at AS seen_at,
|
n.created_at AS seen_at,
|
||||||
(p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref
|
(p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref
|
||||||
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
|
||||||
@@ -697,7 +707,7 @@ fn query_expert(
|
|||||||
(p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref
|
(p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref
|
||||||
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 notes n ON n.discussion_id = d.id
|
JOIN notes n {notes_indexed_by} ON n.discussion_id = d.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.is_system = 0
|
AND n.is_system = 0
|
||||||
@@ -851,6 +861,7 @@ fn query_expert_details(
|
|||||||
.collect();
|
.collect();
|
||||||
let in_clause = placeholders.join(",");
|
let in_clause = placeholders.join(",");
|
||||||
|
|
||||||
|
let notes_indexed_by = "INDEXED BY idx_notes_diffnote_path_created";
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"
|
"
|
||||||
WITH signals AS (
|
WITH signals AS (
|
||||||
@@ -863,7 +874,7 @@ fn query_expert_details(
|
|||||||
m.title AS title,
|
m.title AS title,
|
||||||
COUNT(*) AS note_count,
|
COUNT(*) AS note_count,
|
||||||
MAX(n.created_at) AS last_activity
|
MAX(n.created_at) AS last_activity
|
||||||
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
|
||||||
@@ -891,7 +902,7 @@ fn query_expert_details(
|
|||||||
MAX(n.created_at) AS last_activity
|
MAX(n.created_at) AS last_activity
|
||||||
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 notes n ON n.discussion_id = d.id
|
JOIN notes n {notes_indexed_by} ON n.discussion_id = d.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.is_system = 0
|
AND n.is_system = 0
|
||||||
@@ -1194,8 +1205,11 @@ fn query_reviews(
|
|||||||
project_id: Option<i64>,
|
project_id: Option<i64>,
|
||||||
since_ms: i64,
|
since_ms: i64,
|
||||||
) -> Result<ReviewsResult> {
|
) -> Result<ReviewsResult> {
|
||||||
// Count total DiffNotes by this user on MRs they didn't author
|
// Force the partial index on DiffNote queries (same rationale as expert mode).
|
||||||
|
// COUNT + COUNT(DISTINCT) + category extraction all benefit from 26K DiffNote
|
||||||
|
// scan vs 282K notes full scan: measured 25x speedup.
|
||||||
let total_sql = "SELECT COUNT(*) FROM notes n
|
let total_sql = "SELECT COUNT(*) FROM notes n
|
||||||
|
INDEXED BY idx_notes_diffnote_path_created
|
||||||
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
|
||||||
WHERE n.author_username = ?1
|
WHERE n.author_username = ?1
|
||||||
@@ -1213,6 +1227,7 @@ fn query_reviews(
|
|||||||
|
|
||||||
// Count distinct MRs reviewed
|
// Count distinct MRs reviewed
|
||||||
let mrs_sql = "SELECT COUNT(DISTINCT m.id) FROM notes n
|
let mrs_sql = "SELECT COUNT(DISTINCT m.id) FROM notes n
|
||||||
|
INDEXED BY idx_notes_diffnote_path_created
|
||||||
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
|
||||||
WHERE n.author_username = ?1
|
WHERE n.author_username = ?1
|
||||||
@@ -1232,7 +1247,7 @@ fn query_reviews(
|
|||||||
let cat_sql = "SELECT
|
let cat_sql = "SELECT
|
||||||
SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
|
SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
|
||||||
COUNT(*) AS cnt
|
COUNT(*) AS cnt
|
||||||
FROM notes n
|
FROM notes n INDEXED BY idx_notes_diffnote_path_created
|
||||||
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
|
||||||
WHERE n.author_username = ?1
|
WHERE n.author_username = ?1
|
||||||
@@ -1517,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
|
||||||
@@ -1526,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
|
||||||
@@ -1549,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