Compare commits
1 Commits
perf-audit
...
740607e06d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
740607e06d |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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-2kop
|
||||
bd-3hjh
|
||||
|
||||
59
AGENTS.md
59
AGENTS.md
@@ -324,7 +324,7 @@ bv --robot-insights | jq '.Cycles' # Circular deps (must
|
||||
|
||||
```bash
|
||||
ubs file.rs file2.rs # Specific files (< 1s) — USE THIS
|
||||
ubs $(jj diff --name-only) # Changed files — before commit
|
||||
ubs $(git diff --name-only --cached) # Staged files — before commit
|
||||
ubs --only=rust,toml src/ # Language filter (3-5x faster)
|
||||
ubs --ci --fail-on-warning . # CI mode — before PR
|
||||
ubs . # Whole project (ignores target/, Cargo.lock)
|
||||
@@ -436,9 +436,9 @@ Returns structured results with file paths, line ranges, and extracted code snip
|
||||
|
||||
## Beads Workflow Integration
|
||||
|
||||
This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in version control.
|
||||
This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in git.
|
||||
|
||||
**Note:** `br` is non-invasive—it never executes VCS commands directly. You must commit manually after `br sync --flush-only`.
|
||||
**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`.
|
||||
|
||||
### Essential Commands
|
||||
|
||||
@@ -454,7 +454,7 @@ br create --title="..." --type=task --priority=2
|
||||
br update <id> --status=in_progress
|
||||
br close <id> --reason="Completed"
|
||||
br close <id1> <id2> # Close multiple issues at once
|
||||
br sync --flush-only # Export to JSONL (then: jj commit -m "Update beads")
|
||||
br sync --flush-only # Export to JSONL (then manually: git add .beads/ && git commit)
|
||||
```
|
||||
|
||||
### Workflow Pattern
|
||||
@@ -474,14 +474,15 @@ br sync --flush-only # Export to JSONL (then: jj commit -m "Update beads")
|
||||
|
||||
### Session Protocol
|
||||
|
||||
**Before ending any session, run this checklist (solo/lead only — workers skip VCS):**
|
||||
**Before ending any session, run this checklist:**
|
||||
|
||||
```bash
|
||||
jj status # Check what changed
|
||||
br sync --flush-only # Export beads to JSONL
|
||||
jj commit -m "..." # Commit code and beads (jj auto-tracks all changes)
|
||||
jj bookmark set <name> -r @- # Point bookmark at committed work
|
||||
jj git push -b <name> # Push to remote
|
||||
git status # Check what changed
|
||||
git add <files> # Stage code changes
|
||||
br sync --flush-only # Export beads to JSONL
|
||||
git add .beads/ # Stage beads changes
|
||||
git commit -m "..." # Commit code and beads
|
||||
git push # Push to remote
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
@@ -490,15 +491,13 @@ jj git push -b <name> # Push to remote
|
||||
- Update status as you work (in_progress → closed)
|
||||
- Create new issues with `br create` when you discover tasks
|
||||
- Use descriptive titles and set appropriate priority/type
|
||||
- Always run `br sync --flush-only` then commit before ending session (jj auto-tracks .beads/)
|
||||
- Always run `br sync --flush-only` then commit .beads/ before ending session
|
||||
|
||||
<!-- end-bv-agent-instructions -->
|
||||
|
||||
## Landing the Plane (Session Completion)
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until push succeeds.
|
||||
|
||||
**WHO RUNS THIS:** Solo agents run it themselves. In multi-agent sessions, ONLY the team lead runs this. Workers skip VCS entirely.
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
@@ -507,20 +506,19 @@ jj git push -b <name> # Push to remote
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
jj git fetch # Get latest remote state
|
||||
jj rebase -d trunk() # Rebase onto latest trunk if needed
|
||||
br sync --flush-only # Export beads to JSONL
|
||||
jj commit -m "Update beads" # Commit (jj auto-tracks .beads/ changes)
|
||||
jj bookmark set <name> -r @- # Point bookmark at committed work
|
||||
jj git push -b <name> # Push to remote
|
||||
jj log -r '<name>' # Verify bookmark position
|
||||
git pull --rebase
|
||||
br sync --flush-only
|
||||
git add .beads/
|
||||
git commit -m "Update beads"
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Abandon empty orphan changes if any (`jj abandon <rev>`)
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `jj git push` succeeds
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
@@ -754,21 +752,6 @@ lore -J mrs --fields iid,title,state,draft,labels # Custom field list
|
||||
- Use `lore robot-docs` for response schema discovery
|
||||
- The `-p` flag supports fuzzy project matching (suffix and substring)
|
||||
|
||||
---
|
||||
|
||||
## Read/Write Split: lore vs glab
|
||||
|
||||
| Operation | Tool | Why |
|
||||
|-----------|------|-----|
|
||||
| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |
|
||||
| View issue/MR detail | lore | Pre-joined discussions, work-item status |
|
||||
| Search across entities | lore | FTS5 + vector hybrid search |
|
||||
| Expert/workload analysis | lore | who command — no glab equivalent |
|
||||
| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |
|
||||
| Create/update/close | glab | Write operations |
|
||||
| Approve/merge MR | glab | Write operations |
|
||||
| CI/CD pipelines | glab | Not in lore scope |
|
||||
|
||||
````markdown
|
||||
## UBS Quick Reference for AI Agents
|
||||
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1106,7 +1106,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lore"
|
||||
version = "0.6.2"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lore"
|
||||
version = "0.6.2"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
description = "Gitlore - Local GitLab data management with semantic search"
|
||||
authors = ["Taylor Eernisse"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 274 KiB |
@@ -1,204 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 269 KiB |
@@ -1,203 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 217 KiB |
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 179 KiB |
@@ -1,184 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB |
@@ -1,131 +0,0 @@
|
||||
1. **Make immutable identity usable now (`--author-id`)**
|
||||
Why: The plan captures `author_id` but intentionally defers using it, so the core longitudinal-analysis problem is only half-fixed.
|
||||
|
||||
```diff
|
||||
@@ Phase 1: `lore notes` Command / Work Chunk 1A
|
||||
pub struct NoteListFilters<'a> {
|
||||
+ pub author_id: Option<i64>, // immutable identity filter
|
||||
@@
|
||||
- pub author: Option<&'a str>, // case-insensitive match via COLLATE NOCASE
|
||||
+ pub author: Option<&'a str>, // display-name filter
|
||||
+ // If both author and author_id are provided, apply both (AND) for precision.
|
||||
}
|
||||
@@
|
||||
Filter mappings:
|
||||
+ - `author_id`: `n.author_id = ?` (exact immutable identity)
|
||||
- `author`: strip `@` prefix, `n.author_username = ? COLLATE NOCASE`
|
||||
@@ Phase 1 / Work Chunk 1B (CLI)
|
||||
+ /// Filter by immutable author id
|
||||
+ #[arg(long = "author-id", help_heading = "Filters")]
|
||||
+ pub author_id: Option<i64>,
|
||||
@@ Phase 2 / Work Chunk 2F
|
||||
+ Add `--author-id` support to `lore search` filtering for note documents.
|
||||
@@ Phase 1 / Work Chunk 1E
|
||||
+ CREATE INDEX IF NOT EXISTS idx_notes_project_author_id_created
|
||||
+ ON notes(project_id, author_id, created_at DESC, id DESC)
|
||||
+ WHERE is_system = 0 AND author_id IS NOT NULL;
|
||||
```
|
||||
|
||||
2. **Fix document staleness on username changes**
|
||||
Why: Current plan says username changes are “not semantic,” but note documents include username in content/title, so docs go stale/inconsistent.
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 0D: Immutable Author Identity Capture
|
||||
- Assert: changed_semantics = false (username change is not a semantic change for documents)
|
||||
+ Assert: changed_semantics = true (username affects note document content/title)
|
||||
@@ Work Chunk 0A: semantic-change detection
|
||||
- old_body != body || old_note_type != note_type || ...
|
||||
+ old_body != body || old_note_type != note_type || ...
|
||||
+ || old_author_username != author_username
|
||||
@@ Work Chunk 2C: Note Document Extractor header
|
||||
author: @{author}
|
||||
+ author_id: {author_id}
|
||||
```
|
||||
|
||||
3. **Replace `last_seen_at` sweep marker with monotonic `sync_run_id`**
|
||||
Why: Timestamp markers are vulnerable to clock skew and concurrent runs; run IDs are deterministic and safer.
|
||||
|
||||
```diff
|
||||
@@ Phase 0: Stable Note Identity
|
||||
+ ### Work Chunk 0E: Monotonic Run Marker
|
||||
+ Add `sync_runs` table and `notes.last_seen_run_id`.
|
||||
+ Ingest assigns one run_id per sync transaction.
|
||||
+ Upsert sets `last_seen_run_id = current_run_id`.
|
||||
+ Sweep condition becomes `last_seen_run_id < current_run_id` (when fetch_complete=true).
|
||||
@@ Work Chunk 0C
|
||||
- fetch_complete + last_seen_at-based sweep
|
||||
+ fetch_complete + run_id-based sweep
|
||||
```
|
||||
|
||||
4. **Materialize stale-note set once during sweep**
|
||||
Why: Current set-based SQL still re-runs the stale subquery 3 times; materializing once improves performance and guarantees identical deletion set.
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 0B: Immediate Deletion Propagation
|
||||
- DELETE FROM documents ... IN (SELECT id FROM notes WHERE ...);
|
||||
- DELETE FROM dirty_sources ... IN (SELECT id FROM notes WHERE ...);
|
||||
- DELETE FROM notes WHERE ...;
|
||||
+ CREATE TEMP TABLE _stale_note_ids AS
|
||||
+ SELECT id, is_system FROM notes WHERE discussion_id = ? AND last_seen_run_id < ?;
|
||||
+ DELETE FROM documents
|
||||
+ WHERE source_type='note' AND source_id IN (SELECT id FROM _stale_note_ids WHERE is_system=0);
|
||||
+ DELETE FROM dirty_sources
|
||||
+ WHERE source_type='note' AND source_id IN (SELECT id FROM _stale_note_ids WHERE is_system=0);
|
||||
+ DELETE FROM notes WHERE id IN (SELECT id FROM _stale_note_ids);
|
||||
+ DROP TABLE _stale_note_ids;
|
||||
```
|
||||
|
||||
5. **Move historical note backfill out of migration into resumable runtime job**
|
||||
Why: Data-heavy migration can block startup and is harder to resume/recover on large DBs.
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 2H
|
||||
- Backfill Existing Notes After Upgrade (Migration 024)
|
||||
+ Backfill Existing Notes After Upgrade (Resumable Runtime Backfill)
|
||||
@@
|
||||
- Files: `migrations/024_note_dirty_backfill.sql`, `src/core/db.rs`
|
||||
+ Files: `src/documents/backfill.rs`, `src/cli/commands/generate_docs.rs`
|
||||
@@
|
||||
- INSERT INTO dirty_sources ... SELECT ... FROM notes ...
|
||||
+ Introduce batched backfill API:
|
||||
+ `enqueue_missing_note_documents(batch_size: usize) -> BackfillProgress`
|
||||
+ invoked from `generate-docs`/`sync` until complete, resumable across runs.
|
||||
```
|
||||
|
||||
6. **Add streaming path for large `jsonl`/`csv` note exports**
|
||||
Why: Current `query_notes` materializes full result set in memory; streaming improves scalability and latency.
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 1A
|
||||
+ Add `query_notes_stream(conn, filters, row_handler)` for forward-only row iteration.
|
||||
@@ Work Chunk 1C
|
||||
- print_list_notes_jsonl(&result)
|
||||
- print_list_notes_csv(&result)
|
||||
+ print_list_notes_jsonl_stream(config, filters)
|
||||
+ print_list_notes_csv_stream(config, filters)
|
||||
+ (table/json keep counted buffered path)
|
||||
```
|
||||
|
||||
7. **Add index for path-centric note queries**
|
||||
Why: `--path` + project/date queries are a stated hot path and not fully covered by current proposed indexes.
|
||||
|
||||
```diff
|
||||
@@ Work Chunk 1E: Composite Query Index
|
||||
+ CREATE INDEX IF NOT EXISTS idx_notes_project_path_created
|
||||
+ ON notes(project_id, position_new_path, created_at DESC, id DESC)
|
||||
+ WHERE is_system = 0 AND position_new_path IS NOT NULL;
|
||||
```
|
||||
|
||||
8. **Add property/invariant tests (not only examples)**
|
||||
Why: This feature touches ingestion identity, sweeping, deletion propagation, and document regeneration; randomized invariants will catch subtle regressions.
|
||||
|
||||
```diff
|
||||
@@ Verification Checklist
|
||||
+ Add property tests (proptest):
|
||||
+ - stable local IDs across randomized re-sync orderings
|
||||
+ - no orphan `documents(source_type='note')` after randomized deletions/sweeps
|
||||
+ - partial-fetch runs never reduce note count
|
||||
+ - repeated full rebuild converges (fixed-point idempotence)
|
||||
```
|
||||
|
||||
These revisions keep your existing direction, avoid all rejected items, and materially improve correctness, scale behavior, and long-term maintainability.
|
||||
@@ -2,7 +2,7 @@
|
||||
plan: true
|
||||
title: ""
|
||||
status: iterating
|
||||
iteration: 5
|
||||
iteration: 6
|
||||
target_iterations: 8
|
||||
beads_revision: 0
|
||||
related_plans: []
|
||||
@@ -171,12 +171,17 @@ Then detect semantic change with a separate check that excludes `updated_at` and
|
||||
```sql
|
||||
WHERE notes.body IS NOT excluded.body
|
||||
OR notes.note_type IS NOT excluded.note_type
|
||||
OR notes.author_username IS NOT excluded.author_username
|
||||
OR notes.resolved IS NOT excluded.resolved
|
||||
OR notes.resolved_by IS NOT excluded.resolved_by
|
||||
OR notes.position_new_path IS NOT excluded.position_new_path
|
||||
OR notes.position_new_line IS NOT excluded.position_new_line
|
||||
```
|
||||
|
||||
**Why `author_username` is semantic:** Note documents embed the username in both the content header (`author: @{author}`) and the title (`Note by @{author} on Issue #42`). If a GitLab user changes their username (e.g., `jdefting` -> `jd-engineering`), the existing note documents become stale — search results show the old username, inconsistent with what the API returns. Treating username changes as semantic ensures documents stay accurate.
|
||||
|
||||
**Note:** `author_id` changes do NOT trigger `changed_semantics`. The `author_id` is an immutable identity anchor — it never changes in practice, and even if it did (data migration), it doesn't affect document content.
|
||||
|
||||
**Rationale:** `updated_at` changes alone (e.g., GitLab touching the timestamp without modifying content) should NOT trigger document regeneration. This avoids unnecessary dirty queue churn on large datasets. The WHERE clause fires the DO UPDATE unconditionally (to refresh `last_seen_at`), and `changed_semantics` is derived from `conn.changes()` after a second query that checks only semantic fields:
|
||||
|
||||
```rust
|
||||
@@ -210,8 +215,8 @@ let local_id = match &existed {
|
||||
|
||||
let changed_semantics = match &existed {
|
||||
None => true, // New insert = always changed
|
||||
Some((_, old_body, old_note_type, old_resolved, old_path, old_line)) => {
|
||||
old_body.as_deref() != body || old_note_type.as_deref() != note_type || /* ... */
|
||||
Some((_, old_body, old_note_type, old_author_username, old_resolved, old_path, old_line)) => {
|
||||
old_body.as_deref() != body || old_note_type.as_deref() != note_type || old_author_username.as_deref() != author_username || /* ... */
|
||||
}
|
||||
};
|
||||
```
|
||||
@@ -401,7 +406,7 @@ if fetch_complete {
|
||||
|
||||
GitLab note payloads include `note.author.id` (an immutable integer). Capturing this alongside the username provides a stable identity anchor for longitudinal analysis, even across username changes.
|
||||
|
||||
**Scope:** This chunk adds the column and populates it during ingestion. It does NOT add a `--author-id` CLI filter — that's deferred to the downstream reviewer profiling PRD. The value here is data capture: once `author_id` is stored, it can never be retroactively recovered if we don't capture it now.
|
||||
**Scope:** This chunk adds the column and populates it during ingestion. A `--author-id` CLI filter for `lore notes` is wired up in Phase 1 (Work Chunk 1A/1B) to make the immutable identity immediately usable for the core longitudinal analysis use case. The value here is data capture and query foundation: once `author_id` is stored, it can never be retroactively recovered if we don't capture it now.
|
||||
|
||||
#### Tests to Write First
|
||||
|
||||
@@ -431,7 +436,7 @@ fn test_note_author_id_survives_username_change() {
|
||||
// Re-upsert same gitlab_id with author_username = "jd-engineering", author_id = 12345
|
||||
// Assert: author_id unchanged (12345)
|
||||
// Assert: author_username updated to "jd-engineering"
|
||||
// Assert: changed_semantics = false (username change is not a semantic change for documents)
|
||||
// Assert: changed_semantics = true (username is embedded in document content/title)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -445,18 +450,19 @@ Add to the query index migration SQL:
|
||||
-- Add immutable author identity column (nullable for backcompat with pre-existing notes)
|
||||
ALTER TABLE notes ADD COLUMN author_id INTEGER;
|
||||
|
||||
-- Index for future author_id lookups (not used by current CLI, but enables
|
||||
-- the downstream reviewer profiling PRD to query by stable identity)
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_author_id
|
||||
ON notes(author_id)
|
||||
WHERE author_id IS NOT NULL;
|
||||
-- Composite index for author_id lookups — used by `lore notes --author-id`
|
||||
-- for immutable identity queries. Includes project_id and created_at for
|
||||
-- the common "all notes by this person in this project" pattern.
|
||||
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. Populate `author_id` during upsert** — In both `upsert_note_for_issue()` (discussions.rs) and `upsert_note()` (mr_discussions.rs), add `author_id` to the INSERT and ON CONFLICT DO UPDATE SET clauses. Extract from the GitLab API note payload's `author.id` field.
|
||||
|
||||
**3. Semantic change detection** — `author_id` changes should NOT trigger `changed_semantics = true`. The `author_id` is an identity anchor, not a content field. It's excluded from the semantic change comparison alongside `updated_at` and `last_seen_at`.
|
||||
**3. Semantic change detection** — `author_id` changes should NOT trigger `changed_semantics = true`. The `author_id` is an identity anchor, not a content field. It's excluded from the semantic change comparison alongside `updated_at` and `last_seen_at`. However, `author_username` changes DO trigger `changed_semantics = true` because the username appears in document content and title (see Work Chunk 0A semantic detection).
|
||||
|
||||
**4. Note document extraction** — No changes needed for this chunk. The `extract_note_document()` function (Work Chunk 2C) uses `author_username` for the document content. The `author_id` is stored for future use but not surfaced in the current document format.
|
||||
**4. Note document extraction** — Work Chunk 2C's `extract_note_document()` function includes both `author_username` (in the document content header and title) and `author_id` (in the metadata header). The `author_id` field enables downstream tools to reliably identify the same person even after username changes.
|
||||
|
||||
---
|
||||
|
||||
@@ -515,11 +521,25 @@ fn test_query_notes_filter_author_strips_at() {
|
||||
|
||||
#[test]
|
||||
fn test_query_notes_filter_author_case_insensitive() {
|
||||
// Insert notes from "Alice" (capital A)
|
||||
// Insert note from "Alice" (capital A)
|
||||
// Call query_notes with author = Some("alice")
|
||||
// Assert: matches (COLLATE NOCASE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_notes_filter_author_id() {
|
||||
// Insert notes from author_id = 100 (username "alice") and author_id = 200 (username "bob")
|
||||
// Call query_notes with author_id = Some(100)
|
||||
// Assert: only alice's notes returned (by immutable identity)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_notes_filter_author_id_and_author_combined() {
|
||||
// Insert notes from author_id=100/username="alice" and author_id=100/username="alice-renamed"
|
||||
// Call query_notes with author_id = Some(100), author = Some("alice")
|
||||
// Assert: only notes where BOTH match (AND semantics) — returns alice's notes before rename
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_notes_filter_note_type() {
|
||||
// Insert notes with note_type = Some("DiffNote") and Some("DiscussionNote") and None
|
||||
@@ -785,7 +805,8 @@ impl From<&NoteListResult> for NoteListResultJson { ... }
|
||||
pub struct NoteListFilters<'a> {
|
||||
pub limit: usize,
|
||||
pub project: Option<&'a str>,
|
||||
pub author: Option<&'a str>, // case-insensitive match via COLLATE NOCASE
|
||||
pub author: Option<&'a str>, // display-name filter, case-insensitive via COLLATE NOCASE
|
||||
pub author_id: Option<i64>, // immutable identity filter (exact match)
|
||||
pub note_type: Option<&'a str>, // "DiffNote" | "DiscussionNote"
|
||||
pub include_system: bool, // default false
|
||||
pub for_issue_iid: Option<i64>, // filter by parent issue iid
|
||||
@@ -857,6 +878,7 @@ Dynamic WHERE clauses follow the same `where_clauses` + `params` vec pattern as
|
||||
Filter mappings:
|
||||
- `include_system = false` (default): `n.is_system = 0`
|
||||
- `author`: strip `@` prefix, `n.author_username = ? COLLATE NOCASE`
|
||||
- `author_id`: `n.author_id = ?` (exact immutable identity match). If both `author` and `author_id` are provided, both are applied (AND) for precision — this lets users query "notes by user 12345 when they were known as jdefting"
|
||||
- `note_type`: `n.note_type = ?`
|
||||
- `project`: `resolve_project(conn, project)?` then `n.project_id = ?`
|
||||
- `note_id`: `n.id = ?` (exact local row ID match — useful for debugging sync correctness)
|
||||
@@ -876,14 +898,25 @@ Filter mappings:
|
||||
|
||||
COUNT query first (same pattern as issues), then SELECT with LIMIT.
|
||||
|
||||
**Public entry point:**
|
||||
**Public entry points:**
|
||||
|
||||
```rust
|
||||
/// Buffered query — materializes full result set. Used by table and JSON output.
|
||||
pub fn run_list_notes(config: &Config, filters: NoteListFilters) -> Result<NoteListResult> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
query_notes(&conn, &filters)
|
||||
}
|
||||
|
||||
/// Streaming query — calls row_handler for each row without full materialization.
|
||||
/// Used by JSONL and CSV output (Work Chunk 1C). Skips COUNT query.
|
||||
pub fn query_notes_stream<F>(conn: &Connection, filters: &NoteListFilters, mut row_handler: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(NoteListRow) -> Result<()>,
|
||||
{
|
||||
// Same SQL as query_notes() but iterates with Statement::query_map()
|
||||
// instead of collecting into Vec<NoteListRow>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -940,6 +973,10 @@ pub struct NotesArgs {
|
||||
#[arg(short = 'a', long, help_heading = "Filters")]
|
||||
pub author: Option<String>,
|
||||
|
||||
/// Filter by immutable GitLab author id (stable across username changes)
|
||||
#[arg(long = "author-id", help_heading = "Filters")]
|
||||
pub author_id: Option<i64>,
|
||||
|
||||
/// Filter by note type (DiffNote, DiscussionNote)
|
||||
#[arg(long = "note-type", value_parser = ["DiffNote", "DiscussionNote"], help_heading = "Filters")]
|
||||
pub note_type: Option<String>,
|
||||
@@ -1037,6 +1074,7 @@ fn handle_notes(config_path: Option<&str>, args: NotesArgs, robot_mode: bool) ->
|
||||
limit: args.limit,
|
||||
project: args.project.as_deref(),
|
||||
author: args.author.as_deref(),
|
||||
author_id: args.author_id,
|
||||
note_type: args.note_type.as_deref(),
|
||||
include_system: args.include_system,
|
||||
for_issue_iid: args.for_issue,
|
||||
@@ -1053,20 +1091,27 @@ fn handle_notes(config_path: Option<&str>, args: NotesArgs, robot_mode: bool) ->
|
||||
order: if args.asc { "asc" } else { "desc" },
|
||||
};
|
||||
|
||||
let result = run_list_notes(&config, filters)?;
|
||||
|
||||
// JSONL and CSV use streaming path (no full materialization in memory)
|
||||
// Table and JSON use buffered path (need total_count for envelope/summary)
|
||||
match (robot_mode, args.format.as_str()) {
|
||||
(true, _) | (_, "json") => {
|
||||
print_list_notes_json(&result, start.elapsed().as_millis() as u64, args.fields.as_deref());
|
||||
}
|
||||
(_, "jsonl") => {
|
||||
print_list_notes_jsonl(&result);
|
||||
let conn = open_db(&config)?;
|
||||
print_list_notes_jsonl_stream(&conn, &filters)?;
|
||||
}
|
||||
(_, "csv") => {
|
||||
print_list_notes_csv(&result);
|
||||
let conn = open_db(&config)?;
|
||||
print_list_notes_csv_stream(&conn, &filters)?;
|
||||
}
|
||||
_ => {
|
||||
print_list_notes(&result);
|
||||
let result = run_list_notes(&config, filters)?;
|
||||
match (robot_mode, args.format.as_str()) {
|
||||
(true, _) | (_, "json") => {
|
||||
print_list_notes_json(&result, start.elapsed().as_millis() as u64, args.fields.as_deref());
|
||||
}
|
||||
_ => {
|
||||
print_list_notes(&result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1082,7 +1127,7 @@ Some(Commands::Notes(args)) => handle_notes(cli.config.as_deref(), args, robot_m
|
||||
**5. Re-export in `src/cli/commands/mod.rs`:**
|
||||
|
||||
```rust
|
||||
pub use list::{run_list_notes, print_list_notes, print_list_notes_json, print_list_notes_jsonl, print_list_notes_csv};
|
||||
pub use list::{run_list_notes, query_notes_stream, print_list_notes, print_list_notes_json, print_list_notes_jsonl_stream, print_list_notes_csv_stream};
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1106,18 +1151,27 @@ fn test_truncate_note_body() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csv_output_roundtrip() {
|
||||
// NoteListRow with body containing commas, quotes, newlines, and multi-byte chars
|
||||
// Write via print_list_notes_csv, parse back with csv::ReaderBuilder
|
||||
fn test_csv_stream_output_roundtrip() {
|
||||
// Setup DB with notes containing commas, quotes, newlines, and multi-byte chars in body
|
||||
// Run print_list_notes_csv_stream, capture stdout, parse back with csv::ReaderBuilder
|
||||
// Assert: all fields roundtrip correctly
|
||||
// Assert: header row present
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonl_output_one_per_line() {
|
||||
// NoteListResult with 3 notes
|
||||
// Capture stdout, split by newline
|
||||
fn test_jsonl_stream_output_one_per_line() {
|
||||
// Setup DB with 3 non-system notes
|
||||
// Run print_list_notes_jsonl_stream, capture stdout, split by newline
|
||||
// Assert: each line parses as valid JSON
|
||||
// Assert: 3 lines total
|
||||
// Assert: 3 lines total (no envelope, no metadata line)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_matches_buffered_content() {
|
||||
// Setup DB with 5 non-system notes
|
||||
// Run buffered query_notes() and streaming query_notes_stream()
|
||||
// Assert: identical note data in same order (streaming omits total_count, but
|
||||
// the content of each row must match the buffered path)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1153,33 +1207,42 @@ Follows exact envelope pattern:
|
||||
|
||||
Supports `--fields` via `filter_fields(&mut output, "notes", &expanded)`.
|
||||
|
||||
**`print_list_notes_jsonl(result: &NoteListResult)`** — one JSON object per line:
|
||||
**`print_list_notes_jsonl` / `print_list_notes_csv`** — streaming output:
|
||||
|
||||
For JSONL and CSV formats, use a **streaming path** that writes rows directly to stdout as they're read from the database, avoiding full materialization in memory. This matters for the year-long analysis use case where `--limit 10000` or higher is common, and for piped workflows where downstream consumers (jq, LLM ingestion) can begin processing before the query completes.
|
||||
|
||||
**`print_list_notes_jsonl_stream(conn, filters)`** — streaming JSONL:
|
||||
|
||||
```rust
|
||||
// Execute query, iterate over rows with a callback
|
||||
query_notes_stream(&conn, &filters, |row| {
|
||||
let json_row = NoteListRowJson::from(&row);
|
||||
println!("{}", serde_json::to_string(&json_row).unwrap());
|
||||
Ok(())
|
||||
})?;
|
||||
```
|
||||
|
||||
Each line is a complete `NoteListRowJson` object. No envelope, no metadata. This format is ideal for streaming into LLM prompts, `jq` pipelines, or notebook ingestion.
|
||||
|
||||
```rust
|
||||
for note in &result.notes {
|
||||
let json_row = NoteListRowJson::from(note);
|
||||
println!("{}", serde_json::to_string(&json_row).unwrap());
|
||||
}
|
||||
```
|
||||
|
||||
**`print_list_notes_csv(result: &NoteListResult)`** — CSV with header:
|
||||
|
||||
Columns mirror `NoteListRowJson` field names. Uses the `csv` crate (`csv::Writer`) for RFC 4180-compliant escaping, handling commas, quotes, newlines, and multi-byte characters correctly. This avoids the fragility of manual CSV escaping.
|
||||
**`print_list_notes_csv_stream(conn, filters)`** — streaming CSV:
|
||||
|
||||
```rust
|
||||
let mut wtr = csv::Writer::from_writer(std::io::stdout());
|
||||
// Write header
|
||||
wtr.write_record(&["id", "gitlab_id", "author_username", "body", "note_type", ...])?;
|
||||
// Write rows
|
||||
for note in &result.notes {
|
||||
let json_row = NoteListRowJson::from(note);
|
||||
query_notes_stream(&conn, &filters, |row| {
|
||||
let json_row = NoteListRowJson::from(&row);
|
||||
wtr.write_record(&[json_row.id.to_string(), ...])?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
wtr.flush()?;
|
||||
```
|
||||
|
||||
Columns mirror `NoteListRowJson` field names. Uses the `csv` crate (`csv::Writer`) for RFC 4180-compliant escaping, handling commas, quotes, newlines, and multi-byte characters correctly.
|
||||
|
||||
**`query_notes_stream(conn, filters, row_handler)`** — forward-only row iteration that calls `row_handler` for each row. Uses the same SQL as `query_notes()` but iterates with `rusqlite::Statement::query_map()` instead of collecting into a Vec. The table and JSON formats continue to use the buffered `query_notes()` path since they need `total_count` and `showing` metadata.
|
||||
|
||||
**Note:** The streaming path skips the COUNT query since there's no envelope to report `total_count` in. For JSONL, this is expected — consumers count lines themselves. For CSV, the header row provides column names; row count is implicit.
|
||||
|
||||
**Dependency:** Add `csv = "1"` to `Cargo.toml` under `[dependencies]`. The `csv` crate is well-maintained, widely adopted (~100M downloads), and has zero unsafe code.
|
||||
|
||||
---
|
||||
@@ -1219,12 +1282,13 @@ fn test_migration_022_indexes_exist() {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN (
|
||||
'idx_notes_user_created', 'idx_notes_project_created',
|
||||
'idx_notes_project_path_created',
|
||||
'idx_discussions_issue_id', 'idx_discussions_mr_id'
|
||||
)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(count, 4);
|
||||
assert_eq!(count, 5);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1248,6 +1312,13 @@ CREATE INDEX IF NOT EXISTS idx_notes_project_created
|
||||
ON notes(project_id, created_at DESC, id DESC)
|
||||
WHERE is_system = 0;
|
||||
|
||||
-- Composite index for path-centric note queries (--path with project/date filters).
|
||||
-- DiffNote reviews on specific files are a stated hot path for the reviewer
|
||||
-- profiling use case. Only indexes rows where position_new_path is populated.
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_project_path_created
|
||||
ON notes(project_id, position_new_path, created_at DESC, id DESC)
|
||||
WHERE is_system = 0 AND position_new_path IS NOT NULL;
|
||||
|
||||
-- Index on discussions.issue_id for efficient JOIN when filtering by parent issue.
|
||||
-- The query_notes() function JOINs discussions to reach parent entities.
|
||||
CREATE INDEX IF NOT EXISTS idx_discussions_issue_id
|
||||
@@ -1258,7 +1329,7 @@ CREATE INDEX IF NOT EXISTS idx_discussions_mr_id
|
||||
ON discussions(merge_request_id);
|
||||
```
|
||||
|
||||
The first partial index serves the primary use case (author-scoped queries) with `COLLATE NOCASE` matching the query's case-insensitive author comparison. The second serves project-scoped date-range queries (`--since`/`--until` without `--author`). Both exclude system notes, which are filtered out by default. The discussion indexes accelerate the JOIN path used by all note queries.
|
||||
The first partial index serves the primary use case (author-scoped queries) with `COLLATE NOCASE` matching the query's case-insensitive author comparison. The second serves project-scoped date-range queries (`--since`/`--until` without `--author`). The third serves path-centric DiffNote queries (`--path src/auth/` combined with project and date filters). All three exclude system notes, which are filtered out by default. The discussion indexes accelerate the JOIN path used by all note queries.
|
||||
|
||||
**Register in `src/core/db.rs`:**
|
||||
|
||||
@@ -1388,156 +1459,17 @@ The migration must:
|
||||
6. Same pattern for `dirty_sources` (simpler — no dependents)
|
||||
|
||||
```sql
|
||||
-- Capture pre-migration counts for integrity verification
|
||||
CREATE TEMP TABLE _pre_counts AS
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM documents) AS doc_count,
|
||||
(SELECT COUNT(*) FROM document_labels) AS label_count,
|
||||
(SELECT COUNT(*) FROM document_paths) AS path_count,
|
||||
(SELECT COUNT(*) FROM dirty_sources) AS dirty_count;
|
||||
|
||||
-- Rebuild dirty_sources with expanded CHECK
|
||||
CREATE TABLE dirty_sources_new (
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||
source_id INTEGER NOT NULL,
|
||||
queued_at INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER,
|
||||
PRIMARY KEY(source_type, source_id)
|
||||
);
|
||||
INSERT INTO dirty_sources_new SELECT * FROM dirty_sources;
|
||||
DROP TABLE dirty_sources;
|
||||
ALTER TABLE dirty_sources_new RENAME TO dirty_sources;
|
||||
CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at);
|
||||
|
||||
-- Rebuild documents (must preserve FTS consistency)
|
||||
-- Step 1: Save junction table data
|
||||
CREATE TABLE _doc_labels_backup AS SELECT * FROM document_labels;
|
||||
CREATE TABLE _doc_paths_backup AS SELECT * FROM document_paths;
|
||||
|
||||
-- Step 2: Drop FTS triggers (they reference 'documents')
|
||||
DROP TRIGGER IF EXISTS documents_ai;
|
||||
DROP TRIGGER IF EXISTS documents_ad;
|
||||
DROP TRIGGER IF EXISTS documents_au;
|
||||
|
||||
-- Step 3: Drop junction tables (they FK to documents)
|
||||
DROP TABLE document_labels;
|
||||
DROP TABLE document_paths;
|
||||
|
||||
-- Step 4: Rebuild documents with updated CHECK
|
||||
CREATE TABLE documents_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||
source_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
author_username TEXT,
|
||||
label_names TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
content_text TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
labels_hash TEXT NOT NULL DEFAULT '',
|
||||
paths_hash TEXT NOT NULL DEFAULT '',
|
||||
is_truncated INTEGER NOT NULL DEFAULT 0,
|
||||
truncated_reason TEXT CHECK (
|
||||
truncated_reason IN (
|
||||
'token_limit_middle_drop','single_note_oversized','first_last_oversized',
|
||||
'hard_cap_oversized'
|
||||
)
|
||||
OR truncated_reason IS NULL
|
||||
),
|
||||
UNIQUE(source_type, source_id)
|
||||
);
|
||||
INSERT INTO documents_new SELECT * FROM documents;
|
||||
DROP TABLE documents;
|
||||
ALTER TABLE documents_new RENAME TO documents;
|
||||
|
||||
-- Step 5: Recreate indexes
|
||||
CREATE INDEX idx_documents_project_updated ON documents(project_id, updated_at);
|
||||
CREATE INDEX idx_documents_author ON documents(author_username);
|
||||
CREATE INDEX idx_documents_source ON documents(source_type, source_id);
|
||||
CREATE INDEX idx_documents_hash ON documents(content_hash);
|
||||
|
||||
-- Step 6: Recreate junction tables
|
||||
CREATE TABLE document_labels (
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
label_name TEXT NOT NULL,
|
||||
PRIMARY KEY(document_id, label_name)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX idx_document_labels_label ON document_labels(label_name);
|
||||
|
||||
CREATE TABLE document_paths (
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY(document_id, path)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX idx_document_paths_path ON document_paths(path);
|
||||
|
||||
-- Step 7: Restore junction table data
|
||||
INSERT INTO document_labels SELECT * FROM _doc_labels_backup;
|
||||
INSERT INTO document_paths SELECT * FROM _doc_paths_backup;
|
||||
DROP TABLE _doc_labels_backup;
|
||||
DROP TABLE _doc_paths_backup;
|
||||
|
||||
-- Step 8: Recreate FTS triggers
|
||||
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(rowid, title, content_text)
|
||||
VALUES (new.id, COALESCE(new.title, ''), new.content_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
||||
INSERT INTO documents_fts(documents_fts, rowid, title, content_text)
|
||||
VALUES('delete', old.id, COALESCE(old.title, ''), old.content_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_au AFTER UPDATE ON documents
|
||||
WHEN old.title IS NOT new.title OR old.content_text != new.content_text
|
||||
BEGIN
|
||||
INSERT INTO documents_fts(documents_fts, rowid, title, content_text)
|
||||
VALUES('delete', old.id, COALESCE(old.title, ''), old.content_text);
|
||||
INSERT INTO documents_fts(rowid, title, content_text)
|
||||
VALUES (new.id, COALESCE(new.title, ''), new.content_text);
|
||||
END;
|
||||
|
||||
-- Step 9: Rebuild FTS index to be safe
|
||||
INSERT INTO documents_fts(documents_fts) VALUES('rebuild');
|
||||
|
||||
-- Step 10: Defense-in-depth cleanup triggers for note documents.
|
||||
-- These fire when a note is deleted or flipped to system, ensuring orphaned
|
||||
-- documents/dirty_sources entries cannot survive even if a future code path
|
||||
-- deletes notes outside the normal sweep functions (Work Chunk 0B).
|
||||
-- The sweep functions handle the common path; these triggers are the safety net.
|
||||
CREATE TRIGGER notes_ad_cleanup AFTER DELETE ON notes
|
||||
WHEN old.is_system = 0
|
||||
BEGIN
|
||||
DELETE FROM documents
|
||||
WHERE source_type = 'note' AND source_id = old.id;
|
||||
DELETE FROM dirty_sources
|
||||
WHERE source_type = 'note' AND source_id = old.id;
|
||||
END;
|
||||
|
||||
-- If a note is reclassified from user to system (unlikely but possible via
|
||||
-- API changes), remove its document artifacts since system notes don't get documents.
|
||||
CREATE TRIGGER notes_au_system_cleanup AFTER UPDATE OF is_system ON notes
|
||||
WHEN old.is_system = 0 AND new.is_system = 1
|
||||
BEGIN
|
||||
DELETE FROM documents
|
||||
WHERE source_type = 'note' AND source_id = new.id;
|
||||
DELETE FROM dirty_sources
|
||||
WHERE source_type = 'note' AND source_id = new.id;
|
||||
END;
|
||||
|
||||
-- Step 11: Integrity verification (moved to migration tests)
|
||||
-- Note: RAISE(ABORT, ...) in standalone SELECT is not valid SQLite usage outside
|
||||
-- triggers/CHECK constraints. Integrity checks are enforced in the migration test
|
||||
-- suite instead (see test_migration_023_integrity_checks_pass). This keeps migration
|
||||
-- SQL portable and avoids relying on SQLite-version-specific behavior.
|
||||
|
||||
DROP TABLE _pre_counts;
|
||||
-- Backfill: seed all existing non-system notes into the dirty queue
|
||||
-- so the next generate-docs run creates documents for them.
|
||||
-- Uses LEFT JOIN to skip notes that already have documents (idempotent).
|
||||
-- ON CONFLICT DO NOTHING handles notes already in the dirty queue.
|
||||
INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||
SELECT 'note', n.id, CAST(strftime('%s', 'now') AS INTEGER) * 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;
|
||||
```
|
||||
|
||||
**Register in `src/core/db.rs`:**
|
||||
@@ -1551,59 +1483,7 @@ Add to the `MIGRATIONS` array (after migration 022):
|
||||
),
|
||||
```
|
||||
|
||||
`LATEST_SCHEMA_VERSION` auto-derives from `MIGRATIONS.len()` — no manual change needed.
|
||||
|
||||
**Migration integrity tests** (add to migration test module):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_migration_023_integrity_checks_pass() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
// Run all migrations up to 022, then insert test data
|
||||
// Run migration 023
|
||||
// 1. Verify pre/post row count equality for documents, document_labels,
|
||||
// document_paths, and dirty_sources
|
||||
// 2. Verify PRAGMA foreign_key_check returns empty result set
|
||||
// 3. Verify documents_fts row count matches documents row count after rebuild
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_023_fts_rebuild_consistent() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Insert several documents with different source types
|
||||
// Verify: SELECT COUNT(*) FROM documents_fts == SELECT COUNT(*) FROM documents
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_023_note_delete_trigger_cleans_document() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Setup: project, issue, discussion, non-system note
|
||||
// Insert note document (source_type='note', source_id=note.id)
|
||||
// Delete the note row directly (simulating a non-sweep deletion path)
|
||||
// Assert: document row for that note is gone (trigger fired)
|
||||
// Assert: dirty_sources entry (if any) is gone
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_023_note_system_flip_trigger_cleans_document() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Setup: project, issue, discussion, non-system note with a document
|
||||
// UPDATE notes SET is_system = 1 WHERE id = note_id
|
||||
// Assert: document row for that note is gone (trigger fired)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_023_system_note_delete_trigger_does_not_fire() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
// Setup: system note (is_system = 1) — no document exists
|
||||
// Delete the system note row
|
||||
// Assert: no error (trigger WHEN clause skips system notes)
|
||||
}
|
||||
```
|
||||
**Note:** This is a data-only migration — no schema changes. It's safe to run on empty databases (no notes = no-op). On databases with existing notes, it queues them for document generation on the next `lore generate-docs` or `lore sync` run.
|
||||
|
||||
---
|
||||
|
||||
@@ -1727,7 +1607,7 @@ fn test_note_document_inherits_parent_labels() {
|
||||
link_issue_label(&conn, 1, 1);
|
||||
link_issue_label(&conn, 1, 2);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(&conn, 1, 100, 1, Some("jdefting"), Some("Comment"), 1000, false, None, None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Hello"), 1710460800000, false, None, None);
|
||||
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert_eq!(doc.labels, vec!["backend", "security"]);
|
||||
@@ -1740,7 +1620,7 @@ fn test_note_document_mr_labels() {
|
||||
insert_label(&conn, 1, "review");
|
||||
link_mr_label(&conn, 1, 1);
|
||||
insert_discussion(&conn, 1, "MergeRequest", None, Some(1));
|
||||
insert_note(&conn, 1, 100, 1, Some("reviewer"), Some("LGTM"), 1000, false, None, None);
|
||||
insert_note(&conn, 1, 100, 1, Some("reviewer"), Some("LGTM"), 1710460800000, false, None, None);
|
||||
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert_eq!(doc.labels, vec!["review"]);
|
||||
@@ -1751,7 +1631,7 @@ fn test_note_document_system_note_returns_none() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(&conn, 1, 100, 1, Some("bot"), Some("assigned to @alice"), 1000, true, None, None);
|
||||
insert_note(&conn, 1, 100, 1, Some("bot"), Some("assigned to @alice"), 1710460800000, true, None, None);
|
||||
|
||||
let result = extract_note_document(&conn, 1).unwrap();
|
||||
assert!(result.is_none());
|
||||
@@ -1770,7 +1650,7 @@ fn test_note_document_orphaned_discussion() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(&conn, 99, 10, Some("Deleted"), None, "opened", None, None);
|
||||
insert_discussion(&conn, 1, "Issue", Some(99), None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Hello"), 1000, false, None, None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Hello"), 1710460800000, false, None, None);
|
||||
conn.execute("PRAGMA foreign_keys = OFF", []).unwrap();
|
||||
conn.execute("DELETE FROM issues WHERE id = 99", []).unwrap();
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
@@ -1784,7 +1664,7 @@ fn test_note_document_hash_deterministic() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Comment"), 1000, false, None, None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some("Comment"), 1710460800000, false, None, None);
|
||||
|
||||
let doc1 = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
let doc2 = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
@@ -1798,10 +1678,11 @@ fn test_note_document_empty_body() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some(""), 1000, false, None, None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), Some(""), 1710460800000, false, None, None);
|
||||
|
||||
// Should still produce a document (body is optional in schema)
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert!(doc.content_text.contains("--- Body ---"));
|
||||
assert!(doc.content_text.contains("[[Note]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1809,7 +1690,7 @@ fn test_note_document_null_body() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(&conn, 1, 10, Some("Test"), Some("desc"), "opened", None, None);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), None, 1000, false, None, None);
|
||||
insert_note(&conn, 1, 100, 1, Some("alice"), None, 1710460800000, false, None, None);
|
||||
|
||||
// Should still produce a document (body is optional in schema)
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
@@ -1864,6 +1745,7 @@ pub fn extract_note_document(
|
||||
// parent_title: {title}
|
||||
// note_type: {DiffNote|DiscussionNote|Comment}
|
||||
// author: @{author}
|
||||
// author_id: {author_id} (only if non-null)
|
||||
// created_at: {iso8601}
|
||||
// resolved: {true|false} (only if resolvable)
|
||||
// path: {position_new_path}:{position_new_line} (only if DiffNote)
|
||||
@@ -1991,7 +1873,7 @@ if !note.is_system && outcome.changed_semantics {
|
||||
|
||||
### Work Chunk 2E: Generate-Docs Full Rebuild Support
|
||||
|
||||
**Files:** Search for where `generate-docs --full` seeds the dirty queue
|
||||
**Files:** Search for where `robot-docs` manifest is generated (search for `robot-docs` or `RobotDocs` command handler)
|
||||
|
||||
**Depends on:** Work Chunk 2D
|
||||
|
||||
@@ -2042,7 +1924,7 @@ ON CONFLICT(source_type, source_id) DO UPDATE SET
|
||||
|
||||
**Files:** `src/cli/mod.rs`, `src/cli/commands/search.rs` (display code)
|
||||
|
||||
**Depends on:** Work Chunks 2A-2D (documents must exist to be searched)
|
||||
**Depends on:** Work Chunks 2A-2E (documents must exist to be searched)
|
||||
|
||||
#### Tests to Write First
|
||||
|
||||
@@ -2337,6 +2219,8 @@ lore notes --resolution unresolved # Tri-state resolution filter
|
||||
lore notes --contains "unwrap" --note-type DiffNote # Body substring + type filter
|
||||
lore notes --author jdefting --format jsonl | wc -l # JSONL streaming
|
||||
lore notes --format csv > /tmp/notes.csv && head -1 /tmp/notes.csv # CSV header
|
||||
lore -J notes --author-id 12345 --since 365d # Immutable identity filter
|
||||
lore -J notes --author-id 12345 --author jdefting # Combined: both must match (AND)
|
||||
lore -J notes --gitlab-note-id 12345 # Precision filter: exact GitLab note
|
||||
lore -J notes --discussion-id 42 # Precision filter: all notes in thread
|
||||
|
||||
@@ -2408,6 +2292,15 @@ WHERE n.is_system = 0 AND d.issue_id = (SELECT id FROM issues WHERE iid = 42 AND
|
||||
ORDER BY n.created_at DESC, n.id DESC
|
||||
LIMIT 50;"
|
||||
# Should show SEARCH using idx_discussions_issue_id for the join
|
||||
|
||||
sqlite3 ~/.local/share/lore/lore.db "EXPLAIN QUERY PLAN
|
||||
SELECT n.id FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON n.project_id = p.id
|
||||
WHERE n.is_system = 0 AND n.project_id = 1 AND n.position_new_path LIKE 'src/auth/%' ESCAPE '\' AND n.created_at >= 1704067200000
|
||||
ORDER BY n.created_at DESC, n.id DESC
|
||||
LIMIT 50;"
|
||||
# Should show SEARCH using idx_notes_project_path_created
|
||||
```
|
||||
|
||||
Operational checks:
|
||||
@@ -2503,16 +2396,16 @@ These recommendations were proposed during review and deliberately rejected. Doc
|
||||
|
||||
- **Compact/slim metadata header for note documents** — rejected because the verbose key-value header is intentional. The structured fields (`source_type`, `note_gitlab_id`, `project`, `parent_type`, `parent_iid`, etc.) are what enable precise FTS and embedding search for queries like "jdefting's comments on authentication issues in project-one." The compact format (`@author on Issue#42 in project`) loses machine-parseable structure and reduces search precision. Metadata stored in document columns/labels/paths is not searchable via FTS — only `content_text` is FTS-indexed. The token cost of the header (~50 tokens) is negligible compared to typical note body length.
|
||||
|
||||
- **Replace IID filter subqueries with JOIN predicates** — rejected because the subquery approach (`d.issue_id = (SELECT id FROM issues WHERE iid = ? AND project_id = ?)`) is clearer about intent and the performance difference is negligible. The subquery hits a UNIQUE index for a single-row lookup. The JOIN alternative (`i.iid = ? AND i.project_id = ?`) requires the query planner to choose the right join order, and the LEFT JOIN is already present for fetching parent metadata. Adding a WHERE clause on a LEFT JOINed table that may have NULL values for non-matching rows introduces subtle correctness risks. The subquery is self-contained and correct by construction.
|
||||
|
||||
- **Use `notes.gitlab_id` instead of `notes.id` as document `source_id`** (feedback-4, rec #1) — rejected because the entire existing document pipeline uses local row IDs as `source_id` for issues, MRs, and discussions. Switching to `gitlab_id` only for notes would create an inconsistent pattern where note documents use a different identity scheme than every other document type. This inconsistency would complicate the regenerator (which dispatches by `source_type` + `source_id`), the dirty tracker, and the full-rebuild seeder. Phase 0 specifically stabilizes local IDs via upsert, making them reliable for this purpose. If we ever want to move to `gitlab_id` globally, that's a cross-cutting migration affecting all source types — not a per-type decision.
|
||||
|
||||
- **`--aggregate` analytics mode for `lore notes`** (feedback-4, rec #5) — rejected because it's scope creep that edges into the explicitly excluded "reviewer profile" non-goal. The raw note output in JSONL/CSV format already supports downstream analysis via `jq`, `awk`, or LLM ingestion. Adding `--aggregate author|note_type|path|resolution` with `--top N` introduces a new query mode, output format, and interaction model. This belongs in a follow-up PRD focused on analytics primitives, not in the per-note search infrastructure PRD.
|
||||
|
||||
- **Source-type fairness / weighted scheduling in dirty queue processing** (feedback-4, rec #6) — rejected because the dirty queue is processed by a single-user CLI tool, not a multi-tenant service. The backfill of ~8k notes is a one-time event after upgrade. After the initial backfill, incremental syncs produce proportional dirty counts across source types. Adding weighted bucket scheduling (issue:3, MR:3, discussion:2, note:1) for a CLI that runs `generate-docs` on demand is premature optimization. If queue starvation becomes a real problem, we can add round-robin by source type then — but it hasn't happened with 2,800 documents and won't happen with 10,800.
|
||||
|
||||
- **Replace `fetch_complete: bool` with `FetchState` enum (`Complete`/`Partial`/`Failed`) and run_seen_at monotonicity checks** (feedback-5, rec #2) — rejected because the boolean captures the one bit of information that matters: did the fetch complete? `FetchState::Failed` is redundant with not reaching the sweep call site — if the fetch fails, we don't call sweep at all. The monotonicity check on `run_seen_at` adds complexity for a condition that can't occur in practice: `run_seen_at` is generated once per sync run and passed unchanged through all upserts. The boolean is sufficient and self-documenting.
|
||||
|
||||
- **Embedding dedup cache keyed by semantic text hash** (feedback-5, rec #5) — rejected because the existing `content_hash` dedup already prevents re-embedding unchanged documents. A semantic-text-only hash that ignores metadata would conflate genuinely different review contexts: two "LGTM" notes from different authors on different MRs are semantically distinct for the reviewer profiling use case (who said it, where, and when matters). The embedding pipeline handles ~8k notes comfortably without dedup optimization.
|
||||
|
||||
- **Derived review signal labels (`signal:nit`, `signal:blocking`, `signal:security`)** (feedback-5, rec #6) — rejected because (a) it encroaches on the explicitly excluded reviewer profiling scope, (b) heuristic signal derivation (regex for "nit:", keyword matching for "security") is inherently fragile and would require ongoing maintenance as review vocabulary evolves, and (c) the raw note text already supports downstream LLM-based analysis that produces far more accurate signal classification than static keyword matching. This belongs in the downstream profiling PRD where LLM-based classification can be done properly.
|
||||
|
||||
- **Replace `last_seen_at` sweep marker with monotonic `sync_run_id`** (feedback-6, rec #3) — rejected because it introduces a new `sync_runs` table, a new column (`last_seen_run_id`), and changes sweep mechanics across both issue and MR ingestion paths. The `last_seen_at` approach is already battle-tested in the MR discussion path and works correctly for a single-user local CLI. Clock skew isn't a real concern when the same process generates timestamps within a single sync run. The engineering cost (new table, migration, plumbing through all callers) far exceeds the theoretical risk it mitigates.
|
||||
|
||||
- **Materialize stale-note set with temp table during sweep** (feedback-6, rec #4) — rejected because the subquery `SELECT id FROM notes WHERE discussion_id = ? AND last_seen_at < ?` runs against a UNIQUE(gitlab_id) index and SQLite's query optimizer handles repeated identical subqueries efficiently. Adding `CREATE TEMP TABLE` / `DROP TABLE` DDL adds transaction complexity for negligible performance gain on typical thread sizes (< 100 notes per discussion). The defense-in-depth triggers from Work Chunk 2A already guarantee consistency even if a subquery somehow produced different results across statements (which it can't within a transaction).
|
||||
|
||||
- **Move historical note backfill from migration to resumable runtime job** (feedback-6, rec #5) — rejected because the migration backfill is a single `INSERT...SELECT` that seeds dirty_sources from the notes table. On 8k notes, this executes in under a second on SQLite. Moving it to a runtime job adds resumability state tracking, a new code path in `generate-docs`/`sync`, and the risk that users forget to run the backfill. The migration approach is simpler, atomic, runs exactly once, and is guaranteed to execute on upgrade. If the note count were 1M+, runtime batching would be justified — at 8k it's premature.
|
||||
|
||||
- **Property/invariant tests with proptest** (feedback-6, rec #8) — rejected because the plan already has extensive example-based tests covering all four invariants mentioned (stable local IDs across re-syncs, no orphan documents after sweeps, partial-fetch safety, idempotent full rebuilds). Adding `proptest` as a dependency for randomized testing introduces nondeterministic CI behavior, slower test runs, and harder-to-debug failures. The deterministic example-based tests provide equivalent coverage with better debuggability. If specific invariants prove fragile in practice, targeted property tests can be added later — but speculative fuzz testing at plan time is premature.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
ALTER TABLE issues ADD COLUMN closed_at TEXT;
|
||||
ALTER TABLE issues ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (23, strftime('%s', 'now') * 1000, 'Add closed_at and confidential to issues');
|
||||
@@ -1,250 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
@@ -1,134 +0,0 @@
|
||||
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
|
||||
title: ""
|
||||
status: iterating
|
||||
iteration: 6
|
||||
iteration: 5
|
||||
target_iterations: 8
|
||||
beads_revision: 1
|
||||
related_plans: []
|
||||
created: 2026-02-08
|
||||
updated: 2026-02-12
|
||||
updated: 2026-02-09
|
||||
---
|
||||
|
||||
# Time-Decay Expert Scoring Model
|
||||
@@ -70,8 +70,7 @@ 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
|
||||
2. **`src/cli/commands/who.rs`** — Core changes:
|
||||
- Add `half_life_decay()` pure function
|
||||
- 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
|
||||
- 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
|
||||
- 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)
|
||||
- Split reviewer signal into participated vs assigned-only
|
||||
@@ -107,10 +106,10 @@ pub struct ScoringConfig {
|
||||
```
|
||||
|
||||
**Config validation**: Add a `validate_scoring()` call in `Config::load_from_path()` after deserialization:
|
||||
- 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 `*_half_life_days` must be > 0 (prevents division by zero in decay function)
|
||||
- All `*_weight` / `*_bonus` must be >= 0 (negative weights produce nonsensical scores)
|
||||
- `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 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)
|
||||
- `closed_mr_multiplier` must be in `(0.0, 1.0]` (0 would discard closed MRs entirely; >1 would over-weight them)
|
||||
- `reviewer_min_note_chars` must be >= 0 (0 disables the filter; typical useful values: 10-50)
|
||||
- `excluded_usernames` entries must be non-empty strings (no blank entries)
|
||||
- Return `LoreError::ConfigInvalid` with a clear message on failure
|
||||
|
||||
@@ -127,9 +126,9 @@ fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
|
||||
|
||||
### 3. SQL Restructure (who.rs)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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)`.
|
||||
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)`.
|
||||
|
||||
```sql
|
||||
WITH matched_notes_raw AS (
|
||||
@@ -178,24 +177,6 @@ matched_file_changes AS (
|
||||
SELECT DISTINCT merge_request_id, project_id
|
||||
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 (
|
||||
-- Precompute which (mr_id, username) pairs have substantive DiffNote participation.
|
||||
-- Materialized once, then joined against mr_reviewers to classify.
|
||||
@@ -204,20 +185,17 @@ reviewer_participation AS (
|
||||
-- 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
|
||||
-- (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
|
||||
FROM matched_notes mn
|
||||
JOIN discussions d ON mn.discussion_id = d.id
|
||||
JOIN notes n_body ON mn.id = n_body.id
|
||||
WHERE d.merge_request_id IS NOT NULL
|
||||
AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6
|
||||
AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= {reviewer_min_note_chars}
|
||||
),
|
||||
raw AS (
|
||||
-- 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,
|
||||
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
|
||||
m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at, m.state AS mr_state
|
||||
FROM matched_notes mn
|
||||
JOIN discussions d ON mn.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
@@ -227,10 +205,8 @@ raw AS (
|
||||
UNION ALL
|
||||
|
||||
-- Signal 2: DiffNote MR author
|
||||
-- Computes state_mult inline (same reason as signal 1).
|
||||
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,
|
||||
CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
|
||||
m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at, m.state AS mr_state
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN matched_notes mn ON mn.discussion_id = d.id
|
||||
@@ -240,59 +216,65 @@ raw AS (
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Signal 3: MR author via file changes (uses mr_activity CTE for timestamp + state_mult)
|
||||
SELECT a.author_username AS username, 'file_author' AS signal,
|
||||
a.mr_id, NULL AS note_id,
|
||||
a.activity_ts AS seen_at, a.state_mult
|
||||
FROM mr_activity a
|
||||
WHERE a.author_username IS NOT NULL
|
||||
AND a.activity_ts >= ?2
|
||||
AND a.activity_ts < ?4
|
||||
-- Signal 3: MR author via file changes (state-aware timestamp)
|
||||
SELECT m.author_username AS username, 'file_author' AS signal,
|
||||
m.id AS mr_id, NULL AS note_id,
|
||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
||||
FROM matched_file_changes mfc
|
||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
||||
WHERE m.author_username IS NOT NULL
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND {state_aware_ts} >= ?2
|
||||
AND {state_aware_ts} < ?4
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path)
|
||||
SELECT r.username AS username, 'file_reviewer_participated' AS signal,
|
||||
a.mr_id, NULL AS note_id,
|
||||
a.activity_ts AS seen_at, a.state_mult
|
||||
FROM mr_activity a
|
||||
JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
|
||||
JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
|
||||
m.id AS mr_id, NULL AS note_id,
|
||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
||||
FROM matched_file_changes mfc
|
||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||
JOIN reviewer_participation rp ON rp.mr_id = m.id AND rp.username = r.username
|
||||
WHERE r.username IS NOT NULL
|
||||
AND (a.author_username IS NULL OR r.username != a.author_username)
|
||||
AND a.activity_ts >= ?2
|
||||
AND a.activity_ts < ?4
|
||||
AND (m.author_username IS NULL OR r.username != m.author_username)
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND {state_aware_ts} >= ?2
|
||||
AND {state_aware_ts} < ?4
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path)
|
||||
SELECT r.username AS username, 'file_reviewer_assigned' AS signal,
|
||||
a.mr_id, NULL AS note_id,
|
||||
a.activity_ts AS seen_at, a.state_mult
|
||||
FROM mr_activity a
|
||||
JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
|
||||
LEFT JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
|
||||
m.id AS mr_id, NULL AS note_id,
|
||||
{state_aware_ts} AS seen_at, m.state AS mr_state
|
||||
FROM matched_file_changes mfc
|
||||
JOIN merge_requests m ON mfc.merge_request_id = m.id
|
||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||
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
|
||||
AND r.username IS NOT NULL
|
||||
AND (a.author_username IS NULL OR r.username != a.author_username)
|
||||
AND a.activity_ts >= ?2
|
||||
AND a.activity_ts < ?4
|
||||
AND (m.author_username IS NULL OR r.username != m.author_username)
|
||||
AND m.state IN ('opened','merged','closed')
|
||||
AND {state_aware_ts} >= ?2
|
||||
AND {state_aware_ts} < ?4
|
||||
),
|
||||
aggregated AS (
|
||||
-- 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, MAX(state_mult) AS state_mult
|
||||
SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, mr_state
|
||||
FROM raw WHERE signal != 'diffnote_reviewer'
|
||||
GROUP BY username, signal, mr_id
|
||||
UNION ALL
|
||||
-- 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, MAX(state_mult) AS state_mult
|
||||
SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts, mr_state
|
||||
FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL
|
||||
GROUP BY username, mr_id
|
||||
)
|
||||
SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE username IS NOT NULL
|
||||
SELECT username, signal, mr_id, qty, ts, mr_state FROM aggregated WHERE username IS NOT NULL
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -326,21 +308,7 @@ 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 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).
|
||||
|
||||
**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.
|
||||
### 3a. Path Resolution Probes (who.rs)
|
||||
|
||||
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.
|
||||
|
||||
@@ -369,29 +337,39 @@ 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.
|
||||
|
||||
**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.
|
||||
**State-aware timestamps for file-change signals (signals 3, 4a, 4b)**: Replace `m.updated_at` with a state-aware expression:
|
||||
|
||||
```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.
|
||||
|
||||
### 4. Rust-Side Aggregation (who.rs)
|
||||
|
||||
For each username, accumulate into a struct with:
|
||||
- **Author MRs**: `HashMap<i64, (i64, f64)>` (mr_id -> (max timestamp, state_mult)) from `diffnote_author` + `file_author` signals
|
||||
- **Reviewer Participated MRs**: `HashMap<i64, (i64, f64)>` from `diffnote_reviewer` + `file_reviewer_participated` signals
|
||||
- **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, 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.
|
||||
- **Author MRs**: `HashMap<i64, (i64, String)>` (mr_id -> (max timestamp, mr_state)) from `diffnote_author` + `file_author` signals
|
||||
- **Reviewer Participated MRs**: `HashMap<i64, (i64, String)>` 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)
|
||||
- **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.
|
||||
- **Last seen**: max of all timestamps
|
||||
- **Components** (when `--explain-score`): Track per-component f64 subtotals for `author`, `reviewer_participated`, `reviewer_assigned`, `notes`
|
||||
|
||||
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.
|
||||
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"`.
|
||||
|
||||
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):
|
||||
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"`:
|
||||
```
|
||||
state_mult(mr) = if mr.state == "closed" { closed_mr_multiplier } else { 1.0 }
|
||||
|
||||
raw_score =
|
||||
sum(author_weight * state_mult * decay(now - ts, author_hl) for (mr, ts, state_mult) in author_mrs)
|
||||
+ sum(reviewer_weight * state_mult * decay(now - ts, reviewer_hl) for (mr, ts, state_mult) in reviewer_participated)
|
||||
+ 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 * log2(1 + count) * decay(now - ts, note_hl) for (mr, count, ts, state_mult) in notes_per_mr)
|
||||
sum(author_weight * state_mult(mr) * decay(now - ts, author_hl) for (mr, ts) in author_mrs)
|
||||
+ sum(reviewer_weight * state_mult(mr) * decay(now - ts, reviewer_hl) for (mr, ts) in reviewer_participated)
|
||||
+ sum(reviewer_assignment_weight * state_mult(mr) * decay(now - ts, reviewer_assignment_hl) for (mr, ts) 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)
|
||||
```
|
||||
|
||||
**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.
|
||||
@@ -480,16 +458,9 @@ CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
||||
ON notes(discussion_id, author_username, created_at)
|
||||
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. 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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -555,14 +526,6 @@ 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_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)
|
||||
|
||||
**`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.
|
||||
@@ -591,8 +554,6 @@ 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_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 `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
|
||||
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
|
||||
@@ -610,7 +571,6 @@ 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)
|
||||
- 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.
|
||||
- Test path normalization: `who --path ./src//foo.rs` and `who --path src/foo.rs` should produce identical results
|
||||
|
||||
## Accepted from External Review
|
||||
|
||||
@@ -654,14 +614,6 @@ 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.
|
||||
- **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)
|
||||
|
||||
These suggestions were considered during review but explicitly excluded from this iteration:
|
||||
@@ -683,6 +635,3 @@ 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.
|
||||
- **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.
|
||||
- **Epsilon-based tie buckets for ranking** (feedback-6 #4) — rejected because the plan already has deterministic contribution ordering by `mr_id` within each signal type, which eliminates HashMap-iteration nondeterminism. Platform-dependent `powf` differences at the scale of dozens to hundreds of contributions per user are sub-epsilon (order of 1e-15). If two users genuinely score within 1e-9 of each other, the existing tiebreak by `(last_seen DESC, username ASC)` is already meaningful and deterministic. Adding a bucketing layer introduces a magic epsilon constant and floor operation for a problem that doesn't manifest in practice.
|
||||
- **`--diagnose-score` aggregated diagnostics flag** (feedback-6 #5) — rejected because this is diagnostic/debugging tooling that adds a new flag, new output format, and new counting logic (matched_notes_raw_count, dedup_count, window exclusions, etc.) across the SQL pipeline. The existing `--explain-score` component breakdown + manual EXPLAIN QUERY PLAN verification already covers the debugging need. The additional SQL instrumentation required (counting rows at each CTE stage) would complicate the query for a feature with unclear demand. A v2 addition if operational debugging becomes a recurring need.
|
||||
- **Multi-path expert scoring (`--path` repeatable)** (feedback-6 #7) — rejected because this is a feature expansion, not a plan improvement for the time-decay model. Multi-path requires a `requested_paths` CTE, modified dedup logic keyed on `(username, signal, mr_id)` across paths, CLI parsing changes for repeatable `--path` and `--path-file`, and new test cases for overlap/prefix/dedup semantics. This is a separate bead/feature that should be designed independently — it's orthogonal to time-decay scoring and can be added later without requiring any changes to the decay model.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
plan: true
|
||||
title: "Gitlore TUI PRD v2 - FrankenTUI"
|
||||
status: iterating
|
||||
iteration: 11
|
||||
iteration: 10
|
||||
target_iterations: 10
|
||||
beads_revision: 0
|
||||
related_plans: []
|
||||
@@ -135,7 +135,7 @@ We are making a deliberate bet that FrankenTUI's technical superiority justifies
|
||||
| Runtime panic leaves user blocked | High | Medium | Panic hook captures crash context (last 2000 events ring buffer + screen/nav/task/build/db snapshot), restores terminal, offers fallback CLI command. Retention: latest 20 crash files, oldest auto-pruned. |
|
||||
| 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). |
|
||||
| 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. |
|
||||
| Malicious URL in entity data opened in browser | Medium | Low | URL host validated against configured GitLab instance before `open`/`xdg-open` |
|
||||
| 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
|
||||
@@ -2923,7 +2923,7 @@ Ensures the TUI displays the same data as the CLI robot mode, preventing drift b
|
||||
|
||||
**Success criterion:** Parity suite passes on CI fixtures (S and M tiers). Parity is asserted by field-level comparison, not string formatting comparison — the TUI and CLI may format differently but must present the same underlying data.
|
||||
|
||||
**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).
|
||||
**Total estimated scope:** ~49 implementation days across 9 phases (increased from ~47 to account for filter DSL parser, render cache, progress coalescer, Quick Peek panel, ReaderLease interrupt handles, and generation-guarding all async Msg variants).
|
||||
|
||||
### 9.3 Phase 0 — Toolchain Gate
|
||||
|
||||
@@ -2969,8 +2969,6 @@ 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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -3517,56 +3515,25 @@ pub fn sanitize_for_terminal(input: &str) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
/// Classify a URL's safety level against the configured GitLab origin(s) and
|
||||
/// known entity path patterns before opening in browser.
|
||||
/// Returns tri-state: AllowedEntityPath (open immediately), AllowedButUnrecognizedPath
|
||||
/// (prompt user to confirm), or Blocked (refuse to open).
|
||||
pub fn classify_safe_url(url: &str, policy: &UrlPolicy) -> UrlSafety {
|
||||
let Ok(parsed) = url::Url::parse(url) else { return UrlSafety::Blocked };
|
||||
/// Validate a URL against the configured GitLab origin(s) before opening.
|
||||
/// Enforces scheme + normalized host + port match to prevent deceptive variants
|
||||
/// (e.g., IDN homograph attacks, unexpected port redirects).
|
||||
pub fn is_safe_url(url: &str, allowed_origins: &[AllowedOrigin]) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(url) else { return false };
|
||||
|
||||
// Only allow HTTPS
|
||||
if parsed.scheme() != "https" { return UrlSafety::Blocked; }
|
||||
if parsed.scheme() != "https" { return false; }
|
||||
|
||||
// Normalize host (lowercase, IDNA-compatible) and match scheme+host+port
|
||||
let Some(host) = parsed.host_str() else { return UrlSafety::Blocked; };
|
||||
let Some(host) = parsed.host_str() else { return false; };
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
let port = parsed.port_or_known_default();
|
||||
|
||||
let origin_match = policy.allowed_origins.iter().any(|origin| {
|
||||
allowed_origins.iter().any(|origin| {
|
||||
origin.scheme == "https"
|
||||
&& origin.host == host_lower
|
||||
&& 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).
|
||||
@@ -7975,9 +7942,3 @@ Recommendations from external review (feedback-9, ChatGPT) that were evaluated a
|
||||
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.
|
||||
|
||||
@@ -185,7 +185,6 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--no-detail",
|
||||
],
|
||||
),
|
||||
("drift", &["--threshold", "--project"]),
|
||||
(
|
||||
"init",
|
||||
&[
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use console::style;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::robot::RobotMeta;
|
||||
use crate::core::config::Config;
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::ms_to_iso;
|
||||
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||
use crate::embedding::similarity::cosine_similarity;
|
||||
|
||||
const BATCH_SIZE: usize = 32;
|
||||
const WINDOW_SIZE: usize = 3;
|
||||
const MIN_DESCRIPTION_LEN: usize = 20;
|
||||
const MAX_NOTES: i64 = 200;
|
||||
const TOP_TOPICS: usize = 3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DriftResponse {
|
||||
pub entity: DriftEntity,
|
||||
pub drift_detected: bool,
|
||||
pub threshold: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drift_point: Option<DriftPoint>,
|
||||
pub drift_topics: Vec<String>,
|
||||
pub similarity_curve: Vec<SimilarityPoint>,
|
||||
pub recommendation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DriftEntity {
|
||||
pub entity_type: String,
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DriftPoint {
|
||||
pub note_index: usize,
|
||||
pub note_id: i64,
|
||||
pub author: String,
|
||||
pub created_at: String,
|
||||
pub similarity: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SimilarityPoint {
|
||||
pub note_index: usize,
|
||||
pub similarity: f32,
|
||||
pub author: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal row types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct IssueInfo {
|
||||
id: i64,
|
||||
iid: i64,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
struct NoteRow {
|
||||
id: i64,
|
||||
body: String,
|
||||
author_username: String,
|
||||
created_at: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn run_drift(
|
||||
config: &Config,
|
||||
entity_type: &str,
|
||||
iid: i64,
|
||||
threshold: f32,
|
||||
project: Option<&str>,
|
||||
) -> Result<DriftResponse> {
|
||||
if entity_type != "issues" {
|
||||
return Err(LoreError::Other(
|
||||
"drift currently supports 'issues' only".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
let issue = find_issue(&conn, iid, project)?;
|
||||
|
||||
let description = match &issue.description {
|
||||
Some(d) if d.len() >= MIN_DESCRIPTION_LEN => d.clone(),
|
||||
_ => {
|
||||
return Ok(DriftResponse {
|
||||
entity: DriftEntity {
|
||||
entity_type: entity_type.to_string(),
|
||||
iid: issue.iid,
|
||||
title: issue.title,
|
||||
},
|
||||
drift_detected: false,
|
||||
threshold,
|
||||
drift_point: None,
|
||||
drift_topics: vec![],
|
||||
similarity_curve: vec![],
|
||||
recommendation: "Description too short for drift analysis.".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let notes = fetch_notes(&conn, issue.id)?;
|
||||
|
||||
if notes.len() < WINDOW_SIZE {
|
||||
return Ok(DriftResponse {
|
||||
entity: DriftEntity {
|
||||
entity_type: entity_type.to_string(),
|
||||
iid: issue.iid,
|
||||
title: issue.title,
|
||||
},
|
||||
drift_detected: false,
|
||||
threshold,
|
||||
drift_point: None,
|
||||
drift_topics: vec![],
|
||||
similarity_curve: vec![],
|
||||
recommendation: format!(
|
||||
"Only {} note(s) found; need at least {} for drift detection.",
|
||||
notes.len(),
|
||||
WINDOW_SIZE
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Build texts to embed: description first, then each note body.
|
||||
let mut texts: Vec<String> = Vec::with_capacity(1 + notes.len());
|
||||
texts.push(description.clone());
|
||||
for note in ¬es {
|
||||
texts.push(note.body.clone());
|
||||
}
|
||||
|
||||
let embeddings = embed_texts(config, &texts).await?;
|
||||
|
||||
let desc_embedding = &embeddings[0];
|
||||
let note_embeddings = &embeddings[1..];
|
||||
|
||||
// Build similarity curve.
|
||||
let similarity_curve: Vec<SimilarityPoint> = note_embeddings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, emb)| SimilarityPoint {
|
||||
note_index: i,
|
||||
similarity: cosine_similarity(desc_embedding, emb),
|
||||
author: notes[i].author_username.clone(),
|
||||
created_at: ms_to_iso(notes[i].created_at),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Detect drift via sliding window.
|
||||
let (drift_detected, drift_point) = detect_drift(&similarity_curve, ¬es, threshold);
|
||||
|
||||
// Extract drift topics.
|
||||
let drift_topics = if drift_detected {
|
||||
let drift_idx = drift_point.as_ref().map_or(0, |dp| dp.note_index);
|
||||
extract_drift_topics(&description, ¬es, drift_idx)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let recommendation = if drift_detected {
|
||||
let dp = drift_point.as_ref().unwrap();
|
||||
format!(
|
||||
"Discussion drifted at note {} by @{} (similarity {:.2}). Consider splitting into a new issue.",
|
||||
dp.note_index, dp.author, dp.similarity
|
||||
)
|
||||
} else {
|
||||
"Discussion remains on topic.".to_string()
|
||||
};
|
||||
|
||||
Ok(DriftResponse {
|
||||
entity: DriftEntity {
|
||||
entity_type: entity_type.to_string(),
|
||||
iid: issue.iid,
|
||||
title: issue.title,
|
||||
},
|
||||
drift_detected,
|
||||
threshold,
|
||||
drift_point,
|
||||
drift_topics,
|
||||
similarity_curve,
|
||||
recommendation,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn find_issue(
|
||||
conn: &rusqlite::Connection,
|
||||
iid: i64,
|
||||
project_filter: Option<&str>,
|
||||
) -> Result<IssueInfo> {
|
||||
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
|
||||
Some(project) => {
|
||||
let project_id = resolve_project(conn, project)?;
|
||||
(
|
||||
"SELECT i.id, i.iid, i.title, i.description
|
||||
FROM issues i
|
||||
WHERE i.iid = ? AND i.project_id = ?",
|
||||
vec![Box::new(iid), Box::new(project_id)],
|
||||
)
|
||||
}
|
||||
None => (
|
||||
"SELECT i.id, i.iid, i.title, i.description
|
||||
FROM issues i
|
||||
WHERE i.iid = ?",
|
||||
vec![Box::new(iid)],
|
||||
),
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows: Vec<IssueInfo> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok(IssueInfo {
|
||||
id: row.get(0)?,
|
||||
iid: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
match rows.len() {
|
||||
0 => Err(LoreError::NotFound(format!("Issue #{iid} not found"))),
|
||||
1 => Ok(rows.into_iter().next().unwrap()),
|
||||
_ => Err(LoreError::Ambiguous(format!(
|
||||
"Issue #{iid} exists in multiple projects. Use --project to specify."
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_notes(conn: &rusqlite::Connection, issue_id: i64) -> Result<Vec<NoteRow>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.id, n.body, n.author_username, n.created_at
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
WHERE d.issue_id = ?
|
||||
AND n.is_system = 0
|
||||
AND LENGTH(n.body) >= 20
|
||||
ORDER BY n.created_at ASC
|
||||
LIMIT ?",
|
||||
)?;
|
||||
|
||||
let notes: Vec<NoteRow> = stmt
|
||||
.query_map(rusqlite::params![issue_id, MAX_NOTES], |row| {
|
||||
Ok(NoteRow {
|
||||
id: row.get(0)?,
|
||||
body: row.get(1)?,
|
||||
author_username: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedding helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn embed_texts(config: &Config, texts: &[String]) -> Result<Vec<Vec<f32>>> {
|
||||
let ollama = OllamaClient::new(OllamaConfig {
|
||||
base_url: config.embedding.base_url.clone(),
|
||||
model: config.embedding.model.clone(),
|
||||
timeout_secs: 60,
|
||||
});
|
||||
|
||||
let mut all_embeddings: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
|
||||
|
||||
for chunk in texts.chunks(BATCH_SIZE) {
|
||||
let refs: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect();
|
||||
let batch_result = ollama.embed_batch(&refs).await?;
|
||||
all_embeddings.extend(batch_result);
|
||||
}
|
||||
|
||||
Ok(all_embeddings)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drift detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn detect_drift(
|
||||
curve: &[SimilarityPoint],
|
||||
notes: &[NoteRow],
|
||||
threshold: f32,
|
||||
) -> (bool, Option<DriftPoint>) {
|
||||
if curve.len() < WINDOW_SIZE {
|
||||
return (false, None);
|
||||
}
|
||||
|
||||
for i in 0..=curve.len() - WINDOW_SIZE {
|
||||
let window_avg: f32 = curve[i..i + WINDOW_SIZE]
|
||||
.iter()
|
||||
.map(|p| p.similarity)
|
||||
.sum::<f32>()
|
||||
/ WINDOW_SIZE as f32;
|
||||
|
||||
if window_avg < threshold {
|
||||
return (
|
||||
true,
|
||||
Some(DriftPoint {
|
||||
note_index: i,
|
||||
note_id: notes[i].id,
|
||||
author: notes[i].author_username.clone(),
|
||||
created_at: ms_to_iso(notes[i].created_at),
|
||||
similarity: curve[i].similarity,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(false, None)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topic extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static STOPWORDS: LazyLock<std::collections::HashSet<&'static str>> = LazyLock::new(|| {
|
||||
[
|
||||
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
|
||||
"do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can",
|
||||
"need", "dare", "ought", "used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
|
||||
"as", "into", "through", "during", "before", "after", "above", "below", "between", "out",
|
||||
"off", "over", "under", "again", "further", "then", "once", "here", "there", "when",
|
||||
"where", "why", "how", "all", "each", "every", "both", "few", "more", "most", "other",
|
||||
"some", "such", "no", "not", "only", "own", "same", "so", "than", "too", "very", "just",
|
||||
"because", "but", "and", "or", "if", "while", "about", "up", "it", "its", "this", "that",
|
||||
"these", "those", "i", "me", "my", "we", "our", "you", "your", "he", "him", "his", "she",
|
||||
"her", "they", "them", "their", "what", "which", "who", "whom", "also", "like", "get",
|
||||
"got", "think", "know", "see", "make", "go", "one", "two", "new", "way",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
});
|
||||
|
||||
fn tokenize(text: &str) -> Vec<String> {
|
||||
let cleaned = strip_markdown(text);
|
||||
cleaned
|
||||
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||
.filter(|w| w.len() >= 3)
|
||||
.map(|w| w.to_lowercase())
|
||||
.filter(|w| !STOPWORDS.contains(w.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_drift_topics(description: &str, notes: &[NoteRow], drift_idx: usize) -> Vec<String> {
|
||||
let desc_terms: std::collections::HashSet<String> = tokenize(description).into_iter().collect();
|
||||
|
||||
let mut freq: HashMap<String, usize> = HashMap::new();
|
||||
for note in notes.iter().skip(drift_idx) {
|
||||
for term in tokenize(¬e.body) {
|
||||
if !desc_terms.contains(&term) {
|
||||
*freq.entry(term).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut sorted: Vec<(String, usize)> = freq.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
sorted
|
||||
.into_iter()
|
||||
.take(TOP_TOPICS)
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown stripping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static RE_FENCED_CODE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?s)```[^\n]*\n.*?```").unwrap());
|
||||
static RE_INLINE_CODE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]+`").unwrap());
|
||||
static RE_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap());
|
||||
static RE_BLOCKQUOTE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^>\s?").unwrap());
|
||||
static RE_HTML_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
|
||||
|
||||
fn strip_markdown(text: &str) -> String {
|
||||
let text = RE_FENCED_CODE.replace_all(text, "");
|
||||
let text = RE_INLINE_CODE.replace_all(&text, "");
|
||||
let text = RE_LINK.replace_all(&text, "$1");
|
||||
let text = RE_BLOCKQUOTE.replace_all(&text, "");
|
||||
let text = RE_HTML_TAG.replace_all(&text, "");
|
||||
text.into_owned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Printers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn print_drift_human(response: &DriftResponse) {
|
||||
let header = format!(
|
||||
"Drift Analysis: {} #{}",
|
||||
response.entity.entity_type, response.entity.iid
|
||||
);
|
||||
println!("{}", style(&header).bold());
|
||||
println!("{}", "-".repeat(header.len().min(60)));
|
||||
println!("Title: {}", response.entity.title);
|
||||
println!("Threshold: {:.2}", response.threshold);
|
||||
println!("Notes: {}", response.similarity_curve.len());
|
||||
println!();
|
||||
|
||||
if response.drift_detected {
|
||||
println!("{}", style("DRIFT DETECTED").red().bold());
|
||||
if let Some(dp) = &response.drift_point {
|
||||
println!(
|
||||
" At note #{} by @{} ({}) - similarity {:.2}",
|
||||
dp.note_index, dp.author, dp.created_at, dp.similarity
|
||||
);
|
||||
}
|
||||
if !response.drift_topics.is_empty() {
|
||||
println!(" Topics: {}", response.drift_topics.join(", "));
|
||||
}
|
||||
} else {
|
||||
println!("{}", style("No drift detected").green());
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", response.recommendation);
|
||||
|
||||
if !response.similarity_curve.is_empty() {
|
||||
println!();
|
||||
println!("{}", style("Similarity Curve:").bold());
|
||||
for pt in &response.similarity_curve {
|
||||
let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize;
|
||||
let bar: String = "#".repeat(bar_len);
|
||||
println!(
|
||||
" {:>3} {:.2} {} @{}",
|
||||
pt.note_index, pt.similarity, bar, pt.author
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_drift_json(response: &DriftResponse, elapsed_ms: u64) {
|
||||
let meta = RobotMeta { elapsed_ms };
|
||||
let output = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": response,
|
||||
"meta": meta,
|
||||
});
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_drift_when_divergent() {
|
||||
let notes: Vec<NoteRow> = (0..6)
|
||||
.map(|i| NoteRow {
|
||||
id: i as i64,
|
||||
body: format!("note {i}"),
|
||||
author_username: "user".to_string(),
|
||||
created_at: 1000 + i as i64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let curve: Vec<SimilarityPoint> = [0.9, 0.85, 0.8, 0.25, 0.2, 0.15]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &sim)| SimilarityPoint {
|
||||
note_index: i,
|
||||
similarity: sim,
|
||||
author: "user".to_string(),
|
||||
created_at: ms_to_iso(1000 + i as i64),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (detected, point) = detect_drift(&curve, ¬es, 0.4);
|
||||
assert!(detected);
|
||||
assert!(point.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_drift_consistent() {
|
||||
let notes: Vec<NoteRow> = (0..5)
|
||||
.map(|i| NoteRow {
|
||||
id: i as i64,
|
||||
body: format!("note {i}"),
|
||||
author_username: "user".to_string(),
|
||||
created_at: 1000 + i as i64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let curve: Vec<SimilarityPoint> = [0.85, 0.8, 0.75, 0.7, 0.65]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &sim)| SimilarityPoint {
|
||||
note_index: i,
|
||||
similarity: sim,
|
||||
author: "user".to_string(),
|
||||
created_at: ms_to_iso(1000 + i as i64),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (detected, _) = detect_drift(&curve, ¬es, 0.4);
|
||||
assert!(!detected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drift_point_is_first_divergent() {
|
||||
let notes: Vec<NoteRow> = (0..5)
|
||||
.map(|i| NoteRow {
|
||||
id: (i * 10) as i64,
|
||||
body: format!("note {i}"),
|
||||
author_username: format!("user{i}"),
|
||||
created_at: 1000 + i as i64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Window of 3: indices [0,1,2] avg=0.83, [1,2,3] avg=0.55, [2,3,4] avg=0.23
|
||||
let curve: Vec<SimilarityPoint> = [0.9, 0.8, 0.8, 0.05, 0.05]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &sim)| SimilarityPoint {
|
||||
note_index: i,
|
||||
similarity: sim,
|
||||
author: format!("user{i}"),
|
||||
created_at: ms_to_iso(1000 + i as i64),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (detected, point) = detect_drift(&curve, ¬es, 0.4);
|
||||
assert!(detected);
|
||||
let dp = point.unwrap();
|
||||
// Window [2,3,4] avg = (0.8+0.05+0.05)/3 = 0.3 < 0.4
|
||||
// But [1,2,3] avg = (0.8+0.8+0.05)/3 = 0.55 >= 0.4, so first failing is index 2
|
||||
assert_eq!(dp.note_index, 2);
|
||||
assert_eq!(dp.note_id, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_drift_topics_excludes_description_terms() {
|
||||
let description = "We need to fix the authentication flow for login users";
|
||||
let notes = vec![
|
||||
NoteRow {
|
||||
id: 1,
|
||||
body: "The database migration script is broken and needs postgres update"
|
||||
.to_string(),
|
||||
author_username: "dev".to_string(),
|
||||
created_at: 1000,
|
||||
},
|
||||
NoteRow {
|
||||
id: 2,
|
||||
body: "The database connection pool also has migration issues with postgres"
|
||||
.to_string(),
|
||||
author_username: "dev".to_string(),
|
||||
created_at: 2000,
|
||||
},
|
||||
];
|
||||
|
||||
let topics = extract_drift_topics(description, ¬es, 0);
|
||||
// "database", "migration", "postgres" should appear; "fix" should not (it's in description)
|
||||
assert!(!topics.is_empty());
|
||||
for t in &topics {
|
||||
assert_ne!(t, "fix");
|
||||
assert_ne!(t, "authentication");
|
||||
assert_ne!(t, "login");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_code_blocks() {
|
||||
let input = "Before\n```rust\nfn main() {}\n```\nAfter";
|
||||
let result = strip_markdown(input);
|
||||
assert!(!result.contains("fn main"));
|
||||
assert!(result.contains("Before"));
|
||||
assert!(result.contains("After"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_preserves_text() {
|
||||
let input = "Check [this link](https://example.com) and `inline code` for details";
|
||||
let result = strip_markdown(input);
|
||||
assert!(result.contains("this link"));
|
||||
assert!(!result.contains("https://example.com"));
|
||||
assert!(!result.contains("inline code"));
|
||||
assert!(result.contains("details"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_too_few_notes() {
|
||||
let notes: Vec<NoteRow> = (0..2)
|
||||
.map(|i| NoteRow {
|
||||
id: i as i64,
|
||||
body: format!("note {i}"),
|
||||
author_username: "user".to_string(),
|
||||
created_at: 1000 + i as i64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let curve: Vec<SimilarityPoint> = [0.1, 0.1]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &sim)| SimilarityPoint {
|
||||
note_index: i,
|
||||
similarity: sim,
|
||||
author: "user".to_string(),
|
||||
created_at: ms_to_iso(1000 + i as i64),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (detected, _) = detect_drift(&curve, ¬es, 0.4);
|
||||
assert!(!detected);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod auth_test;
|
||||
pub mod count;
|
||||
pub mod doctor;
|
||||
pub mod drift;
|
||||
pub mod embed;
|
||||
pub mod generate_docs;
|
||||
pub mod ingest;
|
||||
@@ -21,7 +20,6 @@ pub use count::{
|
||||
run_count_events,
|
||||
};
|
||||
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
|
||||
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
|
||||
pub use embed::{print_embed, print_embed_json, run_embed};
|
||||
pub use generate_docs::{print_generate_docs, print_generate_docs_json, run_generate_docs};
|
||||
pub use ingest::{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use console::style;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -10,10 +8,9 @@ use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::{ms_to_iso, parse_since};
|
||||
use crate::documents::SourceType;
|
||||
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||
use crate::search::{
|
||||
FtsQueryMode, HybridResult, PathFilter, SearchFilters, SearchMode, get_result_snippet,
|
||||
search_fts, search_hybrid,
|
||||
FtsQueryMode, PathFilter, SearchFilters, apply_filters, get_result_snippet, rank_rrf,
|
||||
search_fts,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -61,7 +58,7 @@ pub struct SearchCliFilters {
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
pub async fn run_search(
|
||||
pub fn run_search(
|
||||
config: &Config,
|
||||
query: &str,
|
||||
cli_filters: SearchCliFilters,
|
||||
@@ -74,18 +71,15 @@ pub async fn run_search(
|
||||
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
|
||||
let actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid);
|
||||
|
||||
let client = if actual_mode != SearchMode::Lexical {
|
||||
let ollama_cfg = &config.embedding;
|
||||
Some(OllamaClient::new(OllamaConfig {
|
||||
base_url: ollama_cfg.base_url.clone(),
|
||||
model: ollama_cfg.model.clone(),
|
||||
..OllamaConfig::default()
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Determine actual mode: vector search requires embeddings, which need async + Ollama.
|
||||
// Until hybrid/semantic are wired up, we run lexical and warn if the user asked for more.
|
||||
let actual_mode = "lexical";
|
||||
if requested_mode != "lexical" {
|
||||
warnings.push(format!(
|
||||
"Requested mode '{}' is not yet available; falling back to lexical search.",
|
||||
requested_mode
|
||||
));
|
||||
}
|
||||
|
||||
let doc_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
|
||||
@@ -95,7 +89,7 @@ pub async fn run_search(
|
||||
warnings.push("No documents indexed. Run 'lore generate-docs' first.".to_string());
|
||||
return Ok(SearchResponse {
|
||||
query: query.to_string(),
|
||||
mode: actual_mode.as_str().to_string(),
|
||||
mode: actual_mode.to_string(),
|
||||
total_results: 0,
|
||||
results: vec![],
|
||||
warnings,
|
||||
@@ -157,54 +151,52 @@ pub async fn run_search(
|
||||
limit: cli_filters.limit,
|
||||
};
|
||||
|
||||
// Run FTS separately for snippet extraction (search_hybrid doesn't return snippets).
|
||||
let snippet_top_k = filters
|
||||
.clamp_limit()
|
||||
.checked_mul(10)
|
||||
.unwrap_or(500)
|
||||
.clamp(50, 1500);
|
||||
let fts_results = search_fts(&conn, query, snippet_top_k, fts_mode)?;
|
||||
let snippet_map: HashMap<i64, String> = fts_results
|
||||
let requested = filters.clamp_limit();
|
||||
let top_k = if filters.has_any_filter() {
|
||||
(requested * 50).clamp(200, 1500)
|
||||
} else {
|
||||
(requested * 10).clamp(50, 1500)
|
||||
};
|
||||
|
||||
let fts_results = search_fts(&conn, query, top_k, fts_mode)?;
|
||||
let fts_tuples: Vec<(i64, f64)> = fts_results
|
||||
.iter()
|
||||
.map(|r| (r.document_id, r.bm25_score))
|
||||
.collect();
|
||||
|
||||
let snippet_map: std::collections::HashMap<i64, String> = fts_results
|
||||
.iter()
|
||||
.map(|r| (r.document_id, r.snippet.clone()))
|
||||
.collect();
|
||||
|
||||
// search_hybrid handles recall sizing, RRF ranking, and filter application internally.
|
||||
let (hybrid_results, mut hybrid_warnings) = search_hybrid(
|
||||
&conn,
|
||||
client.as_ref(),
|
||||
query,
|
||||
actual_mode,
|
||||
&filters,
|
||||
fts_mode,
|
||||
)
|
||||
.await?;
|
||||
warnings.append(&mut hybrid_warnings);
|
||||
let ranked = rank_rrf(&[], &fts_tuples);
|
||||
let ranked_ids: Vec<i64> = ranked.iter().map(|r| r.document_id).collect();
|
||||
|
||||
if hybrid_results.is_empty() {
|
||||
let filtered_ids = apply_filters(&conn, &ranked_ids, &filters)?;
|
||||
|
||||
if filtered_ids.is_empty() {
|
||||
return Ok(SearchResponse {
|
||||
query: query.to_string(),
|
||||
mode: actual_mode.as_str().to_string(),
|
||||
mode: actual_mode.to_string(),
|
||||
total_results: 0,
|
||||
results: vec![],
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
let ranked_ids: Vec<i64> = hybrid_results.iter().map(|r| r.document_id).collect();
|
||||
let hydrated = hydrate_results(&conn, &ranked_ids)?;
|
||||
let hydrated = hydrate_results(&conn, &filtered_ids)?;
|
||||
|
||||
let hybrid_map: HashMap<i64, &HybridResult> =
|
||||
hybrid_results.iter().map(|r| (r.document_id, r)).collect();
|
||||
let rrf_map: std::collections::HashMap<i64, &crate::search::RrfResult> =
|
||||
ranked.iter().map(|r| (r.document_id, r)).collect();
|
||||
|
||||
let mut results: Vec<SearchResultDisplay> = Vec::with_capacity(hydrated.len());
|
||||
for row in &hydrated {
|
||||
let hr = hybrid_map.get(&row.document_id);
|
||||
let rrf = rrf_map.get(&row.document_id);
|
||||
let fts_snippet = snippet_map.get(&row.document_id).map(|s| s.as_str());
|
||||
let snippet = get_result_snippet(fts_snippet, &row.content_text);
|
||||
|
||||
let explain_data = if explain {
|
||||
hr.map(|r| ExplainData {
|
||||
rrf.map(|r| ExplainData {
|
||||
vector_rank: r.vector_rank,
|
||||
fts_rank: r.fts_rank,
|
||||
rrf_score: r.rrf_score,
|
||||
@@ -225,14 +217,14 @@ pub async fn run_search(
|
||||
labels: row.labels.clone(),
|
||||
paths: row.paths.clone(),
|
||||
snippet,
|
||||
score: hr.map(|r| r.score).unwrap_or(0.0),
|
||||
score: rrf.map(|r| r.normalized_score).unwrap_or(0.0),
|
||||
explain: explain_data,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SearchResponse {
|
||||
query: query.to_string(),
|
||||
mode: actual_mode.as_str().to_string(),
|
||||
mode: actual_mode.to_string(),
|
||||
total_results: results.len(),
|
||||
results,
|
||||
warnings,
|
||||
@@ -368,12 +360,8 @@ pub fn print_search_results(response: &SearchResponse) {
|
||||
|
||||
if let Some(ref explain) = result.explain {
|
||||
println!(
|
||||
" {} vector_rank={} fts_rank={} rrf_score={:.6}",
|
||||
" {} fts_rank={} rrf_score={:.6}",
|
||||
style("[explain]").magenta(),
|
||||
explain
|
||||
.vector_rank
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
explain
|
||||
.fts_rank
|
||||
.map(|r| r.to_string())
|
||||
|
||||
@@ -75,17 +75,12 @@ pub struct IssueDetail {
|
||||
pub author_username: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub closed_at: Option<String>,
|
||||
pub confidential: bool,
|
||||
pub web_url: Option<String>,
|
||||
pub project_path: String,
|
||||
pub references_full: String,
|
||||
pub labels: Vec<String>,
|
||||
pub assignees: Vec<String>,
|
||||
pub due_date: Option<String>,
|
||||
pub milestone: Option<String>,
|
||||
pub user_notes_count: i64,
|
||||
pub merge_requests_count: usize,
|
||||
pub closing_merge_requests: Vec<ClosingMrRef>,
|
||||
pub discussions: Vec<DiscussionDetail>,
|
||||
pub status_name: Option<String>,
|
||||
@@ -127,9 +122,6 @@ pub fn run_show_issue(
|
||||
|
||||
let discussions = get_issue_discussions(&conn, issue.id)?;
|
||||
|
||||
let references_full = format!("{}#{}", issue.project_path, issue.iid);
|
||||
let merge_requests_count = closing_mrs.len();
|
||||
|
||||
Ok(IssueDetail {
|
||||
id: issue.id,
|
||||
iid: issue.iid,
|
||||
@@ -139,17 +131,12 @@ pub fn run_show_issue(
|
||||
author_username: issue.author_username,
|
||||
created_at: issue.created_at,
|
||||
updated_at: issue.updated_at,
|
||||
closed_at: issue.closed_at,
|
||||
confidential: issue.confidential,
|
||||
web_url: issue.web_url,
|
||||
project_path: issue.project_path,
|
||||
references_full,
|
||||
labels,
|
||||
assignees,
|
||||
due_date: issue.due_date,
|
||||
milestone: issue.milestone_title,
|
||||
user_notes_count: issue.user_notes_count,
|
||||
merge_requests_count,
|
||||
closing_merge_requests: closing_mrs,
|
||||
discussions,
|
||||
status_name: issue.status_name,
|
||||
@@ -169,13 +156,10 @@ struct IssueRow {
|
||||
author_username: String,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
closed_at: Option<String>,
|
||||
confidential: bool,
|
||||
web_url: Option<String>,
|
||||
project_path: String,
|
||||
due_date: Option<String>,
|
||||
milestone_title: Option<String>,
|
||||
user_notes_count: i64,
|
||||
status_name: Option<String>,
|
||||
status_category: Option<String>,
|
||||
status_color: Option<String>,
|
||||
@@ -189,12 +173,8 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
||||
let project_id = resolve_project(conn, project)?;
|
||||
(
|
||||
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
||||
i.created_at, i.updated_at, i.closed_at, i.confidential,
|
||||
i.web_url, p.path_with_namespace,
|
||||
i.created_at, i.updated_at, i.web_url, p.path_with_namespace,
|
||||
i.due_date, i.milestone_title,
|
||||
(SELECT COUNT(*) FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
WHERE d.noteable_type = 'Issue' AND d.noteable_id = i.id AND n.is_system = 0) AS user_notes_count,
|
||||
i.status_name, i.status_category, i.status_color,
|
||||
i.status_icon_name, i.status_synced_at
|
||||
FROM issues i
|
||||
@@ -205,12 +185,8 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
||||
}
|
||||
None => (
|
||||
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
||||
i.created_at, i.updated_at, i.closed_at, i.confidential,
|
||||
i.web_url, p.path_with_namespace,
|
||||
i.created_at, i.updated_at, i.web_url, p.path_with_namespace,
|
||||
i.due_date, i.milestone_title,
|
||||
(SELECT COUNT(*) FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
WHERE d.noteable_type = 'Issue' AND d.noteable_id = i.id AND n.is_system = 0) AS user_notes_count,
|
||||
i.status_name, i.status_category, i.status_color,
|
||||
i.status_icon_name, i.status_synced_at
|
||||
FROM issues i
|
||||
@@ -225,7 +201,6 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let issues: Vec<IssueRow> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let confidential_val: i64 = row.get(9)?;
|
||||
Ok(IssueRow {
|
||||
id: row.get(0)?,
|
||||
iid: row.get(1)?,
|
||||
@@ -235,18 +210,15 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
|
||||
author_username: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
closed_at: row.get(8)?,
|
||||
confidential: confidential_val != 0,
|
||||
web_url: row.get(10)?,
|
||||
project_path: row.get(11)?,
|
||||
due_date: row.get(12)?,
|
||||
milestone_title: row.get(13)?,
|
||||
user_notes_count: row.get(14)?,
|
||||
status_name: row.get(15)?,
|
||||
status_category: row.get(16)?,
|
||||
status_color: row.get(17)?,
|
||||
status_icon_name: row.get(18)?,
|
||||
status_synced_at: row.get(19)?,
|
||||
web_url: row.get(8)?,
|
||||
project_path: row.get(9)?,
|
||||
due_date: row.get(10)?,
|
||||
milestone_title: row.get(11)?,
|
||||
status_name: row.get(12)?,
|
||||
status_category: row.get(13)?,
|
||||
status_color: row.get(14)?,
|
||||
status_icon_name: row.get(15)?,
|
||||
status_synced_at: row.get(16)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -646,7 +618,6 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
println!("{}", "━".repeat(header.len().min(80)));
|
||||
println!();
|
||||
|
||||
println!("Ref: {}", style(&issue.references_full).dim());
|
||||
println!("Project: {}", style(&issue.project_path).cyan());
|
||||
|
||||
let state_styled = if issue.state == "opened" {
|
||||
@@ -656,10 +627,6 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
};
|
||||
println!("State: {}", state_styled);
|
||||
|
||||
if issue.confidential {
|
||||
println!(" {}", style("CONFIDENTIAL").red().bold());
|
||||
}
|
||||
|
||||
if let Some(status) = &issue.status_name {
|
||||
println!(
|
||||
"Status: {}",
|
||||
@@ -691,10 +658,6 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
println!("Created: {}", format_date(issue.created_at));
|
||||
println!("Updated: {}", format_date(issue.updated_at));
|
||||
|
||||
if let Some(closed_at) = &issue.closed_at {
|
||||
println!("Closed: {}", closed_at);
|
||||
}
|
||||
|
||||
if let Some(due) = &issue.due_date {
|
||||
println!("Due: {}", due);
|
||||
}
|
||||
@@ -968,17 +931,12 @@ pub struct IssueDetailJson {
|
||||
pub author_username: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub closed_at: Option<String>,
|
||||
pub confidential: bool,
|
||||
pub web_url: Option<String>,
|
||||
pub project_path: String,
|
||||
pub references_full: String,
|
||||
pub labels: Vec<String>,
|
||||
pub assignees: Vec<String>,
|
||||
pub due_date: Option<String>,
|
||||
pub milestone: Option<String>,
|
||||
pub user_notes_count: i64,
|
||||
pub merge_requests_count: usize,
|
||||
pub closing_merge_requests: Vec<ClosingMrRefJson>,
|
||||
pub discussions: Vec<DiscussionDetailJson>,
|
||||
pub status_name: Option<String>,
|
||||
@@ -1022,17 +980,12 @@ impl From<&IssueDetail> for IssueDetailJson {
|
||||
author_username: issue.author_username.clone(),
|
||||
created_at: ms_to_iso(issue.created_at),
|
||||
updated_at: ms_to_iso(issue.updated_at),
|
||||
closed_at: issue.closed_at.clone(),
|
||||
confidential: issue.confidential,
|
||||
web_url: issue.web_url.clone(),
|
||||
project_path: issue.project_path.clone(),
|
||||
references_full: issue.references_full.clone(),
|
||||
labels: issue.labels.clone(),
|
||||
assignees: issue.assignees.clone(),
|
||||
due_date: issue.due_date.clone(),
|
||||
milestone: issue.milestone.clone(),
|
||||
user_notes_count: issue.user_notes_count,
|
||||
merge_requests_count: issue.merge_requests_count,
|
||||
closing_merge_requests: issue
|
||||
.closing_merge_requests
|
||||
.iter()
|
||||
|
||||
@@ -1532,10 +1532,6 @@ fn query_overlap(
|
||||
} else {
|
||||
"= ?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!(
|
||||
"SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
||||
-- 1. DiffNote reviewer
|
||||
@@ -1545,7 +1541,7 @@ fn query_overlap(
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM notes n {notes_indexed_by}
|
||||
FROM notes n
|
||||
JOIN discussions d 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
|
||||
@@ -1568,9 +1564,9 @@ fn query_overlap(
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM notes n {notes_indexed_by}
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path {path_op}
|
||||
|
||||
@@ -215,24 +215,6 @@ pub enum Commands {
|
||||
/// People intelligence: experts, workload, active discussions, overlap
|
||||
Who(WhoArgs),
|
||||
|
||||
/// Detect discussion divergence from original intent
|
||||
Drift {
|
||||
/// Entity type (currently only "issues" supported)
|
||||
#[arg(value_parser = ["issues"])]
|
||||
entity_type: String,
|
||||
|
||||
/// Entity IID
|
||||
iid: i64,
|
||||
|
||||
/// Similarity threshold for drift detection (0.0-1.0)
|
||||
#[arg(long, default_value = "0.4")]
|
||||
threshold: f32,
|
||||
|
||||
/// Scope to project (fuzzy match)
|
||||
#[arg(short, long)]
|
||||
project: Option<String>,
|
||||
},
|
||||
|
||||
#[command(hide = true)]
|
||||
List {
|
||||
#[arg(value_parser = ["issues", "mrs"])]
|
||||
|
||||
@@ -77,7 +77,6 @@ pub fn strip_schemas(commands: &mut serde_json::Value) {
|
||||
for (_cmd_name, cmd) in map.iter_mut() {
|
||||
if let Some(obj) = cmd.as_object_mut() {
|
||||
obj.remove("response_schema");
|
||||
obj.remove("example_output");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +69,6 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"021",
|
||||
include_str!("../../migrations/021_work_item_status.sql"),
|
||||
),
|
||||
(
|
||||
"023",
|
||||
include_str!("../../migrations/023_issue_detail_fields.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
||||
|
||||
@@ -21,14 +21,13 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
let escaped = escape_like(project_str);
|
||||
let mut suffix_stmt = conn.prepare(
|
||||
"SELECT id, path_with_namespace FROM projects
|
||||
WHERE path_with_namespace LIKE '%/' || ?1 ESCAPE '\\'
|
||||
OR path_with_namespace = ?2",
|
||||
WHERE path_with_namespace LIKE '%/' || ?1
|
||||
OR path_with_namespace = ?1",
|
||||
)?;
|
||||
let suffix_matches: Vec<(i64, String)> = suffix_stmt
|
||||
.query_map(rusqlite::params![escaped, project_str], |row| {
|
||||
.query_map(rusqlite::params![project_str], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -53,10 +52,10 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
|
||||
|
||||
let mut substr_stmt = conn.prepare(
|
||||
"SELECT id, path_with_namespace FROM projects
|
||||
WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%' ESCAPE '\\'",
|
||||
WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%'",
|
||||
)?;
|
||||
let substr_matches: Vec<(i64, String)> = substr_stmt
|
||||
.query_map(rusqlite::params![escaped], |row| {
|
||||
.query_map(rusqlite::params![project_str], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -104,15 +103,6 @@ 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -251,24 +241,4 @@ mod tests {
|
||||
let msg = err.to_string();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ pub mod chunk_ids;
|
||||
pub mod chunking;
|
||||
pub mod ollama;
|
||||
pub mod pipeline;
|
||||
pub mod similarity;
|
||||
|
||||
pub use change_detector::{PendingDocument, count_pending_documents, find_pending_documents};
|
||||
pub use chunking::{CHUNK_MAX_BYTES, CHUNK_OVERLAP_CHARS, split_into_chunks};
|
||||
pub use pipeline::{EmbedResult, embed_documents};
|
||||
pub use similarity::cosine_similarity;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/// Cosine similarity between two embedding vectors.
|
||||
/// Returns value in [-1, 1] range; higher = more similar.
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
debug_assert_eq!(a.len(), b.len(), "embedding dimensions must match");
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm_a == 0.0 || norm_b == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
dot / (norm_a * norm_b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cosine_similarity_identical() {
|
||||
let v = [1.0, 2.0, 3.0];
|
||||
let sim = cosine_similarity(&v, &v);
|
||||
assert!((sim - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_similarity_orthogonal() {
|
||||
let a = [1.0, 0.0, 0.0];
|
||||
let b = [0.0, 1.0, 0.0];
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!(sim.abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_similarity_zero_vector() {
|
||||
let a = [1.0, 2.0, 3.0];
|
||||
let b = [0.0, 0.0, 0.0];
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!((sim - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_similarity_opposite() {
|
||||
let a = [1.0, 2.0, 3.0];
|
||||
let b = [-1.0, -2.0, -3.0];
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!((sim - (-1.0)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
92
src/main.rs
92
src/main.rs
@@ -12,17 +12,17 @@ use lore::cli::autocorrect::{self, CorrectionResult};
|
||||
use lore::cli::commands::{
|
||||
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
|
||||
SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser, open_mr_in_browser,
|
||||
print_count, print_count_json, print_doctor_results, print_drift_human, print_drift_json,
|
||||
print_dry_run_preview, print_dry_run_preview_json, print_embed, print_embed_json,
|
||||
print_event_count, print_event_count_json, print_generate_docs, print_generate_docs_json,
|
||||
print_ingest_summary, print_ingest_summary_json, print_list_issues, print_list_issues_json,
|
||||
print_list_mrs, print_list_mrs_json, print_search_results, print_search_results_json,
|
||||
print_show_issue, print_show_issue_json, print_show_mr, print_show_mr_json, print_stats,
|
||||
print_stats_json, print_sync, print_sync_json, print_sync_status, print_sync_status_json,
|
||||
print_timeline, print_timeline_json_with_meta, print_who_human, print_who_json, run_auth_test,
|
||||
run_count, run_count_events, run_doctor, run_drift, run_embed, run_generate_docs, run_ingest,
|
||||
run_ingest_dry_run, run_init, run_list_issues, run_list_mrs, run_search, run_show_issue,
|
||||
run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, run_who,
|
||||
print_count, print_count_json, print_doctor_results, print_dry_run_preview,
|
||||
print_dry_run_preview_json, print_embed, print_embed_json, print_event_count,
|
||||
print_event_count_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
|
||||
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
|
||||
print_list_mrs_json, print_search_results, print_search_results_json, print_show_issue,
|
||||
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
|
||||
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
|
||||
print_timeline_json_with_meta, print_who_human, print_who_json, run_auth_test, run_count,
|
||||
run_count_events, run_doctor, run_embed, run_generate_docs, run_ingest, run_ingest_dry_run,
|
||||
run_init, run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats,
|
||||
run_sync, run_sync_status, run_timeline, run_who,
|
||||
};
|
||||
use lore::cli::robot::{RobotMeta, strip_schemas};
|
||||
use lore::cli::{
|
||||
@@ -178,22 +178,6 @@ async fn main() {
|
||||
}
|
||||
Some(Commands::Timeline(args)) => handle_timeline(cli.config.as_deref(), args, robot_mode),
|
||||
Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),
|
||||
Some(Commands::Drift {
|
||||
entity_type,
|
||||
iid,
|
||||
threshold,
|
||||
project,
|
||||
}) => {
|
||||
handle_drift(
|
||||
cli.config.as_deref(),
|
||||
&entity_type,
|
||||
iid,
|
||||
threshold,
|
||||
project.as_deref(),
|
||||
robot_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(Commands::Stats(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
|
||||
Some(Commands::Embed(args)) => handle_embed(cli.config.as_deref(), args, robot_mode).await,
|
||||
Some(Commands::Sync(args)) => {
|
||||
@@ -1778,8 +1762,7 @@ async fn handle_search(
|
||||
fts_mode,
|
||||
&args.mode,
|
||||
explain,
|
||||
)
|
||||
.await?;
|
||||
)?;
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
if robot_mode {
|
||||
@@ -2065,7 +2048,6 @@ struct RobotDocsData {
|
||||
version: String,
|
||||
description: String,
|
||||
activation: RobotDocsActivation,
|
||||
quick_start: serde_json::Value,
|
||||
commands: serde_json::Value,
|
||||
/// Deprecated command aliases (old -> new)
|
||||
aliases: serde_json::Value,
|
||||
@@ -2169,7 +2151,6 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"example_output": {"list": {"ok":true,"data":{"issues":[{"iid":3864,"title":"Switch Health Card","state":"opened","status_name":"In progress","labels":["customer:BNSF"],"assignees":["teernisse"],"discussion_count":12,"updated_at_iso":"2026-02-12T..."}],"total_count":1,"showing":1},"meta":{"elapsed_ms":42}}},
|
||||
"fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]}
|
||||
},
|
||||
"mrs": {
|
||||
@@ -2188,7 +2169,6 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"example_output": {"list": {"ok":true,"data":{"mrs":[{"iid":200,"title":"Add throw time chart","state":"opened","draft":false,"author_username":"teernisse","target_branch":"main","source_branch":"feat/throw-time","reviewers":["cseiber"],"discussion_count":5,"updated_at_iso":"2026-02-11T..."}],"total_count":1,"showing":1},"meta":{"elapsed_ms":38}}},
|
||||
"fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]}
|
||||
},
|
||||
"search": {
|
||||
@@ -2200,7 +2180,6 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"data": {"results": "[{document_id:int, source_type:string, title:string, snippet:string, score:float, url:string?, author:string?, created_at:string?, updated_at:string?, project_path:string, labels:[string], paths:[string]}]", "total_results": "int", "query": "string", "mode": "string", "warnings": "[string]"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
},
|
||||
"example_output": {"ok":true,"data":{"query":"throw time","mode":"hybrid","total_results":3,"results":[{"document_id":42,"source_type":"issue","title":"Switch Health Card","score":0.92,"snippet":"...throw time data from BNSF...","project_path":"vs/typescript-code"}],"warnings":[]},"meta":{"elapsed_ms":85}},
|
||||
"fields_presets": {"minimal": ["document_id", "title", "source_type", "score"]}
|
||||
},
|
||||
"count": {
|
||||
@@ -2310,7 +2289,6 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
},
|
||||
"example_output": {"expert": {"ok":true,"data":{"mode":"expert","result":{"experts":[{"username":"teernisse","score":42,"note_count":15,"diff_note_count":8}]}},"meta":{"elapsed_ms":65}}},
|
||||
"fields_presets": {
|
||||
"expert_minimal": ["username", "score"],
|
||||
"workload_minimal": ["entity_type", "iid", "title", "state"],
|
||||
@@ -2324,28 +2302,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
}
|
||||
});
|
||||
|
||||
let quick_start = serde_json::json!({
|
||||
"glab_equivalents": [
|
||||
{ "glab": "glab issue list", "lore": "lore -J issues -n 50", "note": "Richer: includes labels, status, closing MRs, discussion counts" },
|
||||
{ "glab": "glab issue view 123", "lore": "lore -J issues 123", "note": "Includes full discussions, work-item status, cross-references" },
|
||||
{ "glab": "glab issue list -l bug", "lore": "lore -J issues --label bug", "note": "AND logic for multiple --label flags" },
|
||||
{ "glab": "glab mr list", "lore": "lore -J mrs", "note": "Includes draft status, reviewers, discussion counts" },
|
||||
{ "glab": "glab mr view 456", "lore": "lore -J mrs 456", "note": "Includes discussions, review threads, source/target branches" },
|
||||
{ "glab": "glab mr list -s opened", "lore": "lore -J mrs -s opened", "note": "States: opened, merged, closed, locked, all" },
|
||||
{ "glab": "glab api '/projects/:id/issues'", "lore": "lore -J issues -p project", "note": "Fuzzy project matching (suffix or substring)" }
|
||||
],
|
||||
"lore_exclusive": [
|
||||
"search: FTS5 + vector hybrid search across all entities",
|
||||
"who: Expert/workload/reviews analysis per file path or person",
|
||||
"timeline: Chronological event reconstruction across entities",
|
||||
"stats: Database statistics with document/note/discussion counts",
|
||||
"count: Entity counts with state breakdowns",
|
||||
"embed: Generate vector embeddings for semantic search via Ollama"
|
||||
],
|
||||
"read_write_split": "lore = ALL reads (issues, MRs, search, who, timeline, intelligence). glab = ALL writes (create, update, approve, merge, CI/CD)."
|
||||
});
|
||||
|
||||
// --brief: strip response_schema and example_output from every command (~60% smaller)
|
||||
// --brief: strip response_schema from every command (~60% smaller)
|
||||
let mut commands = commands;
|
||||
if brief {
|
||||
strip_schemas(&mut commands);
|
||||
@@ -2448,7 +2405,6 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
env: "LORE_ROBOT=1".to_string(),
|
||||
auto: "Non-TTY stdout".to_string(),
|
||||
},
|
||||
quick_start,
|
||||
commands,
|
||||
aliases,
|
||||
exit_codes,
|
||||
@@ -2489,28 +2445,6 @@ fn handle_who(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_drift(
|
||||
config_override: Option<&str>,
|
||||
entity_type: &str,
|
||||
iid: i64,
|
||||
threshold: f32,
|
||||
project: Option<&str>,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
let effective_project = config.effective_project(project);
|
||||
let response = run_drift(&config, entity_type, iid, threshold, effective_project).await?;
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
if robot_mode {
|
||||
print_drift_json(&response, elapsed_ms);
|
||||
} else {
|
||||
print_drift_human(&response);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_list_compat(
|
||||
config_override: Option<&str>,
|
||||
|
||||
Reference in New Issue
Block a user