@azure-id/orc 2.1.0 → 2.2.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +374 -0
  2. package/README.md +49 -36
  3. package/bin/build-agents.js +7 -5
  4. package/bin/cli.js +1006 -235
  5. package/bin/fix.js +21 -18
  6. package/bin/graph-extract.js +314 -6
  7. package/bin/graph-gain.js +83 -2
  8. package/bin/graph-map.js +23 -1
  9. package/bin/graph-notes.js +1 -1
  10. package/bin/graph-query.js +1362 -37
  11. package/bin/graph-resolve.js +1 -1
  12. package/bin/graph-shard.js +29 -6
  13. package/bin/graph-signals.js +5 -3
  14. package/bin/graph.js +7 -3
  15. package/bin/habit.js +1 -1
  16. package/bin/pricing.json +8 -1
  17. package/bin/trace-write.js +40 -11
  18. package/bin/verify-contracts.js +169 -60
  19. package/bin/verify-package.js +3 -3
  20. package/bin/webui/api.js +8 -0
  21. package/bin/webui/fixtures/extra.js +9 -9
  22. package/bin/webui/fixtures/flow.js +1 -1
  23. package/bin/webui/fixtures/index.js +2 -2
  24. package/bin/webui/fixtures/knowledge.js +8 -1
  25. package/bin/webui/fixtures/pact.js +112 -111
  26. package/bin/webui/fixtures/stats.js +2 -2
  27. package/bin/webui/i18n/en/knowledge.json +2 -0
  28. package/bin/webui/i18n/id/knowledge.json +2 -0
  29. package/bin/webui/js/panels/knowledge.js +17 -0
  30. package/guides/code-graph-benefit.md +116 -0
  31. package/guides/configuration.md +150 -0
  32. package/guides/documents.md +262 -0
  33. package/guides/extra-models.md +448 -0
  34. package/guides/habits-and-gotchas.md +152 -0
  35. package/guides/knowledge-reads.md +190 -0
  36. package/guides/model-selection.md +186 -0
  37. package/guides/rules.md +305 -0
  38. package/guides/status-line.md +344 -0
  39. package/mock-run/a-normal-day.md +2 -2
  40. package/mock-run/extra-slots.md +177 -177
  41. package/mock-run/orc-cli.md +3 -2
  42. package/mock-run/orc-doc.md +452 -448
  43. package/mock-run/orc-fast.md +2 -2
  44. package/mock-run/orc-quick.md +146 -146
  45. package/mock-run/orc.md +4 -4
  46. package/package.json +2 -1
  47. package/templates/agents/MODEL-MAPPING.md +12 -12
  48. package/templates/agents/{orc-executor-haiku-4-5.md → orc-executor-haiku-5-high.md} +21 -8
  49. package/templates/agents/orc-executor-opus-4-7-high.md +17 -5
  50. package/templates/agents/orc-executor-opus-4-7-med.md +17 -5
  51. package/templates/agents/orc-executor-opus-4-8-high.md +17 -5
  52. package/templates/agents/orc-executor-opus-5-high.md +17 -5
  53. package/templates/agents/orc-executor-opus-5-low.md +17 -5
  54. package/templates/agents/orc-executor-opus-5-med.md +17 -5
  55. package/templates/agents/orc-executor-sonnet-4-6-high.md +17 -5
  56. package/templates/agents/orc-executor-sonnet-5-high.md +17 -5
  57. package/templates/agents/orc-executor-sonnet-5-low.md +17 -5
  58. package/templates/agents/orc-executor-sonnet-5-med.md +17 -5
  59. package/templates/agents/{orc-graph-noter-sonnet-4-6-med.md → orc-graph-noter-haiku-5-high.md} +4 -4
  60. package/templates/agents/orc-planner-mini-opus-5-med.md +3 -1
  61. package/templates/agents/orc-planner-mini-sonnet-5-high.md +3 -1
  62. package/templates/agents/orc-planner-opus-5-med.md +3 -1
  63. package/templates/agents/{orc-recon-sonnet-4-6-med.md → orc-recon-sonnet-5-med.md} +4 -4
  64. package/templates/agents/{orc-trace-writer-haiku-4-5.md → orc-trace-writer-haiku-5-high.md} +4 -3
  65. package/templates/commands/orc-doc.md +128 -128
  66. package/templates/commands/orc-export.md +51 -46
  67. package/templates/commands/orc-fast.md +10 -10
  68. package/templates/hooks/README.md +8 -3
  69. package/templates/hooks/orc-effort-guard.js +105 -27
  70. package/templates/hooks/orc-graph-hook.js +252 -4
  71. package/templates/hooks/orc-statusline.js +13 -6
  72. package/templates/hooks/orc-trace.js +1 -1
  73. package/templates/skills/_shared/README.md +2 -2
  74. package/templates/skills/_shared/code-graph.md +185 -251
  75. package/templates/skills/_shared/extra-dispatch.md +4 -4
  76. package/templates/skills/_shared/lane-contract.md +7 -3
  77. package/templates/skills/_shared/opus5-only.md +3 -3
  78. package/templates/skills/_shared/phases/execution.md +13 -11
  79. package/templates/skills/_shared/phases/planning.md +27 -9
  80. package/templates/skills/_shared/phases/preflight.md +3 -3
  81. package/templates/skills/_shared/phases/review.md +4 -3
  82. package/templates/skills/_shared/phases/ship.md +7 -10
  83. package/templates/skills/_shared/phases/summary.md +2 -3
  84. package/templates/skills/_shared/phases/trace-verbs.md +21 -16
  85. package/templates/skills/_shared/phases/trace.md +3 -3
  86. package/templates/skills/_shared/phases/wiki-consult.md +2 -2
  87. package/templates/skills/_shared/pr-templates.md +108 -106
  88. package/templates/skills/_shared/read-ladder.md +8 -2
  89. package/templates/skills/_shared/return-validation.md +10 -6
  90. package/templates/skills/_shared/review-slice.md +1 -1
  91. package/templates/skills/_shared/stack-plan.md +135 -135
  92. package/templates/skills/_shared/wait.md +14 -2
  93. package/templates/skills/context-combiner/SKILL.md +2 -2
  94. package/templates/skills/orc/SKILL.md +7 -7
  95. package/templates/skills/orc/config.md +2 -2
  96. package/templates/skills/orc/examples/full-run-mock.md +7 -7
  97. package/templates/skills/orc/references/effort-and-mode.md +222 -222
  98. package/templates/skills/orc/schemas/planning-output.md +3 -0
  99. package/templates/skills/orc-aftermath/SKILL.md +15 -15
  100. package/templates/skills/orc-analyze/SKILL.md +2 -2
  101. package/templates/skills/orc-analyze-mini/SKILL.md +2 -2
  102. package/templates/skills/orc-boundary/SKILL.md +15 -15
  103. package/templates/skills/orc-boundary/references/card.md +81 -78
  104. package/templates/skills/orc-boundary/references/gate.md +113 -113
  105. package/templates/skills/orc-brainstorm/SKILL.md +15 -15
  106. package/templates/skills/orc-budget/SKILL.md +15 -15
  107. package/templates/skills/orc-challenge/README.md +1 -1
  108. package/templates/skills/orc-challenge/SKILL.md +16 -16
  109. package/templates/skills/orc-challenge/examples/council-full-roster.md +1 -1
  110. package/templates/skills/orc-claude/SKILL.md +4 -4
  111. package/templates/skills/orc-claude/examples/claude-run-mock.md +1 -1
  112. package/templates/skills/orc-diy/SKILL.md +2 -2
  113. package/templates/skills/orc-diy/references/blocks/wiki.md +26 -26
  114. package/templates/skills/orc-diy/references/flow-schema.md +2 -2
  115. package/templates/skills/orc-doc/README.md +2 -2
  116. package/templates/skills/orc-doc/SKILL.md +501 -490
  117. package/templates/skills/orc-doc/examples/orc-doc-prd-run.md +12 -1
  118. package/templates/skills/orc-doc/references/chunking.md +6 -4
  119. package/templates/skills/orc-doc/references/gates.md +311 -311
  120. package/templates/skills/orc-doc/references/resume-protocol.md +229 -228
  121. package/templates/skills/orc-explain/SKILL.md +88 -88
  122. package/templates/skills/orc-export/SKILL.md +190 -187
  123. package/templates/skills/orc-fast/SKILL.md +17 -14
  124. package/templates/skills/orc-grill/SKILL.md +16 -16
  125. package/templates/skills/orc-grill/references/grill-doc.md +1 -1
  126. package/templates/skills/orc-handoff/SKILL.md +11 -11
  127. package/templates/skills/orc-learn/SKILL.md +13 -13
  128. package/templates/skills/orc-learn/examples/learn-run-mock.md +2 -2
  129. package/templates/skills/orc-mini/SKILL.md +12 -12
  130. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -5
  131. package/templates/skills/orc-pact/SKILL.md +15 -15
  132. package/templates/skills/orc-pact/references/gate.md +70 -70
  133. package/templates/skills/orc-pattern/SKILL.md +3 -3
  134. package/templates/skills/orc-poly/SKILL.md +4 -4
  135. package/templates/skills/orc-poly/examples/poly-run-mock.md +51 -51
  136. package/templates/skills/orc-pr-driver/SKILL.md +4 -3
  137. package/templates/skills/orc-pr-driver/references/orc-run-split.md +108 -99
  138. package/templates/skills/orc-pr-setup/SKILL.md +2 -2
  139. package/templates/skills/orc-quick/README.md +11 -12
  140. package/templates/skills/orc-quick/SKILL.md +29 -32
  141. package/templates/skills/orc-quick/references/context-doc.md +5 -5
  142. package/templates/skills/orc-quick/references/dispatch-gate.md +10 -10
  143. package/templates/skills/orc-quick/references/gh-mode.md +2 -1
  144. package/templates/skills/orc-quick/references/look.md +34 -12
  145. package/templates/skills/orc-retro/SKILL.md +2 -2
  146. package/templates/skills/orc-retro/examples/retro-mock.md +2 -2
  147. package/templates/skills/orc-route/SKILL.md +2 -2
  148. package/templates/skills/orc-test/SKILL.md +6 -3
  149. package/templates/skills/orc-verify/SKILL.md +2 -2
  150. package/templates/skills/orc-wait/SKILL.md +10 -2
  151. package/templates/skills/orc-wiki/SKILL.md +4 -4
  152. package/templates/skills/orc-wiki/references/pattern-prewarm.md +19 -19
  153. package/templates/agents/orc-executor-sonnet-4-6-med.md +0 -155
package/bin/fix.js CHANGED
@@ -8,6 +8,8 @@
8
8
  // `/orc-fix` is a RIDER lane, like `/orc-wait` and `/orc-explain`: it opens no
9
9
  // run of its own. So this module NEVER writes `.current` — it only READS it to
10
10
  // find the host run, and appends ONE `FIX` line to that run's trace (DE-14).
11
+ // A host is an OPEN run (6 h) that a lane registered; the hook's bootstrap
12
+ // pointer is not one (v2.1.2, F14).
11
13
  //
12
14
  // The class has two axes (04 §5.2): `source` (what found it) and
13
15
  // `introduced_by` (who wrote the bad lines). Both are kept: a Sonar issue can
@@ -57,17 +59,17 @@ function git(cwd, args) {
57
59
 
58
60
  // The run in flight, READ from `.current` (never written here), and whether a
59
61
  // dispatch is still open — the same sidecar `orc run inflight` reads.
62
+ // v2.1.2 (F14): the pointer goes through `deps.openRunPointer`, the hooks' one
63
+ // idea of an open run (6 hours). A HOST is an open run a LANE registered. The
64
+ // trace hook's bootstrap name (written when /orc-fix's own may-fix executor is
65
+ // dispatched) names no lane, so no lane is there to make the fix. Its open
66
+ // dispatches still block a record.
60
67
  function hostRun(deps, claudeDir) {
61
- const dir = deps.resolveLogDir(claudeDir);
62
- let cur = null;
63
- try {
64
- cur = fs.readFileSync(path.join(dir, ".current"), "utf8").trim();
65
- } catch (_) {}
66
- if (!cur) return { host: null, inflight: "clear", pending: 0 };
67
- const tracePath = path.join(dir, cur);
68
- if (!fs.existsSync(tracePath)) return { host: null, inflight: "clear", pending: 0 };
69
- const m = deps.TRACE_NAME.exec(cur);
70
- const host = { trace: cur, run: cur.replace(/\.txt$/, ""), lane: m ? m[1] : null, slug: m ? m[2] : null, path: tracePath };
68
+ const ptr = deps.openRunPointer(claudeDir);
69
+ if (!ptr || !ptr.exists) return { host: null, open: null, inflight: "clear", pending: 0 };
70
+ const tracePath = ptr.path;
71
+ const ref = { trace: ptr.name, run: ptr.run, lane: ptr.lane, slug: ptr.slug, path: tracePath };
72
+ const host = ptr.lane ? ref : null;
71
73
  let pend = null;
72
74
  try {
73
75
  const raw = JSON.parse(fs.readFileSync(tracePath + ".pending.json", "utf8"));
@@ -75,16 +77,16 @@ function hostRun(deps, claudeDir) {
75
77
  } catch (_) {}
76
78
  const now = Date.now();
77
79
  if (pend) {
78
- const open = pend.filter((r) => !(typeof r.ts === "number" && now - r.ts > deps.inflightStaleMs)).length;
79
- return { host, inflight: open ? "in-flight" : "clear", pending: open };
80
+ const n = pend.filter((r) => !(typeof r.ts === "number" && now - r.ts > deps.inflightStaleMs)).length;
81
+ return { host, open: ref, inflight: n ? "in-flight" : "clear", pending: n };
80
82
  }
81
83
  // No sidecar: trust the trace's own SPAWN/RETURN balance.
82
84
  let balance = 0;
83
85
  try {
84
86
  const text = fs.readFileSync(tracePath, "utf8");
85
- balance = (text.match(/\] hook\s+SPAWN /g) || []).length - (text.match(/\] hook\s+RETURN /g) || []).length + (text.match(/\] hook\s+RETURN ~agent :: unattributed/g) || []).length;
87
+ balance = (text.match(/] hooks+SPAWN /g) || []).length - (text.match(/] hooks+RETURN /g) || []).length + (text.match(/] hooks+RETURN ~agent :: unattributed/g) || []).length;
86
88
  } catch (_) {}
87
- return { host, inflight: balance > 0 ? "in-flight" : "clear", pending: Math.max(0, balance) };
89
+ return { host, open: ref, inflight: balance > 0 ? "in-flight" : "clear", pending: Math.max(0, balance) };
88
90
  }
89
91
 
90
92
  // `git blame` on the named lines → the commit that wrote most of them.
@@ -278,6 +280,7 @@ function classify(deps, claudeDir, text) {
278
280
  commit,
279
281
  evidence,
280
282
  host: run.host ? { trace: run.host.trace, run: run.host.run, lane: run.host.lane, slug: run.host.slug } : null,
283
+ open_run: run.open ? run.open.run : null,
281
284
  inflight: run.inflight,
282
285
  pending: run.pending,
283
286
  // DE-12 (c): a run is open → record only, the host lane makes the fix.
@@ -385,7 +388,7 @@ function fixCmd(deps) {
385
388
  if (!text) return fail(2, "usage", "fix classify: --text \"<what the user said>\" is required\n" + USAGE);
386
389
  const c = classify(deps, claudeDir, text);
387
390
  const code = c.inflight === "in-flight" ? 1 : 0;
388
- if (code) c.message = `a dispatch is in flight in ${c.host.run} (${c.pending} open) — /orc-fix waits for its return`;
391
+ if (code) c.message = `a dispatch is in flight in ${c.open_run} (${c.pending} open) — /orc-fix waits for its return`;
389
392
  return out(c, code, () => {
390
393
  if (code) console.log(`⏸ ${c.message}`);
391
394
  console.log(`proposed: source ${c.source} · introduced by ${c.introduced_by}${c.run ? ` (${c.run})` : ""}`);
@@ -399,7 +402,7 @@ function fixCmd(deps) {
399
402
  if (inp.err) return fail(2, "malformed", `fix record: the input ${inp.err}`, { field: "body" });
400
403
  const run = hostRun(deps, claudeDir);
401
404
  if (run.inflight === "in-flight")
402
- return fail(1, "in-flight", `fix record: a dispatch is in flight in ${run.host.run} (${run.pending} open) — nothing was written. Record after its return.`, { host: run.host.run, pending: run.pending });
405
+ return fail(1, "in-flight", `fix record: a dispatch is in flight in ${run.open.run} (${run.pending} open) — nothing was written. Record after its return.`, { host: run.host ? run.host.run : null, open_run: run.open.run, pending: run.pending });
403
406
  const b = Object.assign({}, inp.value);
404
407
  if (!b.fix_run && run.host) b.fix_run = run.host.run;
405
408
  const t = toObservation(b, now);
@@ -425,7 +428,7 @@ function fixCmd(deps) {
425
428
  // takes the record down with it.
426
429
  let traceLine = null;
427
430
  if (run.host) {
428
- const head = `FIX source=${b.source} introduced_by=${rec.introduced_by} by=${rec.class_by || "evidence"} obs=${rec.obs.slice(0, 8)}${rec.miss ? ` missed_by=${rec.missed_by}` : ""}`;
431
+ const head = `FIX source=${rec.source} introduced_by=${rec.introduced_by} by=${rec.class_by || "evidence"} obs=${rec.obs.slice(0, 8)}${rec.miss ? ` missed_by=${rec.missed_by}` : ""}`;
429
432
  const line = `[${stamp(new Date(now))}] ${"cli".padEnd(8)} ${head} :: ${where}${what ? " " + oneLine(what).slice(0, 80) : ""}`;
430
433
  try {
431
434
  fs.appendFileSync(run.host.path, line + "\n");
@@ -433,7 +436,7 @@ function fixCmd(deps) {
433
436
  } catch (_) {}
434
437
  }
435
438
  const who = rec.introduced_by === "orc" ? `ORC${rec.run ? ` (${rec.run})` : ""}` : rec.introduced_by === "ai" ? "AI-written code" : rec.introduced_by;
436
- const lineOut = `recorded ${id} · ${b.source}${what ? " " + oneLine(what).slice(0, 60) : ""} · introduced by ${who}${rec.miss ? ` · missed by review ${rec.missed_by}` : ""} · the next review sees it`;
439
+ const lineOut = `recorded ${id} · ${rec.source}${what ? " " + oneLine(what).slice(0, 60) : ""} · introduced by ${who}${rec.miss ? ` · missed by review ${rec.missed_by}` : ""} · the next review sees it`;
437
440
  return out(
438
441
  { ok: true, id, obs: rec.obs, observation: rec, miss: !!rec.miss, missed_by: rec.missed_by || null, host: run.host ? run.host.run : null, trace_line: traceLine, promoted: changes.promoted, bumped: changes.bumped, line: lineOut },
439
442
  0,
@@ -56,7 +56,7 @@ const { spawnSync } = require("child_process");
56
56
  // @3 (W9): route handlers are symbols, and a function passed BY NAME is a `ref`.
57
57
  // @4 (EW1): every record says how much of the file the extractor actually saw.
58
58
  // @5 (v1.8.2 W1): urls, mounts, decorator routes, `handler` on a route alias.
59
- const HEURISTIC = "heuristic@6";
59
+ const HEURISTIC = "heuristic@7";
60
60
  const MAX_CALLS_PER_SYMBOL = 200;
61
61
  const MAX_EFFECTS_PER_SYMBOL = 12;
62
62
  const MAX_URLS_PER_SYMBOL = 200;
@@ -1258,6 +1258,8 @@ function objectDefs(c, have, sfc) {
1258
1258
  const top = close > open ? objectMembers(m, open, close).map(([a, b]) => ({ a, b, mem: memberOf(m, a, b) })).filter((x) => x.mem) : [];
1259
1259
  const shaped = top.some(
1260
1260
  (x) => x.mem.type === "fn" || (OBJ_SECTIONS.has(x.mem.name) && (x.mem.type === "obj" || x.mem.type === "short")) || x.mem.name === "mixins" || x.mem.name === "extends"
1261
+ // v2.2.0 T3: inside an SFC, a props-only component is still a component.
1262
+ || (sfc && /^(props|emits|inject|components)$/.test(x.mem.name))
1261
1263
  );
1262
1264
  if (shaped) {
1263
1265
  const nameMem = top.find((x) => x.mem.type === "val" && x.mem.name === "name");
@@ -1273,6 +1275,27 @@ function objectDefs(c, have, sfc) {
1273
1275
  }
1274
1276
  }
1275
1277
  owner = add({ name: oname, qname: oname, kind: "class", from: dx.index, to: close, exported: true, ...(bases.length ? { bases: bases.slice(0, 8) } : {}) });
1278
+ // v2.2.0 T3: the component CONTRACT — `props`, `emits`, `inject`. Each
1279
+ // member is a name-only symbol; like a `const`, it owns no call.
1280
+ for (const x of top) {
1281
+ const kind = CONTRACT_OF[x.mem.name];
1282
+ if (!kind || x.mem.close == null || x.mem.close <= x.mem.open) continue;
1283
+ if (x.mem.type === "arr") {
1284
+ const STR = /(['"`])([A-Za-z_$][\w$-]*)\1/g;
1285
+ const body = src.slice(x.mem.open, x.mem.close);
1286
+ let q;
1287
+ while ((q = STR.exec(body))) {
1288
+ const at = x.mem.open + q.index;
1289
+ if (m[at] !== src[at]) continue;
1290
+ add({ name: q[2], qname: `${oname}.${q[2]}`, kind, from: at, to: at + q[0].length - 1, exported: false });
1291
+ }
1292
+ } else if (x.mem.type === "obj") {
1293
+ for (const [a, b] of objectMembers(m, x.mem.open, x.mem.close)) {
1294
+ const mem = memberOf(m, a, b);
1295
+ if (mem && mem.name) add({ name: mem.name, qname: `${oname}.${mem.name}`, kind, from: a, to: trim(b), exported: false });
1296
+ }
1297
+ }
1298
+ }
1276
1299
  const method = (name, from, to, paren) => {
1277
1300
  if (paren != null) defParens.add(paren);
1278
1301
  add({ name, qname: `${oname}.${name}`, kind: "method", from, to, exported: false });
@@ -1340,7 +1363,271 @@ function objectDefs(c, have, sfc) {
1340
1363
  // visible to every function below it, exactly as it was before W5b.
1341
1364
  if (!taken.has(name)) add({ name, qname: name, kind: "class", from, to, exported: true, nameOnly: true });
1342
1365
  }
1343
- return { defs, defParens };
1366
+ const ownerName = owner ? owner.name : sfc ? ownerStem(rel) : null;
1367
+ return { defs, defParens, ownerName };
1368
+ }
1369
+
1370
+ // ── v2.2.0 T3: what a Vue / SPA codebase says outside a function body ───────
1371
+ // Runs on BOTH rungs, right after the W5b object pass: the template (its
1372
+ // handlers, bindings and child components), lazy `import()`, SPA route
1373
+ // records, router navigation by route NAME, and the string values of an
1374
+ // exported constant object. It edits `defs` and `calls` in place and returns
1375
+ // the import list with the lazy imports appended.
1376
+ const CONTRACT_OF = { props: "prop", emits: "emit", inject: "inject" };
1377
+ const CONTRACT_KINDS = new Set(["prop", "emit", "inject"]);
1378
+ const TPL_SKIP_TAGS = new Set(["router-link", "router-view", "transition", "transition-group", "keep-alive", "component", "slot", "template", "RouterLink", "RouterView", "Transition", "TransitionGroup", "KeepAlive", "Component", "Slot", "Template"]);
1379
+ const TPL_WORDS = new Set(["true", "false", "null", "undefined", "in", "of", "typeof", "instanceof", "new", "void", "this", "NaN", "Infinity"]);
1380
+ const STR_LIT = /^\s*(['"`])([^'"`\n]*)\1/;
1381
+
1382
+ // Blank the inside of every string literal of a template expression, so an
1383
+ // identifier scan never reads a word inside quotes. Length is kept.
1384
+ function blankStrings(e) {
1385
+ return e.replace(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g, (s, q, b) => q + " ".repeat(b.length) + q);
1386
+ }
1387
+
1388
+ function vuePass(c, raw, sfc, defs, calls, imports, ownerName) {
1389
+ const { m, starts } = c;
1390
+ const src = c.src;
1391
+ const lineAt = (p) => lineOf(starts, p);
1392
+ const imps = (imports || []).slice();
1393
+ const code = (p) => m[p] === src[p];
1394
+
1395
+ // ── shape 5: lazy imports ──
1396
+ const lazyAt = new Set();
1397
+ const lazyNames = new Map();
1398
+ const LAZY = /\b(?:const|let|var)[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=\n]+)?=[ \t]*(?:async[ \t]*)?(?:\([ \t]*\)|_)[ \t]*=>[ \t\r\n]*\(?[ \t\r\n]*import[ \t]*\([ \t]*(['"])([^'"\n]+)\2[ \t]*\)/g;
1399
+ let x;
1400
+ while ((x = LAZY.exec(src))) {
1401
+ if (!code(x.index)) continue;
1402
+ const ip = x.index + x[0].lastIndexOf("import");
1403
+ lazyAt.add(ip);
1404
+ const line = lineAt(x.index);
1405
+ lazyNames.set(x[1], line);
1406
+ imps.push({ from: x[3], line, bindings: [{ local: x[1], orig: "default", kind: "default" }] });
1407
+ }
1408
+ const DYN = /\bimport[ \t]*\([ \t]*(['"])([^'"\n]+)\1[ \t]*\)/g;
1409
+ while ((x = DYN.exec(src))) {
1410
+ if (!code(x.index) || lazyAt.has(x.index)) continue;
1411
+ imps.push({ from: x[2], line: lineAt(x.index), bindings: [] });
1412
+ }
1413
+ for (let i = defs.length - 1; i >= 0; i--) {
1414
+ const d = defs[i];
1415
+ if (lazyNames.get(d.name) === d.s && d.kind !== "route" && d.kind !== "class") defs.splice(i, 1);
1416
+ }
1417
+
1418
+ // `const N = 'literal'` in THIS file, for a route name given by constant.
1419
+ const constStr = (name) => {
1420
+ const re = new RegExp(`\\b(?:const|let|var)[ \\t]+${name.replace(/\$/g, "\\$")}[ \\t]*(?::[^=\\n]+)?=[ \\t]*(['"\`])([^'"\`\\n]*)\\1`);
1421
+ const q = re.exec(src);
1422
+ return q && code(q.index) ? q[2] : null;
1423
+ };
1424
+ // A route NAME expression → the call record shape 7 writes.
1425
+ const navCall = (expr, line) => {
1426
+ const e = String(expr).trim();
1427
+ const lit = /^(['"`])([^'"`\n]*)\1$/.exec(e);
1428
+ if (lit) return lit[2] ? { name: lit[2], line, route: true } : null;
1429
+ if (/^[A-Za-z_$][\w$]*$/.test(e)) {
1430
+ const v = constStr(e);
1431
+ return v ? { name: v, line, route: true } : null;
1432
+ }
1433
+ if (/^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/.test(e)) return { name: e, line, route: true, via: true };
1434
+ return null;
1435
+ };
1436
+ const NAME_IN = /(?:^|[{,\s])name[ \t]*:[ \t]*((['"`])[^'"`\n]*\2|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)/;
1437
+
1438
+ // ── shape 7: `router.push({ name: … })` / `this.$router.replace(…)` ──
1439
+ const NAV = /(?:^|[^\w$])((?:[A-Za-z_$][\w$]*[ \t]*\.[ \t]*)*\$?router)[ \t]*\.[ \t]*(?:push|replace)[ \t]*\([ \t\r\n]*\{/g;
1440
+ while ((x = NAV.exec(src))) {
1441
+ const open = x.index + x[0].length - 1;
1442
+ if (!code(open)) continue;
1443
+ const close = matchPairQuiet(m, open, "{", "}");
1444
+ if (close < 0) continue;
1445
+ const n = NAME_IN.exec(src.slice(open + 1, close));
1446
+ if (!n) continue;
1447
+ const rec = navCall(n[1], lineAt(open));
1448
+ if (rec) calls.push(rec);
1449
+ }
1450
+
1451
+ // ── shape 6: SPA route records (js/ts files) ──
1452
+ if (!sfc) {
1453
+ const seenObj = new Set();
1454
+ const PATH = /\bpath[ \t]*:[ \t]*['"`]/g;
1455
+ while ((x = PATH.exec(m))) {
1456
+ // the enclosing `{`
1457
+ let d = 0;
1458
+ let open = -1;
1459
+ for (let k = x.index - 1; k >= 0; k--) {
1460
+ const ch = m[k];
1461
+ if (ch === "}" || ch === "]" || ch === ")") d++;
1462
+ else if (ch === "{" || ch === "[" || ch === "(") {
1463
+ if (d === 0) {
1464
+ open = ch === "{" ? k : -1;
1465
+ break;
1466
+ }
1467
+ d--;
1468
+ }
1469
+ }
1470
+ if (open < 0 || seenObj.has(open)) continue;
1471
+ seenObj.add(open);
1472
+ const close = matchPairQuiet(m, open, "{", "}");
1473
+ if (close < 0) continue;
1474
+ const mems = objectMembers(m, open, close)
1475
+ .map(([a, b]) => ({ a, b, mem: memberOf(m, a, b) }))
1476
+ .filter((y) => y.mem);
1477
+ const get = (k) => mems.find((y) => y.mem.name === k);
1478
+ const pm = get("path");
1479
+ if (!pm || pm.mem.type !== "val") continue;
1480
+ const pl = STR_LIT.exec(src.slice(pm.mem.v, pm.mem.v + 300));
1481
+ if (!pl) continue;
1482
+ const comp = get("component");
1483
+ const compId = comp && comp.mem.type === "val" ? m.slice(comp.mem.v, comp.b).trim() : "";
1484
+ const hasComp = IDENT_RE.test(compId);
1485
+ const nm = get("name");
1486
+ const nl = nm && nm.mem.type === "val" ? STR_LIT.exec(src.slice(nm.mem.v, nm.mem.v + 200)) : null;
1487
+ const ok = hasComp || (get("components") && get("components").mem.type === "obj") || (nl && (get("meta") || get("children") || get("redirect")));
1488
+ if (!ok) continue;
1489
+ let to = close;
1490
+ const def = { kind: "route", method: "VIEW", path: pl[2], name: "route", from: open, to, exported: false, s: lineAt(open), e: lineAt(to) };
1491
+ if (nl) def.route_name = nl[2];
1492
+ defs.push(def);
1493
+ if (hasComp) calls.push({ name: compId, line: lineAt(comp.mem.v), ref: true });
1494
+ }
1495
+ }
1496
+
1497
+ // ── shape 8: the string values of an exported constant object ──
1498
+ for (const d of defs) {
1499
+ if (d.kind !== "const" || !d.exported || d.values) continue;
1500
+ const re = new RegExp(`^[ \\t]*(?:export[ \\t]+)?(?:const|let|var)[ \\t]+${d.name.replace(/\$/g, "\\$")}[ \\t]*(?::[^=\\n]+)?=[ \\t]*\\{`, "m");
1501
+ const q = re.exec(m);
1502
+ if (!q) continue;
1503
+ const open = q.index + q[0].length - 1;
1504
+ const close = matchPairQuiet(m, open, "{", "}");
1505
+ if (close < 0) continue;
1506
+ const values = {};
1507
+ let n = 0;
1508
+ for (const [a, b] of objectMembers(m, open, close)) {
1509
+ if (n >= 64) break;
1510
+ const mem = memberOf(m, a, b);
1511
+ if (!mem || mem.type !== "val") continue;
1512
+ const s = /^(['"`])([^'"`\n]*)\1\s*$/.exec(src.slice(mem.v, b).trim());
1513
+ if (!s) continue;
1514
+ values[mem.name] = s[2].slice(0, 80);
1515
+ n++;
1516
+ }
1517
+ if (n) d.values = values;
1518
+ }
1519
+
1520
+ // ── shapes 1 + 2: the template of a `.vue` file ──
1521
+ if (sfc && !sfc.svelte && raw) {
1522
+ // the outer <template>, outside every <script>
1523
+ const inScript = (p) => sfc.blocks.some(([a, b]) => p >= a && p < b);
1524
+ const TAG = /<(\/?)template\b[^>]*?(\/?)>/gi;
1525
+ let tOpen = -1;
1526
+ let tEnd = -1;
1527
+ let depth = 0;
1528
+ while ((x = TAG.exec(raw))) {
1529
+ if (inScript(x.index) || x[2] === "/") continue;
1530
+ if (!x[1]) {
1531
+ if (depth === 0) tOpen = x.index;
1532
+ depth++;
1533
+ } else if (depth > 0 && --depth === 0) {
1534
+ tEnd = x.index + x[0].length - 1;
1535
+ break;
1536
+ }
1537
+ }
1538
+ if (tOpen >= 0 && tEnd > tOpen && ownerName) {
1539
+ const tdef = { name: "template", qname: `${ownerName}.template`, kind: "template", from: tOpen, to: tEnd, exported: false, s: lineAt(tOpen), e: lineAt(tEnd), _raw: raw.slice(starts[lineAt(tOpen) - 1] || 0, tEnd + 1) };
1540
+ defs.push(tdef);
1541
+ const body = raw.slice(tOpen, tEnd + 1);
1542
+ const base = tOpen;
1543
+ const seen = new Set();
1544
+ const push = (rec) => {
1545
+ const k = `${rec.name}|${rec.line}|${rec.self ? 1 : 0}|${rec.ref ? 1 : 0}|${rec.route ? 1 : 0}`;
1546
+ if (seen.has(k)) return;
1547
+ seen.add(k);
1548
+ calls.push(rec);
1549
+ };
1550
+ const bad = (w) => !w || w[0] === "$" || KW.has(w) || TPL_WORDS.has(w) || REF_WORDS.has(w) || /^\d/.test(w);
1551
+ // v-for aliases are template locals, never a member of the component.
1552
+ const locals = new Set();
1553
+ const VFOR = /\sv-for[ \t]*=[ \t]*(["'])([^"']*?)\s+(?:in|of)\s/g;
1554
+ while ((x = VFOR.exec(body))) for (const w of x[2].replace(/[()]/g, " ").split(/[\s,]+/)) if (w) locals.add(w);
1555
+ const refsOf = (expr, at) => {
1556
+ const e = blankStrings(expr);
1557
+ const ID = /[A-Za-z_$][\w$]*/g;
1558
+ let q;
1559
+ while ((q = ID.exec(e))) {
1560
+ const w = q[0];
1561
+ let p = q.index - 1;
1562
+ while (p >= 0 && /\s/.test(e[p])) p--;
1563
+ if (p >= 0 && (e[p] === "." || /[\w$]/.test(e[p]))) continue;
1564
+ let n = q.index + w.length;
1565
+ while (n < e.length && /\s/.test(e[n])) n++;
1566
+ // an object key: `{ name: … }`
1567
+ if (e[n] === ":" && (p < 0 || e[p] === "{" || e[p] === ",")) continue;
1568
+ if (bad(w) || locals.has(w)) continue;
1569
+ push({ name: w, line: lineAt(at + q.index), self: true, ref: true });
1570
+ }
1571
+ };
1572
+ const ATTR = /\s(@[\w.:\-[\]]+|v-on:[\w.:\-[\]]+|:[\w.\-[\]]+|v-bind:[\w.\-[\]]+|v-(?:if|else-if|show|model|html|text|for)(?::[\w-]+)?(?:\.[\w-]+)*)[ \t]*=[ \t]*(?:"([^"]*)"|'([^']*)')/g;
1573
+ while ((x = ATTR.exec(body))) {
1574
+ const attr = x[1];
1575
+ const val = x[2] != null ? x[2] : x[3];
1576
+ const at = base + x.index + x[0].length - 1 - val.length;
1577
+ if (attr[0] === "@" || attr.startsWith("v-on:")) {
1578
+ const t = val.trim();
1579
+ if (/^[A-Za-z_$][\w$]*$/.test(t)) {
1580
+ if (!bad(t)) push({ name: t, line: lineAt(at + val.indexOf(t)), self: true });
1581
+ continue;
1582
+ }
1583
+ const e = blankStrings(val);
1584
+ const CALLRE = /[A-Za-z_$][\w$]*(?=[ \t]*\()/g;
1585
+ let q;
1586
+ while ((q = CALLRE.exec(e))) {
1587
+ let p = q.index - 1;
1588
+ while (p >= 0 && /\s/.test(e[p])) p--;
1589
+ if (p >= 0 && (e[p] === "." || /[\w$]/.test(e[p]))) continue;
1590
+ if (bad(q[0])) continue;
1591
+ push({ name: q[0], line: lineAt(at + q.index), self: true });
1592
+ }
1593
+ continue;
1594
+ }
1595
+ if (attr.startsWith("v-for")) {
1596
+ const f = /^([\s\S]*?\s)(?:in|of)\s([\s\S]*)$/.exec(val);
1597
+ if (f) refsOf(f[2], at + f[1].length + 3);
1598
+ continue;
1599
+ }
1600
+ refsOf(val, at);
1601
+ }
1602
+ const MUS = /\{\{([\s\S]*?)\}\}/g;
1603
+ while ((x = MUS.exec(body))) refsOf(x[1], base + x.index + 2);
1604
+ // child components, and <router-link :to="{ name: … }">
1605
+ const local = new Set();
1606
+ for (const imp of imps) for (const b of imp.bindings || []) local.add(b.local);
1607
+ const OPEN = /<([A-Za-z][\w-]*)\b([^>]*)>/g;
1608
+ while ((x = OPEN.exec(body))) {
1609
+ const tag = x[1];
1610
+ const line = lineAt(base + x.index);
1611
+ if (tag === "router-link" || tag === "RouterLink") {
1612
+ const to = /\s(?::to|v-bind:to)[ \t]*=[ \t]*(?:"([^"]*)"|'([^']*)')/.exec(x[2]);
1613
+ const v = to ? (to[1] != null ? to[1] : to[2]) : null;
1614
+ const n = v ? NAME_IN.exec(v) : null;
1615
+ const rec = n ? navCall(n[1], line) : null;
1616
+ if (rec) push(rec);
1617
+ continue;
1618
+ }
1619
+ if (TPL_SKIP_TAGS.has(tag)) continue;
1620
+ if (!/^[A-Z]/.test(tag) && !tag.includes("-")) continue;
1621
+ const pascal = tag
1622
+ .split("-")
1623
+ .filter(Boolean)
1624
+ .map((w) => w[0].toUpperCase() + w.slice(1))
1625
+ .join("");
1626
+ if (local.has(pascal)) push({ name: pascal, line, ref: true });
1627
+ }
1628
+ }
1629
+ }
1630
+ return imps;
1344
1631
  }
1345
1632
 
1346
1633
  // ── declarations: Python (indentation) ──────────────────────────────────────
@@ -1633,6 +1920,10 @@ function normCall(name) {
1633
1920
  sup = true;
1634
1921
  }
1635
1922
  }
1923
+ // v2.2.0 T3: `this.$t(…)`, `this.$router.push(…)`, `this.$emit(…)` — a
1924
+ // framework built-in hung on the instance. Stripping the `$` made `this.$t`
1925
+ // a call to `t`, which then resolved into whatever file defined a `t`.
1926
+ if (self && /^\$[A-Za-z_]/.test(n)) return null;
1636
1927
  n = n.replace(/^\$/, "").replace(/\.\$/g, ".");
1637
1928
  const last = n.split(".").pop();
1638
1929
  // A keyword after a dot is a METHOD name — `router.delete(`, `promise.catch(` —
@@ -2301,7 +2592,7 @@ function finalize(c, lang, defs, calls, imports, exportsSet, extractor) {
2301
2592
  // stays with whatever holds the constant — the module, most often — so a
2302
2593
  // constant symbol always carries `calls: []`.
2303
2594
  for (const i of order) {
2304
- if (defs[i].kind === "const" || defs[i].nameOnly) continue;
2595
+ if (defs[i].kind === "const" || CONTRACT_KINDS.has(defs[i].kind) || defs[i].nameOnly) continue;
2305
2596
  for (let L = defs[i].s; L <= defs[i].e && L <= nLines; L++) owner[L] = i;
2306
2597
  }
2307
2598
 
@@ -2409,6 +2700,8 @@ function finalize(c, lang, defs, calls, imports, exportsSet, extractor) {
2409
2700
  effects: [],
2410
2701
  ...(doc ? { doc } : {}),
2411
2702
  ...(d.route ? { route: d.route } : {}),
2703
+ ...(d.route_name ? { route_name: d.route_name } : {}),
2704
+ ...(d.values ? { values: d.values } : {}),
2412
2705
  ...(d.bases && d.bases.length ? { bases: d.bases } : {}),
2413
2706
  };
2414
2707
  });
@@ -2441,6 +2734,8 @@ function finalize(c, lang, defs, calls, imports, exportsSet, extractor) {
2441
2734
  ...(cc.sup ? { sup: true } : {}),
2442
2735
  ...(cc.ref ? { ref: true } : {}),
2443
2736
  ...(cc.alias ? { alias: cc.alias } : {}),
2737
+ ...(cc.route ? { route: true } : {}),
2738
+ ...(cc.via ? { via: true } : {}),
2444
2739
  });
2445
2740
  else cut(t, cc.line);
2446
2741
  if (cc.ref || !cc.args) continue;
@@ -2491,6 +2786,8 @@ function finalize(c, lang, defs, calls, imports, exportsSet, extractor) {
2491
2786
  s.body_hash = hash(src.slice(a, b));
2492
2787
  assignId(s);
2493
2788
  }
2789
+ // v2.2.0 T3: a template is blanked in `src`; its hash comes from the markup.
2790
+ for (let i = 0; i < defs.length; i++) if (defs[i]._raw != null && syms[i]) syms[i].body_hash = hash(defs[i]._raw);
2494
2791
  // W1: the alias ROUTE symbol for a decorated handler. Same lines and the same
2495
2792
  // body hash as the handler; it CALLS the handler (one LOCAL edge on read), so
2496
2793
  // `impact` and `ctx` walk test → route → handler with no special case.
@@ -2576,6 +2873,7 @@ function sfcSource(src) {
2576
2873
 
2577
2874
  function extractOne(item, borrowed) {
2578
2875
  let { lang, src } = item;
2876
+ const raw = src;
2579
2877
  resetCoverage();
2580
2878
  let sfc = null;
2581
2879
  if (lang === "vue" || lang === "svelte") {
@@ -2609,14 +2907,19 @@ function extractOne(item, borrowed) {
2609
2907
  // W5b: the walker records a method only inside a class. An object's
2610
2908
  // members, the constants and an SFC's component come from the same pass
2611
2909
  // the heuristic rung runs, so both rungs name the same symbols.
2612
- if (lang === "js" || lang === "ts") defs.push(...objectDefs(c, defs, sfc).defs);
2910
+ let imps = borrowed.imports;
2911
+ if (lang === "js" || lang === "ts") {
2912
+ const o = objectDefs(c, defs, sfc);
2913
+ defs.push(...o.defs);
2914
+ imps = vuePass(c, raw, sfc, defs, calls, imps, o.ownerName);
2915
+ }
2613
2916
  for (const p of regParens) reg.add(lineOf(c.starts, p));
2614
2917
  for (const cc of calls) if (reg.has(cc.line) && ARGS_WORTH.test(cc.name)) cc.reg = true;
2615
2918
  defs.sort((a, b) => a.from - b.from);
2616
2919
  // `module.exports = { … }` is not in the AST walk; the heuristic half
2617
2920
  // that just ran read it out of the source, so the two sets are merged.
2618
2921
  const exp = new Set([...(borrowed.exportsSet || []), ...(hexports || [])]);
2619
- return finalize(c, lang, defs, calls, borrowed.imports, exp.size ? exp : null, borrowed.extractor);
2922
+ return finalize(c, lang, defs, calls, imps, exp.size ? exp : null, borrowed.extractor);
2620
2923
  }
2621
2924
  // Django routes are module-level calls the ast visitor reports with their
2622
2925
  // argument pieces; the heuristic finds them by regex. Same shape either way.
@@ -2636,15 +2939,20 @@ function extractOne(item, borrowed) {
2636
2939
  return finalize(c, lang, defs, scanCalls(c, defParens, regParens), importsFor(lang, c.src, c.m, c.starts), null, HEURISTIC);
2637
2940
  }
2638
2941
  const { defs, defParens, regParens, exportsSet } = braceDefs(c, lang);
2942
+ let ownerNameOf = null;
2639
2943
  if (lang === "js" || lang === "ts") {
2640
2944
  const o = objectDefs(c, defs, sfc);
2945
+ ownerNameOf = o.ownerName;
2641
2946
  defs.push(...o.defs);
2642
2947
  // APPENDED, never sorted in: a file with no object keeps every symbol in
2643
2948
  // the order 1.9.0 wrote it, so its record is the same bytes.
2644
2949
  for (const p of o.defParens) defParens.add(p);
2645
2950
  }
2646
2951
  braceDecorators(c, lang, defs);
2647
- return finalize(c, lang, defs, scanCalls(c, defParens, regParens), importsFor(lang, c.src, c.m, c.starts), exportsSet, HEURISTIC);
2952
+ const calls = scanCalls(c, defParens, regParens);
2953
+ let imps = importsFor(lang, c.src, c.m, c.starts);
2954
+ if (lang === "js" || lang === "ts") imps = vuePass(c, raw, sfc, defs, calls, imps, ownerNameOf);
2955
+ return finalize(c, lang, defs, calls, imps, exportsSet, HEURISTIC);
2648
2956
  }
2649
2957
 
2650
2958
  // ── the borrowed Python parser ──────────────────────────────────────────────
package/bin/graph-gain.js CHANGED
@@ -27,7 +27,8 @@
27
27
  // executor-window delta, with N and the spread beside it)
28
28
  // · never count a hook coverage note as avoided anything — it adds, it saves
29
29
  // nothing, and it is listed as paid only
30
- // · never count a card the agent's return marked `graph_used: none`
30
+ // · NOT DONE YET: a card the agent's return marked `graph_used: none` is not
31
+ // a saving, but the meter cannot see a return, so `avoided` still counts it
31
32
  // · never block a read: the ledger is appended AFTER the answer is out, and a
32
33
  // failed append is silent
33
34
 
@@ -261,13 +262,83 @@ function stampMs(at) {
261
262
  // `hint`, `read-note` and `hook-update` are the hook's rows, not reads.
262
263
  const READ_SET = ["ctx", "for-slice", "impact", "map", "changes", "cochange", "coverage", "path"];
263
264
 
265
+ // v2.2.0 (G-07): a run is named three ways — `run-quick-x-091026-134917.txt`
266
+ // (the trace file), without `.txt`, and without `run-` (the slug a lane
267
+ // prints). All three are the same run. Both sides are normalised; when they
268
+ // still differ, the same `-DDMMYY-HHMMSS` stamp at the end is the same run.
269
+ function runKey(name) {
270
+ return String(name || "")
271
+ .split(/[\\/]/)
272
+ .pop()
273
+ .replace(/\.txt$/, "")
274
+ .replace(/^run-/, "");
275
+ }
276
+ function sameRun(a, b) {
277
+ if (!a || !b) return false;
278
+ const x = runKey(a);
279
+ const y = runKey(b);
280
+ if (x === y) return true;
281
+ const sx = /-(\d{6}-\d{6})$/.exec(x);
282
+ const sy = /-(\d{6}-\d{6})$/.exec(y);
283
+ return !!(sx && sy && sx[1] === sy[1]);
284
+ }
285
+
286
+ // v2.2.0 (G-08): the identity of one `changes` answer — its sorted symbol ids
287
+ // with their body hashes. The same key twice in one run is the same diff read
288
+ // twice, and the second read avoided nothing.
289
+ function changesKey(symbols) {
290
+ return (symbols || [])
291
+ .map((x) => `${x.id}@${x.body_hash || ""}`)
292
+ .sort()
293
+ .join("|");
294
+ }
295
+ function changesSeen(claudeDir, run, key) {
296
+ if (!run || !key) return false;
297
+ const rows = readLedger(claudeDir) || [];
298
+ return rows.some((r) => r.cmd === "changes" && r.key === key && sameRun(r.run, run));
299
+ }
300
+
301
+ // G-08: `changes` prices ONLY the symbols with a confident caller OUTSIDE the
302
+ // changed files, and only those callers. A symbol nobody outside reaches adds 0.
303
+ function changesPriced(model, symbols, changedFiles, nameHits) {
304
+ const changed = new Set(changedFiles || []);
305
+ let hits = 0;
306
+ let n = 0;
307
+ const callerFiles = [];
308
+ for (const x of symbols || []) {
309
+ const outside = (x.caller_files || []).filter((f) => !changed.has(f));
310
+ if (!outside.length) continue;
311
+ n++;
312
+ hits += Number(nameHits(x)) || 0;
313
+ callerFiles.push(...outside);
314
+ }
315
+ if (!n) return { avoided: { low: 0, high: 0, calls_low: 0, calls_high: 0 }, basis: { files: [], grep_hits: 0 }, priced: 0 };
316
+ return { ...changesCost(model, n, hits, callerFiles), priced: n };
317
+ }
318
+
319
+ // v2.2.0 (T6): the executor PACK. Done well, the executor would have read
320
+ // nothing it did not need (low 0); done badly — what the Vue eval measured —
321
+ // it read every reference file and every big declared file whole. The pack's
322
+ // own tokens come off that, never below 0.
323
+ function packCost(model, refFiles, bigDeclared, packTokens) {
324
+ const cs = [...new Set([...(refFiles || []), ...(bigDeclared || [])])].map((rel) => fileCost(model, rel));
325
+ const whole = cs.reduce((a, c) => a + c.tokens, 0);
326
+ return {
327
+ avoided: { low: 0, high: Math.max(0, whole - (Number(packTokens) || 0)), calls_low: 0, calls_high: cs.length },
328
+ basis: { files: (refFiles || []).map((rel) => strip(fileCost(model, rel))), grep_hits: 0 },
329
+ };
330
+ }
331
+
332
+ // The outcome of each read (G-19). A row written before 2.2.0 has none.
333
+ const OUTCOMES = ["ok", "empty", "declined", "error"];
334
+
264
335
  function gain(claudeDir, opts) {
265
336
  const o = opts || {};
266
337
  const all = readLedger(claudeDir);
267
338
  if (!all) return { ok: false, state: "none", reason: "no-ledger", exit: 1 };
268
339
  const sinceMs = parseSince(o.since);
269
340
  let rows = all;
270
- if (o.run) rows = rows.filter((r) => r.run === o.run);
341
+ if (o.run) rows = rows.filter((r) => sameRun(r.run, o.run));
271
342
  if (sinceMs != null) rows = rows.filter((r) => {
272
343
  const t = stampMs(r.at);
273
344
  return t == null || t >= sinceMs;
@@ -290,6 +361,7 @@ function gain(claudeDir, opts) {
290
361
  // W8: rows the sharded read path could not size. Their estimate omits the
291
362
  // search term, so a FALLING estimate can be told apart from a falling saving.
292
363
  let unsized = 0;
364
+ const outcomes = { ok: 0, empty: 0, declined: 0, error: 0 };
293
365
  const gens = [];
294
366
  const runs = new Set();
295
367
  for (const r of rows) {
@@ -306,6 +378,7 @@ function gain(claudeDir, opts) {
306
378
  calls.high += Number(r.avoided.calls_high) || 0;
307
379
  }
308
380
  if (r.basis && r.basis.grep_hits === null) unsized++;
381
+ if (OUTCOMES.includes(r.outcome)) outcomes[r.outcome]++;
309
382
  byCommand[r.cmd] = (byCommand[r.cmd] || 0) + 1;
310
383
  if (r.cmd === "hint") hints.injected++;
311
384
  if (r.cmd === "read-note") hints.read_notes++;
@@ -333,6 +406,7 @@ function gain(claudeDir, opts) {
333
406
  read_set: READ_SET,
334
407
  never_called: READ_SET.filter((c) => !Object.prototype.hasOwnProperty.call(byCommand, c)),
335
408
  hints,
409
+ outcomes,
336
410
  unsized,
337
411
  estimate: true,
338
412
  carry_note: "a card is re-sent on every later turn of the agent that received it — and so is the read it replaced. Both halves carry the same multiplier, so these are FIRST-ENTRY tokens on both sides (eval/graph-replay.js).",
@@ -421,4 +495,11 @@ module.exports = {
421
495
  reset,
422
496
  stampMs,
423
497
  parseSince,
498
+ runKey,
499
+ sameRun,
500
+ changesKey,
501
+ changesSeen,
502
+ changesPriced,
503
+ packCost,
504
+ OUTCOMES,
424
505
  };