@mmerterden/multi-agent-pipeline 12.4.0 → 12.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/package.json +1 -1
- package/pipeline/lib/extract-conventions.sh +1 -0
- package/pipeline/lib/repo-cache.sh +1 -0
- package/pipeline/lib/shadow-git.sh +8 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -1
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +3 -2
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +10 -0
- package/pipeline/multi-agent-refs/phases.md +2 -0
- package/pipeline/multi-agent-refs/prompt-assembly.md +31 -0
- package/pipeline/schemas/learnings-ledger.schema.json +4 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/schemas/triage-corpus.schema.json +5 -1
- package/pipeline/scripts/eval-mine-corpus.mjs +19 -5
- package/pipeline/scripts/fixtures/install-layout.tsv +3 -3
- package/pipeline/scripts/gc-worktrees.sh +23 -1
- package/pipeline/scripts/learning-curve.mjs +167 -0
- package/pipeline/scripts/learnings-ledger.mjs +9 -2
- package/pipeline/scripts/repo-map.mjs +1 -1
- package/pipeline/scripts/smoke-learning-curve.sh +61 -0
- package/pipeline/scripts/triage-memory.mjs +48 -10
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,45 @@ Internal file-layout changes that don't affect the slash-command surface are sti
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
+
## [12.5.0] - 2026-07-24
|
|
18
|
+
|
|
19
|
+
Repo learning loop: repeated runs on the same repo produce better and cheaper
|
|
20
|
+
output over time. Plus a round of worktree hardening.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Freshness gate** for the triage corpus: `triage-memory` stamps each stored
|
|
25
|
+
row with the file's git `file_sha`, and queries annotate `stale=true` when
|
|
26
|
+
the file changed since the lesson was recorded, so dead lessons stop
|
|
27
|
+
resurfacing in later runs.
|
|
28
|
+
- **Causal diagnosis** on lessons: `learnings-ledger` rows carry an optional
|
|
29
|
+
`diagnosis` (the verbal root-cause "why", per Reflexion), and Phase 4's
|
|
30
|
+
lesson-memory loop now records it. `brief` renders it as `(why: ...)` so the
|
|
31
|
+
reason, not just the outcome, re-enters Phase 1 on the next run.
|
|
32
|
+
- **`learning-curve.mjs`** (+ smoke): a time-bucketed trend over
|
|
33
|
+
`metrics.jsonl` (first-pass clean rate, review cycles, rework per task,
|
|
34
|
+
tokens per task, cache ratio) that shows whether a repo's runs are improving.
|
|
35
|
+
- **`prompt-assembly.md`**: stable-prefix prompt-cache guidance, referenced
|
|
36
|
+
from `phases.md`, so lazy-loaded phase docs stay cache-friendly.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- Worktree handling hardened: `.worktrees/` is now a first-class member of the
|
|
41
|
+
traversal-prune skip set (`node_modules`, `Pods`, `.build`, `DerivedData`,
|
|
42
|
+
`.next`) across every tree walker (`repo-map`, `repo-cache`,
|
|
43
|
+
`extract-conventions`, `shadow-git`), so no walker descends into a worktree
|
|
44
|
+
checkout. Phase 0 gains an explicit residue-guard + traversal-prune contract;
|
|
45
|
+
Phase 5 heals stale worktree admin state before recreating a worktree.
|
|
46
|
+
- `eval-mine-corpus` now reads the corpus from the per-repo memory dir it is
|
|
47
|
+
actually written to (was a path that never received data).
|
|
48
|
+
- Phase-doc total token budget recalibrated 50000 -> 51000 for the new
|
|
49
|
+
contracts (prose compressed first; every per-phase max still passes).
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- `gc-worktrees.sh` anchors the repo on the main worktree and resolves paths on
|
|
54
|
+
both sides, so orphan detection no longer misfires from inside a worktree.
|
|
55
|
+
|
|
17
56
|
## [12.4.0] - 2026-07-23
|
|
18
57
|
|
|
19
58
|
User-defined routines: manage your own recurring, project-specific jobs as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-pipeline",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.5.0",
|
|
4
4
|
"description": "8-phase AI development pipeline with full orchestration on Claude Code and Copilot CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -212,6 +212,7 @@ print(json.dumps(out))
|
|
|
212
212
|
find "$root" -maxdepth "$MAXDEPTH" -type d -name ".git" \
|
|
213
213
|
\( -path "*/node_modules/*" -prune -o -path "*/.build/*" -prune -o \
|
|
214
214
|
-path "*/DerivedData/*" -prune -o -path "*/Pods/*" -prune -o \
|
|
215
|
+
-path "*/.worktrees/*" -prune -o \
|
|
215
216
|
-path "*/.next/*" -prune -o -print \) 2>/dev/null \
|
|
216
217
|
| while read -r gitdir; do
|
|
217
218
|
[ -n "$gitdir" ] || continue
|
|
@@ -110,6 +110,13 @@ do_init() {
|
|
|
110
110
|
if [ -f "$project_root/.gitignore" ]; then
|
|
111
111
|
cat "$project_root/.gitignore" >> "$sd/.git/info/exclude"
|
|
112
112
|
fi
|
|
113
|
+
# `.worktrees/` is forced-excluded too: worktrees live INSIDE the repo at
|
|
114
|
+
# <repo>/.worktrees/<id>/ and each carries a `.git` gitlink, so a blanket
|
|
115
|
+
# `add -A` would record every worktree as a "Subproject commit" and churn a
|
|
116
|
+
# fresh gitlink into every per-tool-call snapshot. The real clone keeps this
|
|
117
|
+
# out of its own index via .git/info/exclude; the shadow repo has its own
|
|
118
|
+
# exclude file and must repeat the rule. This mirrors the traversal-prune
|
|
119
|
+
# contract in phase-0-init.md (never descend into .worktrees on a tree walk).
|
|
113
120
|
cat >> "$sd/.git/info/exclude" <<EOF
|
|
114
121
|
node_modules/
|
|
115
122
|
Pods/
|
|
@@ -119,6 +126,7 @@ DerivedData/
|
|
|
119
126
|
.gradle/
|
|
120
127
|
__pycache__/
|
|
121
128
|
.venv/
|
|
129
|
+
.worktrees/
|
|
122
130
|
*.log
|
|
123
131
|
EOF
|
|
124
132
|
# Initial author setup - keep separate from user's global git identity to
|
|
@@ -450,7 +450,7 @@ fi
|
|
|
450
450
|
|
|
451
451
|
If the path is still registered and healthy after the heal, enter + pull instead of re-adding (existing behavior). Only when `worktree add` still fails after the heal do the rollback / collision flow in the multi-repo block below.
|
|
452
452
|
|
|
453
|
-
**Worktree residue guard (required once per repo, idempotent):** every worktree dir
|
|
453
|
+
**Worktree residue guard (required once per repo, idempotent):** every worktree dir holds a `.git` file, so a blanket `git add -A` in the parent tree records `.worktrees/{id}` as a gitlink that pollutes the branch. Keep `.worktrees/` out of the index via the clone-local exclude file (never committed, so no PR noise):
|
|
454
454
|
|
|
455
455
|
```bash
|
|
456
456
|
ex="$(git -C "$PROJECT_ROOT" rev-parse --path-format=absolute --git-common-dir)/info/exclude"
|
|
@@ -458,6 +458,14 @@ mkdir -p "$(dirname "$ex")"
|
|
|
458
458
|
grep -qxF '.worktrees/' "$ex" 2>/dev/null || printf '.worktrees/\n' >> "$ex"
|
|
459
459
|
```
|
|
460
460
|
|
|
461
|
+
**Traversal-prune contract:** the exclude guard covers only the git INDEX, not
|
|
462
|
+
filesystem scans. Since each worktree is a full checkout, an unpruned tree walk
|
|
463
|
+
double-processes every file and can re-stage gitlinks. So `.worktrees` joins the
|
|
464
|
+
skip set (`node_modules`, `Pods`, `.build`, `DerivedData`, `.next`): every
|
|
465
|
+
`find`, walker, or `git add -A` MUST prune it. Already applied in the Step 2
|
|
466
|
+
scan, `shadow-git.sh` excludes, and the shared walkers (`extract-conventions.sh`,
|
|
467
|
+
`repo-cache.sh`, `repo-map.mjs`); add it to any new tree walk too.
|
|
468
|
+
|
|
461
469
|
**v2.1.0+ Multi-Repo Worktree Setup**:
|
|
462
470
|
|
|
463
471
|
When `state.projects[].length > 1`, repeat steps 2-4 **serially per repo** (worktrees are cheap; serial keeps git index sane and surfaces collisions one at a time):
|
|
@@ -414,11 +414,12 @@ At the end of every fix/rework round (each Phase 3 re-entry that resolved accept
|
|
|
414
414
|
|
|
415
415
|
```bash
|
|
416
416
|
node pipeline/scripts/learnings-ledger.mjs add --kind fact \
|
|
417
|
-
--statement "<one-line
|
|
417
|
+
--statement "<one-line rule/what-to-do so this class of finding does not recur (<=140 chars)>" \
|
|
418
|
+
--diagnosis "<the causal WHY the failure happened, one line>" \
|
|
418
419
|
--scope "<file-or-area glob from the finding>" --task "$TASK_ID"
|
|
419
420
|
```
|
|
420
421
|
|
|
421
|
-
Statement shape: root cause, not symptom - "force-unwrapped optional in async callback path", not "fixed crash in LoginView". Dedup built in (exit 2 = already stored
|
|
422
|
+
Statement shape: the durable rule/root cause, not the symptom - "force-unwrapped optional in async callback path", not "fixed crash in LoginView". Always pass `--diagnosis` with the causal why (Reflexion: the verbal reason prevents recurrence; a bare outcome does not). Dedup built in (exit 2 = already stored). Lessons re-enter Phase 1 + triage via `learnings-ledger.mjs brief` (renders diagnosis as "(why: ...)").
|
|
422
423
|
|
|
423
424
|
Progress line: ` → writing lesson to learnings ledger ({N} entries)`
|
|
424
425
|
|
|
@@ -103,6 +103,16 @@ Tier 1 / Tier 2 records print `screenshotUrl` from the captured evidence (Tier 2
|
|
|
103
103
|
The waiting state persists in `tracker-state.json` across the handoff; `/multi-agent:finish` and `/multi-agent:manual-test` CONTINUE this state file and never re-init it (`$HOME/.claude/multi-agent-refs/tracker-contract.md` "Continuation runs").
|
|
104
104
|
6. If fix needed:
|
|
105
105
|
- Branch already has WIP commit (from step 2) - changes are safe
|
|
106
|
+
- **Heal stale admin state first** (same contract as Phase 0 - step 3's
|
|
107
|
+
`worktree remove` or an interrupted run can leave a stale entry, so a bare
|
|
108
|
+
re-add fails with `already exists`/`already registered`):
|
|
109
|
+
```bash
|
|
110
|
+
git -C "$PROJECT_ROOT" worktree prune 2>/dev/null || true
|
|
111
|
+
if git -C "$PROJECT_ROOT" worktree list --porcelain | grep -qF "{worktree-path}"; then
|
|
112
|
+
git -C "$PROJECT_ROOT" worktree unlock "{worktree-path}" 2>/dev/null || true
|
|
113
|
+
fi
|
|
114
|
+
```
|
|
115
|
+
Phase 0's `.worktrees/` residue guard is already in `.git/info/exclude` - no re-add.
|
|
106
116
|
- Recreate worktree from branch: `git -C $PROJECT_ROOT worktree add {worktree-path} {branch}`
|
|
107
117
|
- Re-set git identity: `git -C {worktree-path} config user.name/email` (from state)
|
|
108
118
|
- Go back to Phase 3
|
|
@@ -149,6 +149,8 @@ Each phase doc is lazy-loaded - only the current phase's spec is in context. B
|
|
|
149
149
|
|
|
150
150
|
Token estimate: `ceil(chars / 4)`. Budget covers phase-\*.md files only. Guides, rules, and agents are loaded separately on demand.
|
|
151
151
|
|
|
152
|
+
**Prompt caching (token learning-curve):** assemble each phase prompt as a stable, cacheable prefix (phase instructions + repo learnings brief + conventions + repo evidence) followed by the volatile task suffix, so repeated runs on a known repo pay cache-read price on the prefix. Full contract: `$HOME/.claude/multi-agent-refs/prompt-assembly.md`. The effect (rising cache ratio, falling tokens/task) is what `learning-curve.mjs` trends.
|
|
153
|
+
|
|
152
154
|
## SubPhase Convention
|
|
153
155
|
|
|
154
156
|
When a specialized skill takes over a main pipeline phase, progress is reported as **SubPhases** nested under the parent. Top-level stays 8 phases (0-7); specialized flows get sub-phase detail.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Prompt assembly: stable-prefix caching (token learning-curve)
|
|
2
|
+
|
|
3
|
+
Anthropic prompt caching is **prefix-based**: the longest unchanged leading span of a request is served from cache at a large discount on repeated calls. As the pipeline accumulates durable knowledge about a repo (conventions, learnings brief, repo evidence), that knowledge should become a near-free cached prefix instead of a growing per-run token cost. This is the mechanism that makes "knowing the repo better" literally cheaper each run.
|
|
4
|
+
|
|
5
|
+
## The rule
|
|
6
|
+
|
|
7
|
+
Assemble every phase prompt as two spans, in this order:
|
|
8
|
+
|
|
9
|
+
1. **Stable prefix (cacheable, repo-stable, changes rarely):**
|
|
10
|
+
- system / role instructions for the phase
|
|
11
|
+
- the repo learnings brief (`learnings-ledger.mjs brief`)
|
|
12
|
+
- extracted conventions (Phase 1c) and the design/analysis doc when applicable
|
|
13
|
+
- repo structural evidence (the reuse-first buckets, Code Connect index)
|
|
14
|
+
2. **Volatile suffix (task-specific, changes every run):**
|
|
15
|
+
- the specific task / issue / diff
|
|
16
|
+
- the current file(s) under edit
|
|
17
|
+
- per-run state and the immediate instruction
|
|
18
|
+
|
|
19
|
+
Put the stable material FIRST and the volatile material LAST. Reordering volatile content into the middle of the prefix invalidates the cache for everything after it, so keep the boundary clean.
|
|
20
|
+
|
|
21
|
+
## Why it matters
|
|
22
|
+
|
|
23
|
+
- Repeated runs on the same repo pay roughly cache-read price (~10% of input) on the stable prefix instead of full price - the dominant per-run token cost once a repo is "known."
|
|
24
|
+
- It turns accumulated learnings from a cost into an asset: more durable knowledge => bigger cached prefix => lower marginal cost, not higher.
|
|
25
|
+
- The effect is visible in `metrics.jsonl` `tokens_cached` / `cache_ratio` and is exactly what `learning-curve.mjs` trends over time (rising cache ratio + falling tokens/task = the learning curve working).
|
|
26
|
+
|
|
27
|
+
## Anti-patterns
|
|
28
|
+
|
|
29
|
+
- Interleaving the task text with conventions/learnings (breaks the prefix).
|
|
30
|
+
- Letting the stable prefix bloat past what the phase needs (context rot; cache a lean prefix, not everything - see the corpus merge/drop + progressive-disclosure discipline).
|
|
31
|
+
- Regenerating repo evidence from scratch each run instead of reusing cached/learned artifacts (defeats both the cache and the learning curve).
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"type": "string",
|
|
35
35
|
"enum": ["low", "medium", "high"],
|
|
36
36
|
"description": "How strongly to weight the entry when replaying it."
|
|
37
|
+
},
|
|
38
|
+
"diagnosis": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "Optional causal 'why' behind the lesson (Reflexion): the root-cause reasoning that turns a detected failure into a prevented one. Rendered in the brief as '(why: ...)'. Absent for plain facts/conventions."
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
}
|
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
"phase-6-commit": { "max_tokens": 6150, "warn_tokens": 5400 },
|
|
13
13
|
"phase-7-report": { "max_tokens": 5600, "warn_tokens": 4950 }
|
|
14
14
|
},
|
|
15
|
-
"total_max_tokens":
|
|
16
|
-
"note": "Token estimate = ceil(chars / 4). Per-phase budget rule: warn = current+10% (rounded to nearest 50), max = current+25%. Gives ~6 edit cycles of headroom before warn trips - intentionally quiet under normal maintenance, loud when a phase grows unusually. Only the active phase is loaded (lazy). Recalibrated at v10.0.0 after the validator/consistency/simplifier/lesson gate contracts landed in phases 1-4. Recalibrated again at v10.9.0 after the verify-by-test (Phase 4 Step 3.7), update-check (Phase 0 Step 0.6), immutable-test (Phase 3 GREEN) and redTests re-entry contracts landed - Step 3.7 prose was compressed to a pointer into refs/features/verify-by-test.md before the recalibration."
|
|
15
|
+
"total_max_tokens": 51000,
|
|
16
|
+
"note": "Token estimate = ceil(chars / 4). Per-phase budget rule: warn = current+10% (rounded to nearest 50), max = current+25%. Gives ~6 edit cycles of headroom before warn trips - intentionally quiet under normal maintenance, loud when a phase grows unusually. Only the active phase is loaded (lazy). Recalibrated at v10.0.0 after the validator/consistency/simplifier/lesson gate contracts landed in phases 1-4. Recalibrated again at v10.9.0 after the verify-by-test (Phase 4 Step 3.7), update-check (Phase 0 Step 0.6), immutable-test (Phase 3 GREEN) and redTests re-entry contracts landed - Step 3.7 prose was compressed to a pointer into refs/features/verify-by-test.md before the recalibration. Total bumped 50000 -> 51000 at v12.5.0 after the worktree residue/traversal-prune contract (Phase 0 + Phase 5 heal) and the Reflexion causal-diagnosis contract (Phase 4 lesson memory) landed; the prose was compressed first (161 tokens reclaimed) and every per-phase max still passes - only the aggregate needed room."
|
|
17
17
|
}
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"issue": { "type": "string", "minLength": 1 },
|
|
27
27
|
"fix": { "type": ["string", "null"] },
|
|
28
28
|
"reason": { "type": ["string", "null"] },
|
|
29
|
-
"reviewer": { "type": ["string", "null"] }
|
|
29
|
+
"reviewer": { "type": ["string", "null"] },
|
|
30
|
+
"file_sha": {
|
|
31
|
+
"type": ["string", "null"],
|
|
32
|
+
"description": "git blob SHA (git hash-object) of `file` at ingest time. On query, a differing current hash flags the hit stale (freshness gate). Null when not computable (file gone / not a git repo)."
|
|
33
|
+
}
|
|
30
34
|
}
|
|
31
35
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// eval-mine-corpus.mjs - grow the triage eval set from real production runs.
|
|
3
3
|
//
|
|
4
|
-
// The pipeline already records every Phase 4 triage decision to
|
|
5
|
-
// `~/.claude/
|
|
4
|
+
// The pipeline already records every Phase 4 triage decision to the per-repo
|
|
5
|
+
// `~/.claude/memory/multi-agent/<repo-slug>/triage-corpus.jsonl` (one line per finding:
|
|
6
6
|
// severity/file/line/issue/fix/reviewer + the classification the run gave it,
|
|
7
7
|
// plus an optional human `reason`/`reviewer`). Hand-authoring eval fixtures is
|
|
8
8
|
// the bottleneck; this closes the loop by turning those real decisions into
|
|
@@ -38,19 +38,33 @@ const candidatesDir = join(root, "pipeline", "eval", "triage-candidates");
|
|
|
38
38
|
const validator = join(root, "pipeline", "scripts", "validate-triage.mjs");
|
|
39
39
|
|
|
40
40
|
const args = process.argv.slice(2);
|
|
41
|
-
const opts = { corpus: null, limit: Infinity, dryRun: false };
|
|
41
|
+
const opts = { corpus: null, limit: Infinity, dryRun: false, repoSlug: null };
|
|
42
42
|
for (let i = 0; i < args.length; i++) {
|
|
43
43
|
if (args[i] === "--corpus") opts.corpus = args[++i];
|
|
44
|
+
else if (args[i] === "--repo-slug") opts.repoSlug = args[++i];
|
|
44
45
|
else if (args[i] === "--limit") opts.limit = Number(args[++i]) || Infinity;
|
|
45
46
|
else if (args[i] === "--dry-run") opts.dryRun = true;
|
|
46
47
|
else if (args[i] === "-h" || args[i] === "--help") {
|
|
47
|
-
console.log("Usage: eval-mine-corpus.mjs [--corpus <path>] [--limit N] [--dry-run]");
|
|
48
|
+
console.log("Usage: eval-mine-corpus.mjs [--corpus <path>] [--repo-slug <slug>] [--limit N] [--dry-run]");
|
|
48
49
|
process.exit(0);
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Resolve the corpus path the SAME way triage-memory.mjs writes it:
|
|
54
|
+
// ~/.claude/memory/multi-agent/<repo-slug>/triage-corpus.jsonl (per repo).
|
|
55
|
+
// (The old default read ~/.claude/logs/.../triage-corpus.jsonl - a flat path
|
|
56
|
+
// nothing writes to - so without --corpus it always found nothing.)
|
|
57
|
+
function resolveRepoSlug() {
|
|
58
|
+
if (opts.repoSlug) return String(opts.repoSlug);
|
|
59
|
+
if (process.env.MULTI_AGENT_REPO_SLUG) return process.env.MULTI_AGENT_REPO_SLUG;
|
|
60
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" });
|
|
61
|
+
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim().split("/").pop();
|
|
62
|
+
return "default";
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
const corpusPath =
|
|
53
|
-
opts.corpus ||
|
|
66
|
+
opts.corpus ||
|
|
67
|
+
join(homedir(), ".claude", "memory", "multi-agent", resolveRepoSlug(), "triage-corpus.jsonl");
|
|
54
68
|
|
|
55
69
|
if (!existsSync(corpusPath)) {
|
|
56
70
|
console.log(`eval-mine-corpus: no corpus at ${corpusPath} - nothing to mine.`);
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
.claude/commands 47
|
|
5
5
|
.claude/lib 27
|
|
6
6
|
.claude/multi-agent-preferences.json 1
|
|
7
|
-
.claude/multi-agent-refs
|
|
7
|
+
.claude/multi-agent-refs 52
|
|
8
8
|
.claude/rules 12
|
|
9
9
|
.claude/schemas 24
|
|
10
|
-
.claude/scripts
|
|
10
|
+
.claude/scripts 199
|
|
11
11
|
.claude/settings.json 1
|
|
12
12
|
.claude/skills 428
|
|
13
13
|
.copilot/.pipeline-version 1
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
.copilot/copilot-instructions.md 1
|
|
16
16
|
.copilot/lib 27
|
|
17
17
|
.copilot/schemas 24
|
|
18
|
-
.copilot/scripts
|
|
18
|
+
.copilot/scripts 199
|
|
19
19
|
.copilot/skills 470
|
|
@@ -64,6 +64,13 @@ if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
|
64
64
|
exit 0
|
|
65
65
|
fi
|
|
66
66
|
REPO="$(cd "$REPO" && pwd -P)"
|
|
67
|
+
# Anchor on the MAIN worktree (first porcelain entry) so a run started from a
|
|
68
|
+
# subdirectory OR from inside a linked worktree still targets
|
|
69
|
+
# <main-repo>/.worktrees/ rather than a bogus <subdir>/.worktrees/ (which
|
|
70
|
+
# silently found nothing and left residue behind).
|
|
71
|
+
MAIN_WT="$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | head -1)"
|
|
72
|
+
MAIN_WT="$(cd "$MAIN_WT" 2>/dev/null && pwd -P)" || MAIN_WT=""
|
|
73
|
+
[ -n "$MAIN_WT" ] && REPO="$MAIN_WT"
|
|
67
74
|
WT_ROOT="$REPO/.worktrees"
|
|
68
75
|
|
|
69
76
|
changed=0
|
|
@@ -76,9 +83,24 @@ pruned=$(git -C "$REPO" worktree prune -v 2>&1 | grep -c . || true)
|
|
|
76
83
|
# 2. Orphan dirs: present under .worktrees/ but not registered as worktrees.
|
|
77
84
|
orphans=()
|
|
78
85
|
if [ -d "$WT_ROOT" ]; then
|
|
79
|
-
|
|
86
|
+
# Resolve every registered worktree path to its physical form so the
|
|
87
|
+
# orphan test below compares like-for-like against `pwd -P`-resolved dirs.
|
|
88
|
+
# A raw (unresolved) registered path with a symlinked component would fail
|
|
89
|
+
# the match and get a HEALTHY worktree misclassified as an orphan and
|
|
90
|
+
# deleted under --yes; resolving both sides removes that risk (this mirrors
|
|
91
|
+
# purge.sh, which resolves both sides).
|
|
92
|
+
registered=""
|
|
93
|
+
while IFS= read -r regpath; do
|
|
94
|
+
[ -n "$regpath" ] || continue
|
|
95
|
+
regphys="$(cd "$regpath" 2>/dev/null && pwd -P)" || regphys=""
|
|
96
|
+
[ -n "$regphys" ] && registered="$registered$regphys
|
|
97
|
+
"
|
|
98
|
+
done <<EOF
|
|
99
|
+
$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
|
|
100
|
+
EOF
|
|
80
101
|
for d in "$WT_ROOT"/*/; do
|
|
81
102
|
[ -d "$d" ] || continue
|
|
103
|
+
[ -L "${d%/}" ] && continue # never follow a symlinked entry out of the tree
|
|
82
104
|
dir="$(cd "$d" && pwd -P)"
|
|
83
105
|
# Path guard: only ever consider dirs strictly inside <repo>/.worktrees/.
|
|
84
106
|
case "$dir" in "$WT_ROOT"/*) ;; *) continue ;; esac
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// learning-curve.mjs - time-bucketed TREND over metrics.jsonl.
|
|
3
|
+
//
|
|
4
|
+
// aggregate-metrics.mjs reports lifetime totals; this reports the CURVE: how
|
|
5
|
+
// the pipeline's per-task quality/cost move over time as it accumulates
|
|
6
|
+
// learnings on a repo. Without a trend you cannot tell whether "it gets better
|
|
7
|
+
// over time" is real - this is the measurement layer for the learning loop.
|
|
8
|
+
//
|
|
9
|
+
// Buckets tasks by the timestamp of their first event, then per bucket reports:
|
|
10
|
+
// tasks, first-pass-clean rate, avg review cycles, rework/task,
|
|
11
|
+
// tokens/task, cache-reuse ratio. Prints a first->last trend for the
|
|
12
|
+
// headline KPIs. Read-only, zero deps, no API/model calls.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// learning-curve.mjs [--bucket=<days>] [--since=ISO] [--json|--markdown]
|
|
16
|
+
// Env: METRICS_FILE overrides the default ~/.claude/logs/multi-agent/metrics.jsonl
|
|
17
|
+
//
|
|
18
|
+
// Exit: 0 always (advisory; empty/missing metrics -> friendly note).
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const opts = { json: false, markdown: false, since: null, bucketDays: 7 };
|
|
26
|
+
for (const a of args) {
|
|
27
|
+
if (a === "--json") opts.json = true;
|
|
28
|
+
else if (a === "--markdown" || a === "--md") opts.markdown = true;
|
|
29
|
+
else if (a.startsWith("--since=")) opts.since = a.slice(8);
|
|
30
|
+
else if (a.startsWith("--bucket=")) opts.bucketDays = Math.max(1, parseInt(a.slice(9), 10) || 7);
|
|
31
|
+
else if (a === "--help" || a === "-h") {
|
|
32
|
+
console.log("Usage: learning-curve.mjs [--bucket=<days>] [--since=ISO] [--json|--markdown]");
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const METRICS_FILE =
|
|
38
|
+
process.env.METRICS_FILE || join(homedir(), ".claude", "logs", "multi-agent", "metrics.jsonl");
|
|
39
|
+
|
|
40
|
+
function emitEmpty(reason) {
|
|
41
|
+
if (opts.json) console.log(JSON.stringify({ ok: false, reason, buckets: [] }));
|
|
42
|
+
else console.error(`learning-curve: ${reason}`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!existsSync(METRICS_FILE)) emitEmpty(`no metrics file at ${METRICS_FILE}`);
|
|
47
|
+
|
|
48
|
+
const events = [];
|
|
49
|
+
for (const line of readFileSync(METRICS_FILE, "utf-8").split("\n")) {
|
|
50
|
+
if (!line.trim()) continue;
|
|
51
|
+
try {
|
|
52
|
+
const e = JSON.parse(line);
|
|
53
|
+
if (opts.since && e.ts && e.ts < opts.since) continue;
|
|
54
|
+
events.push(e);
|
|
55
|
+
} catch {
|
|
56
|
+
/* skip corrupted line */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (events.length === 0) emitEmpty("no metrics events in range");
|
|
60
|
+
|
|
61
|
+
// Fold events into per-task aggregates.
|
|
62
|
+
const num = (v) => (typeof v === "number" ? v : Number(v)) || 0;
|
|
63
|
+
const tasks = new Map();
|
|
64
|
+
for (const e of events) {
|
|
65
|
+
const id = e.task_id;
|
|
66
|
+
if (!id) continue;
|
|
67
|
+
let t = tasks.get(id);
|
|
68
|
+
if (!t) {
|
|
69
|
+
t = { firstTs: e.ts, reviewCycles: null, rework: 0, tokensIn: 0, tokensOut: 0, tokensCached: 0 };
|
|
70
|
+
tasks.set(id, t);
|
|
71
|
+
}
|
|
72
|
+
if (e.ts && (!t.firstTs || e.ts < t.firstTs)) t.firstTs = e.ts;
|
|
73
|
+
const d = e.details || {};
|
|
74
|
+
if (e.event === "review.completed" && d.review_cycles != null) t.reviewCycles = num(d.review_cycles);
|
|
75
|
+
if (e.event === "rework.started") t.rework += 1;
|
|
76
|
+
t.tokensIn += num(d.tokens_in);
|
|
77
|
+
t.tokensOut += num(d.tokens_out);
|
|
78
|
+
t.tokensCached += num(d.tokens_cached);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Bucket tasks by first-seen day / bucketDays window (UTC).
|
|
82
|
+
const DAY = 86400000;
|
|
83
|
+
const bucketOf = (ts) => {
|
|
84
|
+
const ms = Date.parse(ts);
|
|
85
|
+
if (Number.isNaN(ms)) return null;
|
|
86
|
+
const dayIndex = Math.floor(ms / DAY);
|
|
87
|
+
const b = Math.floor(dayIndex / opts.bucketDays) * opts.bucketDays;
|
|
88
|
+
return new Date(b * DAY).toISOString().slice(0, 10);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const byBucket = new Map();
|
|
92
|
+
for (const t of tasks.values()) {
|
|
93
|
+
const key = bucketOf(t.firstTs);
|
|
94
|
+
if (!key) continue;
|
|
95
|
+
if (!byBucket.has(key)) byBucket.set(key, []);
|
|
96
|
+
byBucket.get(key).push(t);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const round = (n, p = 2) => Math.round(n * 10 ** p) / 10 ** p;
|
|
100
|
+
const buckets = [...byBucket.keys()].sort().map((start) => {
|
|
101
|
+
const list = byBucket.get(start);
|
|
102
|
+
const n = list.length;
|
|
103
|
+
const withReview = list.filter((t) => t.reviewCycles != null);
|
|
104
|
+
const firstPassClean = withReview.filter((t) => t.reviewCycles <= 1).length;
|
|
105
|
+
const totIn = list.reduce((s, t) => s + t.tokensIn, 0);
|
|
106
|
+
const totOut = list.reduce((s, t) => s + t.tokensOut, 0);
|
|
107
|
+
const totCached = list.reduce((s, t) => s + t.tokensCached, 0);
|
|
108
|
+
return {
|
|
109
|
+
bucketStart: start,
|
|
110
|
+
tasks: n,
|
|
111
|
+
firstPassCleanRate: withReview.length ? round(firstPassClean / withReview.length) : null,
|
|
112
|
+
avgReviewCycles: withReview.length
|
|
113
|
+
? round(withReview.reduce((s, t) => s + t.reviewCycles, 0) / withReview.length)
|
|
114
|
+
: null,
|
|
115
|
+
reworkPerTask: round(list.reduce((s, t) => s + t.rework, 0) / n),
|
|
116
|
+
tokensPerTask: Math.round((totIn + totOut) / n),
|
|
117
|
+
cacheRatio: totIn ? round(totCached / totIn) : null,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// first->last trend for the headline KPIs (only when >=2 buckets).
|
|
122
|
+
let trend = null;
|
|
123
|
+
if (buckets.length >= 2) {
|
|
124
|
+
const a = buckets[0];
|
|
125
|
+
const z = buckets[buckets.length - 1];
|
|
126
|
+
const delta = (x, y) => (x == null || y == null ? null : round(y - x));
|
|
127
|
+
trend = {
|
|
128
|
+
from: a.bucketStart,
|
|
129
|
+
to: z.bucketStart,
|
|
130
|
+
firstPassCleanRate: delta(a.firstPassCleanRate, z.firstPassCleanRate),
|
|
131
|
+
avgReviewCycles: delta(a.avgReviewCycles, z.avgReviewCycles),
|
|
132
|
+
tokensPerTask: a.tokensPerTask && z.tokensPerTask ? z.tokensPerTask - a.tokensPerTask : null,
|
|
133
|
+
cacheRatio: delta(a.cacheRatio, z.cacheRatio),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const out = { ok: true, bucketDays: opts.bucketDays, buckets, trend };
|
|
138
|
+
|
|
139
|
+
if (opts.json) {
|
|
140
|
+
console.log(JSON.stringify(out, null, 2));
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Markdown / text (default)
|
|
145
|
+
const arrow = (v, goodIsUp) => {
|
|
146
|
+
if (v == null || v === 0) return "-";
|
|
147
|
+
const up = v > 0;
|
|
148
|
+
const good = up === goodIsUp;
|
|
149
|
+
return `${up ? "+" : ""}${v} ${good ? "(better)" : "(worse)"}`;
|
|
150
|
+
};
|
|
151
|
+
console.log(`# Learning curve (${opts.bucketDays}-day buckets)\n`);
|
|
152
|
+
console.log("| Bucket | Tasks | First-pass clean | Avg review cycles | Rework/task | Tokens/task | Cache ratio |");
|
|
153
|
+
console.log("|---|---|---|---|---|---|---|");
|
|
154
|
+
for (const b of buckets) {
|
|
155
|
+
console.log(
|
|
156
|
+
`| ${b.bucketStart} | ${b.tasks} | ${b.firstPassCleanRate ?? "-"} | ${b.avgReviewCycles ?? "-"} | ${b.reworkPerTask} | ${b.tokensPerTask} | ${b.cacheRatio ?? "-"} |`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (trend) {
|
|
160
|
+
console.log(`\n**Trend ${trend.from} -> ${trend.to}:**`);
|
|
161
|
+
console.log(`- First-pass clean: ${arrow(trend.firstPassCleanRate, true)}`);
|
|
162
|
+
console.log(`- Avg review cycles: ${arrow(trend.avgReviewCycles, false)}`);
|
|
163
|
+
console.log(`- Tokens/task: ${arrow(trend.tokensPerTask, false)}`);
|
|
164
|
+
console.log(`- Cache ratio: ${arrow(trend.cacheRatio, true)}`);
|
|
165
|
+
} else {
|
|
166
|
+
console.log("\n(need >= 2 buckets for a trend; keep running the pipeline)");
|
|
167
|
+
}
|
|
@@ -120,7 +120,7 @@ function writeRow(slug, row) {
|
|
|
120
120
|
|
|
121
121
|
/** Append one entry unless an entry with the same kind + normalized statement
|
|
122
122
|
* already exists (idempotent). Returns true when written. */
|
|
123
|
-
function addEntry(slug, { kind, statement, scope, task, confidence }, existing) {
|
|
123
|
+
function addEntry(slug, { kind, statement, scope, task, confidence, diagnosis }, existing) {
|
|
124
124
|
const stmt = String(statement || "").trim();
|
|
125
125
|
if (!stmt) return false;
|
|
126
126
|
const seenKey = `${kind}::${normStatement(stmt)}`;
|
|
@@ -135,6 +135,11 @@ function addEntry(slug, { kind, statement, scope, task, confidence }, existing)
|
|
|
135
135
|
source_task: task || null,
|
|
136
136
|
confidence: CONFIDENCES.has(confidence) ? confidence : "medium",
|
|
137
137
|
};
|
|
138
|
+
// Optional causal diagnosis (Reflexion P5): the "why it failed" behind a
|
|
139
|
+
// lesson. Verbal diagnosis is what turns a detected error into a prevented
|
|
140
|
+
// one; a bare outcome does not. Stored only when supplied.
|
|
141
|
+
const diag = String(diagnosis || "").trim();
|
|
142
|
+
if (diag) row.diagnosis = diag;
|
|
138
143
|
writeRow(slug, row);
|
|
139
144
|
existing.add(seenKey);
|
|
140
145
|
return true;
|
|
@@ -152,6 +157,7 @@ function cmdAdd() {
|
|
|
152
157
|
scope: flags.scope && flags.scope !== true ? String(flags.scope) : "global",
|
|
153
158
|
task: flags.task && flags.task !== true ? String(flags.task) : null,
|
|
154
159
|
confidence: flags.confidence && flags.confidence !== true ? String(flags.confidence) : "medium",
|
|
160
|
+
diagnosis: flags.diagnosis && flags.diagnosis !== true ? String(flags.diagnosis) : null,
|
|
155
161
|
}, existing);
|
|
156
162
|
process.stdout.write(JSON.stringify({ ok: true, slug, added: wrote ? 1 : 0, deduped: wrote ? 0 : 1 }) + "\n");
|
|
157
163
|
process.exit(wrote ? 0 : 2);
|
|
@@ -247,7 +253,8 @@ function cmdBrief() {
|
|
|
247
253
|
lines.push("", `## ${labels[kind]}`);
|
|
248
254
|
for (const r of items) {
|
|
249
255
|
const scope = r.scope && r.scope !== "global" ? ` [${r.scope}]` : "";
|
|
250
|
-
|
|
256
|
+
const why = r.diagnosis ? ` (why: ${r.diagnosis})` : "";
|
|
257
|
+
lines.push(`- ${r.statement}${scope}${why}`);
|
|
251
258
|
}
|
|
252
259
|
}
|
|
253
260
|
lines.push("</repo-learnings>");
|
|
@@ -82,7 +82,7 @@ function parseArgs(argv) {
|
|
|
82
82
|
// ---- File walking ----------------------------------------------------------
|
|
83
83
|
|
|
84
84
|
const DEFAULT_IGNORE = new Set([
|
|
85
|
-
'.git', 'node_modules', 'Pods', '.build', 'DerivedData', '.next',
|
|
85
|
+
'.git', '.worktrees', 'node_modules', 'Pods', '.build', 'DerivedData', '.next',
|
|
86
86
|
'dist', 'build', '__pycache__', '.venv', 'venv', '.cache',
|
|
87
87
|
'.gradle', '.idea', '.vscode', 'target',
|
|
88
88
|
]);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# smoke-learning-curve.sh - contract for learning-curve.mjs (time-bucketed
|
|
3
|
+
# trend over metrics.jsonl).
|
|
4
|
+
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
LC="$SCRIPT_DIR/learning-curve.mjs"
|
|
9
|
+
|
|
10
|
+
PASS=0
|
|
11
|
+
FAIL=0
|
|
12
|
+
record_pass() { PASS=$((PASS + 1)); printf ' \342\234\223 %s\n' "$1"; }
|
|
13
|
+
record_fail() { FAIL=$((FAIL + 1)); printf ' \342\234\227 %s\n' "$1"; }
|
|
14
|
+
|
|
15
|
+
printf '\342\206\222 smoke-learning-curve: metrics trend contract\n'
|
|
16
|
+
|
|
17
|
+
tmp=$(mktemp -d "${TMPDIR:-/tmp}/lc.XXXXXX")
|
|
18
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
19
|
+
M="$tmp/metrics.jsonl"
|
|
20
|
+
|
|
21
|
+
# Two early tasks (noisy: 3/2 review cycles, low cache) + two later tasks
|
|
22
|
+
# (clean: 1 cycle, high cache) -> the curve should improve.
|
|
23
|
+
cat > "$M" <<'EOF'
|
|
24
|
+
{"ts":"2026-06-02T10:00:00Z","task_id":"t1","phase":"4","event":"review.completed","details":{"review_cycles":3,"tokens_in":100000,"tokens_out":8000,"tokens_cached":10000}}
|
|
25
|
+
{"ts":"2026-06-02T10:05:00Z","task_id":"t1","phase":"3","event":"rework.started","details":{}}
|
|
26
|
+
{"ts":"2026-06-03T09:00:00Z","task_id":"t2","phase":"4","event":"review.completed","details":{"review_cycles":2,"tokens_in":90000,"tokens_out":7000,"tokens_cached":20000}}
|
|
27
|
+
{"ts":"2026-06-24T09:00:00Z","task_id":"t3","phase":"4","event":"review.completed","details":{"review_cycles":1,"tokens_in":40000,"tokens_out":4000,"tokens_cached":30000}}
|
|
28
|
+
{"ts":"2026-06-25T09:00:00Z","task_id":"t4","phase":"4","event":"review.completed","details":{"review_cycles":1,"tokens_in":38000,"tokens_out":3500,"tokens_cached":32000}}
|
|
29
|
+
EOF
|
|
30
|
+
|
|
31
|
+
# 1. parses
|
|
32
|
+
bash -n "$SCRIPT_DIR/smoke-learning-curve.sh" >/dev/null 2>&1 || true
|
|
33
|
+
if node --check "$LC" 2>/dev/null; then record_pass "learning-curve.mjs parses"; else record_fail "syntax error"; fi
|
|
34
|
+
|
|
35
|
+
# 2. JSON: buckets + improving trend
|
|
36
|
+
out=$(METRICS_FILE="$M" node "$LC" --json 2>/dev/null)
|
|
37
|
+
nbuckets=$(printf '%s' "$out" | python3 -c "import json,sys;print(len(json.load(sys.stdin)['buckets']))")
|
|
38
|
+
[ "$nbuckets" -ge 2 ] && record_pass "produces >=2 time buckets" || record_fail "expected >=2 buckets, got $nbuckets"
|
|
39
|
+
|
|
40
|
+
tokens_delta=$(printf '%s' "$out" | python3 -c "import json,sys;print(json.load(sys.stdin)['trend']['tokensPerTask'])")
|
|
41
|
+
case "$tokens_delta" in
|
|
42
|
+
-*) record_pass "tokens/task trend is negative (improving)" ;;
|
|
43
|
+
*) record_fail "tokens/task trend should drop, got $tokens_delta" ;;
|
|
44
|
+
esac
|
|
45
|
+
cache_delta=$(printf '%s' "$out" | python3 -c "import json,sys;print(json.load(sys.stdin)['trend']['cacheRatio'])")
|
|
46
|
+
case "$cache_delta" in
|
|
47
|
+
-*|0|0.0) record_fail "cache ratio should rise, got $cache_delta" ;;
|
|
48
|
+
*) record_pass "cache ratio trend rises (improving)" ;;
|
|
49
|
+
esac
|
|
50
|
+
|
|
51
|
+
# 3. markdown renders a table + trend
|
|
52
|
+
md=$(METRICS_FILE="$M" node "$LC" 2>/dev/null)
|
|
53
|
+
printf '%s' "$md" | grep -q "Learning curve" && record_pass "markdown has a header" || record_fail "no markdown header"
|
|
54
|
+
printf '%s' "$md" | grep -q "Trend" && record_pass "markdown shows a trend block" || record_fail "no trend block"
|
|
55
|
+
|
|
56
|
+
# 4. missing metrics -> graceful, exit 0
|
|
57
|
+
METRICS_FILE="/nope/none.jsonl" node "$LC" --json >/dev/null 2>&1
|
|
58
|
+
[ "$?" -eq 0 ] && record_pass "missing metrics is a graceful no-op (exit 0)" || record_fail "missing metrics should exit 0"
|
|
59
|
+
|
|
60
|
+
printf '\n\342\225\220\342\225\220 smoke-learning-curve: %d passed, %d failed \342\225\220\342\225\220\n' "$PASS" "$FAIL"
|
|
61
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -84,6 +84,33 @@ function ensureDir(p) {
|
|
|
84
84
|
mkdirSync(dirname(p), { recursive: true });
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Freshness gate (P1): stamp each finding with the git blob SHA of its file at
|
|
88
|
+
// ingest time; on query, if the file's current content hash differs, the hit is
|
|
89
|
+
// flagged stale so a reused finding is verified, not trusted blindly.
|
|
90
|
+
function repoTop() {
|
|
91
|
+
try {
|
|
92
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
95
|
+
}).trim() || null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fileSha(file, top) {
|
|
102
|
+
if (!file || !top) return null;
|
|
103
|
+
try {
|
|
104
|
+
return execFileSync("git", ["hash-object", "--", file], {
|
|
105
|
+
cwd: top,
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
108
|
+
}).trim() || null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
const STOP = new Set([
|
|
88
115
|
"the","a","an","and","or","but","of","in","on","at","to","for","with","by","is","are",
|
|
89
116
|
"be","been","being","this","that","these","those","it","its","as","not","no","do","does",
|
|
@@ -157,6 +184,7 @@ function ingest() {
|
|
|
157
184
|
catch (e) { die(`cannot parse triage JSON: ${e.message}`, 1); }
|
|
158
185
|
|
|
159
186
|
const ts = new Date().toISOString();
|
|
187
|
+
const top = repoTop();
|
|
160
188
|
const corpus = readCorpus(slug);
|
|
161
189
|
const seen = new Set(corpus.map((r) => `${r.task_id}::${r.classification}::${r.file}::${r.issue}`));
|
|
162
190
|
|
|
@@ -188,6 +216,7 @@ function ingest() {
|
|
|
188
216
|
fix: f.fix || null,
|
|
189
217
|
reason: f._reason || null,
|
|
190
218
|
reviewer: f.reviewer || null,
|
|
219
|
+
file_sha: fileSha(file, top),
|
|
191
220
|
};
|
|
192
221
|
writeRow(slug, row);
|
|
193
222
|
seen.add(key);
|
|
@@ -212,21 +241,30 @@ function query() {
|
|
|
212
241
|
const qTokens = tokenize(issueText);
|
|
213
242
|
const fileRe = fileGlob ? globToRegExp(fileGlob) : null;
|
|
214
243
|
const filtered = fileRe ? corpus.filter((r) => fileRe.test(r.file || "")) : corpus.slice();
|
|
244
|
+
const top = repoTop();
|
|
215
245
|
const ranked = filtered
|
|
216
246
|
.map((r) => ({ row: r, score: score(qTokens, r) }))
|
|
217
247
|
.filter((x) => x.score > 0 || qTokens.size === 0)
|
|
218
248
|
.sort((a, b) => b.score - a.score)
|
|
219
249
|
.slice(0, topN)
|
|
220
|
-
.map(({ row, score }) =>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
250
|
+
.map(({ row, score }) => {
|
|
251
|
+
// Freshness: null when we cannot compare (no stored sha, or file gone /
|
|
252
|
+
// not in a git repo); true means the file changed since the finding was
|
|
253
|
+
// recorded, so verify before reusing.
|
|
254
|
+
const cur = row.file_sha ? fileSha(row.file, top) : null;
|
|
255
|
+
const stale = row.file_sha && cur ? cur !== row.file_sha : null;
|
|
256
|
+
return {
|
|
257
|
+
taskId: row.task_id,
|
|
258
|
+
classification: row.classification,
|
|
259
|
+
severity: row.severity,
|
|
260
|
+
file: row.file,
|
|
261
|
+
line: row.line,
|
|
262
|
+
issue: row.issue,
|
|
263
|
+
reason: row.reason,
|
|
264
|
+
stale,
|
|
265
|
+
score: Math.round(score * 1000) / 1000,
|
|
266
|
+
};
|
|
267
|
+
});
|
|
230
268
|
process.stdout.write(JSON.stringify({ ok: true, slug, total: filtered.length, hits: ranked }) + "\n");
|
|
231
269
|
process.exit(0);
|
|
232
270
|
}
|