@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 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.4.0",
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",
@@ -63,6 +63,7 @@ budget_exhausted() {
63
63
 
64
64
  # ---- skip dirs ----------------------------------------------------------------
65
65
  SKIP_PRUNE_ARGS=(
66
+ -path '*/.worktrees' -prune -o
66
67
  -path '*/.build' -prune -o
67
68
  -path '*/DerivedData' -prune -o
68
69
  -path '*/Pods' -prune -o
@@ -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 contains a `.git` file, so a blanket `git add -A` / `git add .` in the parent tree records `.worktrees/{id}` as a gitlink ("Subproject commit ...") that pollutes the branch and later needs a manual removal commit. Keep `.worktrees/` out of the index via the clone-local exclude file (never committed, so no PR noise in shared repos):
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 root cause: what class of mistake produced the finding and why (<=140 chars)>" \
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, skip). Lessons re-enter Phase 1 + triage via `learnings-ledger.mjs brief`.
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": 50000,
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/logs/multi-agent/triage-corpus.jsonl` (one line per finding:
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 || join(homedir(), ".claude", "logs", "multi-agent", "triage-corpus.jsonl");
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 51
7
+ .claude/multi-agent-refs 52
8
8
  .claude/rules 12
9
9
  .claude/schemas 24
10
- .claude/scripts 197
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 197
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
- registered=$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
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
- lines.push(`- ${r.statement}${scope}`);
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
- taskId: row.task_id,
222
- classification: row.classification,
223
- severity: row.severity,
224
- file: row.file,
225
- line: row.line,
226
- issue: row.issue,
227
- reason: row.reason,
228
- score: Math.round(score * 1000) / 1000,
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
  }