@dzhechkov/harness-core 0.4.1 → 0.4.3

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 (64) hide show
  1. package/.dz-manifest.json +136 -56
  2. package/README.md +4 -2
  3. package/dist/backlog.d.ts +35 -0
  4. package/dist/backlog.d.ts.map +1 -1
  5. package/dist/backlog.js +167 -3
  6. package/dist/backlog.js.map +1 -1
  7. package/dist/feature-adr-checkpoints.d.ts +48 -3
  8. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  9. package/dist/feature-adr-checkpoints.js +85 -24
  10. package/dist/feature-adr-checkpoints.js.map +1 -1
  11. package/dist/index.d.ts +4 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +4 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/loop-blobs.generated.js +9 -9
  16. package/dist/loop-blobs.generated.js.map +1 -1
  17. package/dist/loop-plan-graph.d.ts +49 -0
  18. package/dist/loop-plan-graph.d.ts.map +1 -0
  19. package/dist/loop-plan-graph.js +128 -0
  20. package/dist/loop-plan-graph.js.map +1 -0
  21. package/dist/loop-plan.d.ts.map +1 -1
  22. package/dist/loop-plan.js +13 -15
  23. package/dist/loop-plan.js.map +1 -1
  24. package/dist/loop-render.d.ts.map +1 -1
  25. package/dist/loop-render.js +95 -16
  26. package/dist/loop-render.js.map +1 -1
  27. package/dist/loop-trace.d.ts +26 -1
  28. package/dist/loop-trace.d.ts.map +1 -1
  29. package/dist/loop-trace.js +65 -1
  30. package/dist/loop-trace.js.map +1 -1
  31. package/dist/model-recommender.d.ts +91 -0
  32. package/dist/model-recommender.d.ts.map +1 -0
  33. package/dist/model-recommender.js +186 -0
  34. package/dist/model-recommender.js.map +1 -0
  35. package/dist/registry.d.ts.map +1 -1
  36. package/dist/registry.js +4 -1
  37. package/dist/registry.js.map +1 -1
  38. package/dist/statusline.d.ts +10 -2
  39. package/dist/statusline.d.ts.map +1 -1
  40. package/dist/statusline.js +122 -36
  41. package/dist/statusline.js.map +1 -1
  42. package/dist/trace-bundle.d.ts +209 -0
  43. package/dist/trace-bundle.d.ts.map +1 -0
  44. package/dist/trace-bundle.js +601 -0
  45. package/dist/trace-bundle.js.map +1 -0
  46. package/dist/usage.d.ts +7 -0
  47. package/dist/usage.d.ts.map +1 -1
  48. package/dist/usage.js +30 -2
  49. package/dist/usage.js.map +1 -1
  50. package/package.json +5 -5
  51. package/sbom.json +255 -55
  52. package/src/backlog.ts +176 -3
  53. package/src/feature-adr-checkpoints.ts +103 -4
  54. package/src/index.ts +4 -1
  55. package/src/loop-blobs.generated.ts +9 -9
  56. package/src/loop-plan-graph.ts +132 -0
  57. package/src/loop-plan.ts +13 -15
  58. package/src/loop-render.ts +93 -17
  59. package/src/loop-trace.ts +78 -1
  60. package/src/model-recommender.ts +228 -0
  61. package/src/registry.ts +4 -1
  62. package/src/statusline.ts +117 -30
  63. package/src/trace-bundle.ts +743 -0
  64. package/src/usage.ts +42 -2
@@ -0,0 +1,132 @@
1
+ /**
2
+ * loop-plan-graph (idea d25a3c8a) — the COMPLETENESS leg of loop-plan/1's closed-world checking.
3
+ *
4
+ * What existed before this module (the round-7 cross-family reviewer's ONE not-met bar item,
5
+ * SIGNOFF's "B-not-A reason 1"): `KNOWN_KEYS === INJECT` and the honesty test's `SCANNED` roster
6
+ * all compare artifacts DOWNSTREAM of FIELD_DOMAINS — equality proves the rosters are consistent
7
+ * with each other, never that they are COMPLETE against the interface source. The reviewer's
8
+ * constructive counterexample: declare `LoopStep.extra?: ExtraPolicy`, add only the parent
9
+ * `{t:'record'}` domain entry, and `extra: { enabeld: true }` escapes every check while every
10
+ * equality guard stays green — "a new record kind cannot escape is unproven and demonstrably
11
+ * false" (verbatim). The shipped mitigation was a documented four-step extension discipline — a
12
+ * layer-4 instruction, exactly the layer the cost-of-detection ladder says such a check must not
13
+ * live on.
14
+ *
15
+ * THE FIX (this module, layer 1): walk the interface graph from `LoopPlan` in the SOURCE TEXT,
16
+ * transitively collect every reachable named interface, and let the honesty test require that the
17
+ * reachable set is exactly the wired set. An interface reachable from LoopPlan but absent from the
18
+ * wiring fails BY CONSTRUCTION, naming itself — no memory, no discipline, no fourth manual step.
19
+ *
20
+ * PURE: operates on source text handed in by the caller; no fs, no clock. That is what lets the
21
+ * acceptance test run the reviewer's counterexample against a SABOTAGED COPY of the source and
22
+ * require a red, while the real source stays green.
23
+ */
24
+
25
+ /** One parsed field: its name and the DECLARED interface names its type text references. */
26
+ export interface GraphField {
27
+ readonly field: string;
28
+ readonly refs: readonly string[];
29
+ }
30
+
31
+ /** interface name → its fields (index signatures like `[xKey: \`x-${string}\`]` are excluded:
32
+ * they open no named-interface edge and are the extension escape hatch by design). */
33
+ export type InterfaceGraph = ReadonlyMap<string, readonly GraphField[]>;
34
+
35
+ /** Brace-matched interface extraction. A regex-only scan truncates at the first nested brace
36
+ * (inline object fields are everywhere in this file), so bodies are cut by depth counting. */
37
+ export function parseInterfaceGraph(source: string): InterfaceGraph {
38
+ const names = new Set<string>();
39
+ const headRe = /(?:^|\n)\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/g;
40
+ for (let m = headRe.exec(source); m !== null; m = headRe.exec(source)) names.add(m[1]!);
41
+
42
+ const graph = new Map<string, GraphField[]>();
43
+ headRe.lastIndex = 0;
44
+ for (let m = headRe.exec(source); m !== null; m = headRe.exec(source)) {
45
+ const name = m[1]!;
46
+ const open = source.indexOf('{', m.index + m[0].length);
47
+ if (open === -1) continue;
48
+ let depth = 0;
49
+ let close = -1;
50
+ for (let i = open; i < source.length; i += 1) {
51
+ const ch = source[i];
52
+ if (ch === '{') depth += 1;
53
+ else if (ch === '}') {
54
+ depth -= 1;
55
+ if (depth === 0) { close = i; break; }
56
+ }
57
+ }
58
+ if (close === -1) continue;
59
+ const body = source.slice(open + 1, close);
60
+
61
+ // Split the body into top-level entries at depth 0 (`;` inside an inline `{...}` must not cut).
62
+ const entries: string[] = [];
63
+ let entry = '';
64
+ let d = 0;
65
+ for (const ch of body) {
66
+ if (ch === '{' || ch === '(' || ch === '<' || ch === '[') d += 1;
67
+ else if (ch === '}' || ch === ')' || ch === '>' || ch === ']') d -= 1;
68
+ if (ch === ';' && d === 0) { entries.push(entry); entry = ''; continue; }
69
+ entry += ch;
70
+ }
71
+ if (entry.trim() !== '') entries.push(entry);
72
+
73
+ const fields: GraphField[] = [];
74
+ for (const raw of entries) {
75
+ // strip comments, then match `readonly? name?: TYPE`
76
+ const text = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '').trim();
77
+ if (text === '' || text.startsWith('[')) continue; // index signature — by-design escape hatch
78
+ const fm = /^(?:readonly\s+)?([A-Za-z_$][\w$]*)\s*\??:\s*([\s\S]+)$/.exec(text);
79
+ if (fm === null) continue;
80
+ const typeText = fm[2]!;
81
+ const refs = new Set<string>();
82
+ const idRe = /[A-Za-z_$][\w$]*/g;
83
+ for (let im = idRe.exec(typeText); im !== null; im = idRe.exec(typeText)) {
84
+ if (names.has(im[0]) && im[0] !== name) refs.add(im[0]);
85
+ }
86
+ fields.push({ field: fm[1]!, refs: [...refs] });
87
+ }
88
+ graph.set(name, fields);
89
+ }
90
+ return graph;
91
+ }
92
+
93
+ /** Every interface reachable from `root` (inclusive), via any field's declared-interface refs —
94
+ * arrays, unions and nullables all count: `LoopStep[]`, `RetryProfile | null` open the same edge. */
95
+ export function reachableInterfaces(graph: InterfaceGraph, root: string): string[] {
96
+ const seen = new Set<string>();
97
+ const queue = [root];
98
+ while (queue.length > 0) {
99
+ const name = queue.shift()!;
100
+ if (seen.has(name) || !graph.has(name)) continue;
101
+ seen.add(name);
102
+ for (const f of graph.get(name)!) for (const ref of f.refs) if (!seen.has(ref)) queue.push(ref);
103
+ }
104
+ return [...seen].sort();
105
+ }
106
+
107
+ export interface GraphWiringReport {
108
+ readonly ok: boolean;
109
+ /** Reachable from the root but NOT in the wired roster — each one is exactly the reviewer's
110
+ * counterexample: a record kind whose key space is open while every equality guard stays green. */
111
+ readonly unwired: string[];
112
+ readonly reachable: string[];
113
+ /** Wired but no longer reachable — a stale roster entry (the reverse rot). */
114
+ readonly stale: string[];
115
+ }
116
+
117
+ /** The completeness check the equality guards could not perform: reachable(source) vs wired. */
118
+ export function checkGraphWiring(source: string, wired: readonly string[], root = 'LoopPlan'): GraphWiringReport {
119
+ const graph = parseInterfaceGraph(source);
120
+ const reachable = reachableInterfaces(graph, root);
121
+ const wiredSet = new Set(wired);
122
+ const reachableSet = new Set(reachable);
123
+ const unwired = reachable.filter((n) => !wiredSet.has(n));
124
+ // The wired roster (KNOWN_KEYS) legitimately mixes interface names with INLINE-record FIELD names
125
+ // (`artifacts`, `budget`, `checkpointing`, …) — those are the inlineSubFields machinery's
126
+ // business, not this check's. Staleness is judged only for entries that ARE declared interfaces
127
+ // in this source: a declared-but-unreachable interface in the roster is real rot; an inline field
128
+ // name is not an interface and must not be reported as one (caught on the first live run: five
129
+ // false stale entries, all inline fields).
130
+ const stale = [...wiredSet].filter((n) => graph.has(n) && !reachableSet.has(n)).sort();
131
+ return { ok: unwired.length === 0 && stale.length === 0, unwired, reachable, stale };
132
+ }
package/src/loop-plan.ts CHANGED
@@ -389,22 +389,20 @@ export const FIELD_DOMAINS: Record<string, FieldDomain> = {
389
389
  // domain entry fails the honesty test; adding it WITH one makes it known here automatically. There
390
390
  // is exactly one roster, and it is the source's.
391
391
  //
392
- // WHAT THIS DOES AND DOES NOT PROVE (QE round-7, the cross-family reviewer's ONE not-met bar item,
393
- // CONCEDED). PROVEN, and tested: every record path CURRENTLY WIRED here is closed no key of any
394
- // present-day spelling reaches the plan without a diagnostic, and the accepted roster is the
395
- // source's, not a second hand-list. NOT PROVEN, and it would be an overclaim to say otherwise: that
396
- // a record kind added in the FUTURE is closed AUTOMATICALLY. Three things remain hand-bounded —
397
- // (a) the honesty test's `SCANNED` names the interfaces it scans, (b) the fuzz's `INJECT` names the
398
- // injection sites, and (c) `checkKeys`' parser DESCENT names which nested records it walks. The
399
- // reviewer's constructive counterexample: declare `LoopStep.extra?: ExtraPolicy` with only a
400
- // `LoopStep.extra: {t:'record'}` domain entry and an `ExtraPolicy { enabled?: boolean }` interface
401
- // unless someone ALSO hand-adds ExtraPolicy's own domains, the descent, `SCANNED` and `INJECT`, then
402
- // `extra: { enabeld: true }` escapes closed-world checking WHILE the equality assertions stay green.
403
- // That is a future-extension / proof-maintenance hole, not an input bypass that works today.
392
+ // WHAT THIS PROVES (updated 2026-08-17, idea d25a3c8a the round-7 not-met bar item is now MET).
393
+ // PROVEN, and tested: every record path wired here is closed, the accepted roster is the source's,
394
+ // AND the roster is COMPLETE against the interface graph: `loop-plan-graph.ts` walks the interfaces
395
+ // reachable from LoopPlan in this file's SOURCE and the honesty test requires reachable == wired ==
396
+ // SCANNED. The reviewer's constructive counterexample (`LoopStep.extra?: ExtraPolicy` with a
397
+ // parent-only domain entry) is the ACCEPTANCE TEST: it goes red naming ExtraPolicy, by
398
+ // construction, before any hand step. Still out of scope, said plainly: interfaces referenced only
399
+ // through type ALIASES are followed one identifier deep (the graph collects declared-interface
400
+ // names from the field's type text); an alias chain that hides an interface behind a non-interface
401
+ // alias would need the alias declared in this file to be walked.
404
402
  //
405
- // EXTENSION DISCIPLINE (the manual step the derivation does not perform for you). When you add a
406
- // NEW nested record-typed field, do ALL FOUR in the same change, or the key space it opens is not
407
- // closed: 1) add `<NewIface>.<field>` entries to FIELD_DOMAINS for every field of the new interface
403
+ // EXTENSION CONVENIENCE (no longer load-bearing the graph-completeness test reddens on a missed
404
+ // step by construction; this list just tells you what the red means). When you add a NEW nested
405
+ // record-typed field: 1) add `<NewIface>.<field>` entries to FIELD_DOMAINS for every field of the new interface
408
406
  // (not just the `{t:'record'}` entry on its PARENT); 2) add the new interface to the honesty test's
409
407
  // `SCANNED`; 3) add an `INJECT` site for it in the closed-world fuzz; 4) descend into it in
410
408
  // `checkKeys`. A structural fix that derives 2–4 from the interface graph — so a new record kind is
@@ -149,6 +149,7 @@ function landedBarrier(id: string, phase: string, writes: string[], pad: string)
149
149
  `${pad} const probeCmd = 'cd ' + shqRt(TRACE_DIR === null ? '.' : TRACE_DIR) + ${jsString(' && ' + testExpr + ' && echo LANDED || echo NOT-LANDED')}`,
150
150
  `${pad} let landed = false`,
151
151
  `${pad} for (let p = 0; p < 5 && !landed; p++) {`,
152
+ `${pad} __agentCalls++`,
152
153
  `${pad} const probe = await agent('Run EXACTLY this one shell command via your Bash tool and reply with ONLY its raw stdout: ' + probeCmd, { label: ${jsString('landed:' + id)}, phase: ${jsString(phase)}, effort: 'low' }) // loop-lint: infra-agent`,
153
154
  `${pad} landed = typeof probe === 'string' && probe.indexOf('NOT-LANDED') === -1 && probe.indexOf('LANDED') !== -1`,
154
155
  `${pad} }`,
@@ -283,8 +284,8 @@ function renderStep(v: StepPlanView, plan: LoopPlan, env: RenderEnv): string {
283
284
  .map((d) => `r_${ident(d)}`);
284
285
  const hashParts = `[P_${ident(id)}${depVars.length > 0 ? ', ' + depVars.join(', ') : ''}]`;
285
286
  const artifactRel = writes.length > 0 ? JSON.stringify(writes) : 'null';
286
- const tpLine = (pad: string): string =>
287
- `${pad}await __tpCapture(${jsString(id)}, ${jsString(s.phase)}, P_${ident(id)}, r_${ident(id)}, ${typeof s.model === 'string' && s.model !== '' ? jsString(s.model) : 'null'})`;
287
+ const tpLine = (pad: string, resumed: boolean): string =>
288
+ `${pad}await __tpCapture(${jsString(id)}, ${jsString(s.phase)}, P_${ident(id)}, r_${ident(id)}, ${typeof s.model === 'string' && s.model !== '' ? jsString(s.model) : 'null'}, ${resumed ? 'true' : 'false'})`;
288
289
 
289
290
  // ROUND-6 B3 SHAPE: `let r_x` + (optional) dispatch-fn declaration form the await-free
290
291
  // preamble; then exactly ONE settle-routed try wraps EVERY await this step performs —
@@ -303,13 +304,14 @@ function renderStep(v: StepPlanView, plan: LoopPlan, env: RenderEnv): string {
303
304
  lines.push(` if (__live !== true && __ckptResume(${jsString(id)}, __h, ${artifactRel})) {`);
304
305
  lines.push(` r_${ident(id)} = __ckptEntries[${jsString(id)}].result`);
305
306
  lines.push(` log(${jsString(`checkpoint: step ${id} RESUMED (fingerprint+artifact match) — dispatch skipped`)})`);
307
+ if (tp) lines.push(tpLine(' ', true));
306
308
  lines.push(` return r_${ident(id)}`);
307
309
  lines.push(` }`);
308
310
  }
309
311
  lines.push(` r_${ident(id)} = await ${runExpr}`);
310
312
  if (writes.length > 0) lines.push(...landedBarrier(id, s.phase, writes, ' '));
311
313
  if (ckpt) lines.push(` await __ckptAppend(${jsString(id)}, ${jsString(s.phase)}, __h, r_${ident(id)})`);
312
- if (tp) lines.push(tpLine(' '));
314
+ if (tp) lines.push(tpLine(' ', false));
313
315
  lines.push(` return r_${ident(id)}`);
314
316
  lines.push(`}`);
315
317
  }
@@ -320,16 +322,17 @@ function renderStep(v: StepPlanView, plan: LoopPlan, env: RenderEnv): string {
320
322
  lines.push(` if (__ckptResume(${jsString(id)}, __h_${ident(id)}, ${artifactRel})) {`);
321
323
  lines.push(` r_${ident(id)} = __ckptEntries[${jsString(id)}].result`);
322
324
  lines.push(` log(${jsString(`checkpoint: step ${id} RESUMED (fingerprint+artifact match) — dispatch skipped`)})`);
325
+ if (tp) lines.push(tpLine(' ', true));
323
326
  lines.push(` } else {`);
324
327
  lines.push(` r_${ident(id)} = await ${runExpr}`);
325
328
  if (writes.length > 0) lines.push(...landedBarrier(id, s.phase, writes, ' '));
326
329
  lines.push(` await __ckptAppend(${jsString(id)}, ${jsString(s.phase)}, __h_${ident(id)}, r_${ident(id)})`);
327
- if (tp) lines.push(tpLine(' '));
330
+ if (tp) lines.push(tpLine(' ', false));
328
331
  lines.push(` }`);
329
332
  } else {
330
333
  lines.push(` r_${ident(id)} = await ${runExpr}`);
331
334
  if (writes.length > 0) lines.push(...landedBarrier(id, s.phase, writes, ' '));
332
- if (tp) lines.push(tpLine(' '));
335
+ if (tp) lines.push(tpLine(' ', false));
333
336
  }
334
337
  } else {
335
338
  lines.push(` await __dispatch_${ident(id)}()`);
@@ -353,6 +356,7 @@ function renderStep(v: StepPlanView, plan: LoopPlan, env: RenderEnv): string {
353
356
  if (routeIsTerminal) {
354
357
  lines.push(` if (__v_${ident(id)} !== 'pass') {`);
355
358
  lines.push(` // typed terminal failure route (plan gates[].failRoute) — a NAMED phase, never a silent pass; settled durably through the single exit (round-5 B3)`);
359
+ lines.push(` await __ledgerAppend(${jsString(s.phase)}, ${jsString((route as string).slice('terminal:'.length))})`);
356
360
  lines.push(` return await __settleStep({ stepId: ${jsString(id)}, phase: ${jsString(s.phase)}, outcome: 'terminal', value: { phase: ${jsString(route as string)}, gate: ${jsString(id)}, verdict: __v_${ident(id)} } })`);
357
361
  lines.push(` }`);
358
362
  } else {
@@ -373,8 +377,10 @@ function renderStep(v: StepPlanView, plan: LoopPlan, env: RenderEnv): string {
373
377
  if (pause?.payloadSchema !== undefined) {
374
378
  // enacts pauses[].payloadSchema: the pause return CARRIES the declared payload shape, so the
375
379
  // re-invoking caller sees what the resume arg must contain.
380
+ lines.push(` await __ledgerAppend(${jsString(s.phase)}, ${jsString(pauseState)})`);
376
381
  lines.push(` return await __settleStep({ stepId: ${jsString(id)}, phase: ${jsString(s.phase)}, outcome: 'terminal', value: { phase: ${jsString(pauseState)}, resumeArg: ${jsString(resumeArg)}, payloadSchema: ${JSON.stringify(pause.payloadSchema)} } })`);
377
382
  } else {
383
+ lines.push(` await __ledgerAppend(${jsString(s.phase)}, ${jsString(pauseState)})`);
378
384
  lines.push(` return await __settleStep({ stepId: ${jsString(id)}, phase: ${jsString(s.phase)}, outcome: 'terminal', value: { phase: ${jsString(pauseState)}, resumeArg: ${jsString(resumeArg)} } })`);
379
385
  }
380
386
  lines.push(`}`);
@@ -481,8 +487,17 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
481
487
  lines.push(`const RUN_ID = (typeof A.runId === 'string' && /^[a-z0-9-]{1,40}$/.test(A.runId)) ? A.runId : 'run-1'`);
482
488
  lines.push(`const TRACE_DIR = (typeof A.traceDir === 'string' && A.traceDir.charAt(0) === '/') ? A.traceDir.replace(/\\/+$/, '') : null`);
483
489
  lines.push(`const TRACE_FILE = TRACE_DIR === null ? null : TRACE_DIR + '/trace.jsonl'`);
490
+ lines.push(`const REPO_DIR = (typeof A.repo === 'string' && A.repo.charAt(0) === '/') ? A.repo.replace(/\\/+$/, '') : null`);
491
+ lines.push(`const DZ_BIN = (typeof A.dz === 'string' && A.dz !== '') ? A.dz : 'dz'`);
492
+ lines.push(`const LOOP_SLUG = ${jsString(plan.name)}`);
484
493
  lines.push(`// budget guard — spent BEFORE every spawn; retries consume budget (lint: budget-before-spawn)`);
485
494
  lines.push(`const __budget = { left: ${budgetTotal} }`);
495
+ lines.push(`// Total agent invocations this run made — model dispatches AND infra agents. The ledger's`);
496
+ lines.push(`// \`agents\` column means agent_count from the completion notification (ALL subagents), so the`);
497
+ lines.push(`// automated row must count every dispatch, never the trace's model-dispatch subset (QE F1).`);
498
+ lines.push(`let __agentCalls = 0`);
499
+ lines.push(`let __ledgerDone = false`);
500
+ if (traceOn) lines.push(`let __faLegWarned = false`);
486
501
  lines.push(`function __spendBudget(stepId) { if (__budget.left <= 0) { throw new Error('loop budget exhausted before ' + stepId) } __budget.left-- }`);
487
502
  lines.push(`const __hooks = { onDispatch: null, onSettle: null }`);
488
503
  lines.push(`const __settled = {}`);
@@ -580,7 +595,11 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
580
595
  lines.push(`// SECONDARY event; it never replaces the primary outcome — success included (the ha-consilium`);
581
596
  lines.push(`// totality lesson at the flush layer).`);
582
597
  lines.push(`async function __settleStep(o) {`);
583
- lines.push(` try { await __traceFlushNow(o.phase) } catch (_fe) { log('settle flush for ' + o.stepId + ' threw: ' + __errText(_fe) + ' — primary outcome preserved') }`);
598
+ lines.push(` try { await __traceFlushNow(o.phase, o.stepId) } catch (_fe) { log('settle flush for ' + o.stepId + ' threw: ' + __errText(_fe) + ' — primary outcome preserved') }`);
599
+ if (plan.subsystems?.trainingPairs === true) {
600
+ lines.push(` // The ONE producer of the captureFailures channel on terminal values — the four terminal call sites never carry the key, so future routes inherit it.`);
601
+ lines.push(` if (o.outcome === 'terminal' && o.value !== null && typeof o.value === 'object') { o.value.captureFailures = __captureFailures }`);
602
+ }
584
603
  lines.push(` if (o.outcome === 'failed') { throw o.error }`);
585
604
  lines.push(` return o.value`);
586
605
  lines.push(`}`);
@@ -588,7 +607,7 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
588
607
  lines.push(`// secondary event, never a replaced outcome (the naked await __traceFlushNow at phase`);
589
608
  lines.push(`// boundaries was the round-5 success-replacement hole).`);
590
609
  lines.push(`async function __phaseFlush(phaseName) {`);
591
- lines.push(` try { await __traceFlushNow(phaseName) } catch (_fe) { log('phase flush threw: ' + __errText(_fe) + ' — outcome preserved (flush failure is secondary)') }`);
610
+ lines.push(` try { await __traceFlushNow(phaseName, null) } catch (_fe) { log('phase flush threw: ' + __errText(_fe) + ' — outcome preserved (flush failure is secondary)') }`);
592
611
  lines.push(`}`);
593
612
  lines.push(`// join failures route through the single exit too (joinRegion throws; the wrapper settles)`);
594
613
  lines.push(`async function __joinSettled(joinStepId, phaseName, results, o) {`);
@@ -620,6 +639,7 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
620
639
  lines.push(` let value = null`);
621
640
  lines.push(` let outcome = 'ok'`);
622
641
  lines.push(` try {`);
642
+ lines.push(` __agentCalls++`);
623
643
  lines.push(` value = await thunk()`);
624
644
  lines.push(` if (value === null || value === undefined) outcome = 'null'`);
625
645
  lines.push(` } catch (err) {`);
@@ -706,15 +726,36 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
706
726
  lines.push(`const __traceState = traceInit(RUN_ID, PLAN_DIGEST, EXEC_FP)`);
707
727
  lines.push(`__hooks.onDispatch = function (e) { return traceOnDispatch(__traceState, e) }`);
708
728
  lines.push(`__hooks.onSettle = function (e) { return traceOnSettle(__traceState, e) }`);
709
- lines.push(`async function __traceFlushNow(phaseName) {`);
729
+ lines.push(`async function __traceFlushNow(phaseName, stepLabel) {`);
710
730
  lines.push(` if (TRACE_FILE === null) { return }`);
711
- lines.push(` const cmd = traceFlushCmd(__traceState, TRACE_FILE)`);
731
+ lines.push(` // cmd must be let: the trace payload stays LEFT and must never be replaced by the fa-record panel leg; both ride the SAME writer agent.`);
732
+ lines.push(` let cmd = traceFlushCmd(__traceState, TRACE_FILE)`);
712
733
  lines.push(` if (cmd === null) { return }`);
734
+ lines.push(` const fa = traceFaRecordCmd(DZ_BIN, LOOP_SLUG, (typeof stepLabel === 'string' && stepLabel !== '') ? stepLabel : phaseName, REPO_DIR)`);
735
+ lines.push(` if (fa !== null) { cmd = cmd + ' && { ' + fa + ' || true; }' }`);
736
+ lines.push(` else if (REPO_DIR === null && !__faLegWarned) { __faLegWarned = true; log('fa-record leg skipped — the live panel was not updated because no args.repo was given (the trace flush still runs)') }`);
713
737
  lines.push(` // the flush agent is infra, not a step (it would otherwise recurse) // loop-lint: infra-agent`);
738
+ lines.push(` __agentCalls++`);
714
739
  lines.push(` await agent('Run EXACTLY this one shell command via your Bash tool and reply with only OK: ' + cmd, { label: 'trace:flush', phase: phaseName, effort: 'low' }) // loop-lint: infra-agent`);
715
740
  lines.push(`}`);
741
+ lines.push(`async function __ledgerAppend(phaseName, outcome) {`);
742
+ lines.push(` if (__ledgerDone) { return } __ledgerDone = true`);
743
+ lines.push(` // Ledger telemetry is SECONDARY: this whole body is total and can never fail the run.`);
744
+ lines.push(` try {`);
745
+ lines.push(` if (REPO_DIR === null) { log('ledger:append skipped — ledger row was not written because no args.repo was given'); return }`);
746
+ lines.push(` // + 1 is THIS ledger writer, which is about to be invoked and not yet counted.`);
747
+ lines.push(` const line = traceLedgerLine({ slug: LOOP_SLUG, runId: RUN_ID, planDigest: PLAN_DIGEST, agents: __agentCalls + 1, outcome: outcome, date: A.date })`);
748
+ lines.push(` if (line === null) { log('ledger:append skipped — traceLedgerLine returned null'); return }`);
749
+ lines.push(` const cmd = traceLedgerAppendCmd(REPO_DIR, line)`);
750
+ lines.push(` if (cmd === null) { log('ledger:append skipped — traceLedgerAppendCmd returned null'); return }`);
751
+ lines.push(` __agentCalls++`);
752
+ lines.push(` const reply = await agent('Run EXACTLY this one shell command via your Bash tool and reply with only its stdout: ' + cmd, { label: 'ledger:append', phase: phaseName, effort: 'low' }) // loop-lint: infra-agent`);
753
+ lines.push(` if (!/LEDGER-OK/.test(String(reply))) { log('ledger:append UNVERIFIED — ledger row write was not confirmed; run continues') }`);
754
+ lines.push(` } catch (_le) { log('ledger:append failed as a SECONDARY event: ' + __errText(_le) + ' — run continues') }`);
755
+ lines.push(`}`);
716
756
  } else {
717
- lines.push(`async function __traceFlushNow(phaseName) { /* trace.emit=false — no trace plane; fitness-suite verification is NOT claimable for this loop */ }`);
757
+ lines.push(`async function __traceFlushNow(phaseName, stepLabel) { /* trace.emit=false — no trace plane; fitness-suite verification is NOT claimable for this loop */ }`);
758
+ lines.push(`async function __ledgerAppend(phaseName, outcome) { /* trace off — no agents counted, no ledger row */ }`);
718
759
  }
719
760
  if (ckptOn) {
720
761
  lines.push(`// checkpoint wiring (blob-provided pure half; the read/write agents are infra) — the resume`);
@@ -738,6 +779,7 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
738
779
  lines.push(` if (CKPT_DIR === null) { log('checkpointing enabled but no traceDir given — running LIVE; nothing resumes, nothing persists (named, never silent)'); return }`);
739
780
  lines.push(` try {`);
740
781
  lines.push(` const cmd = checkpointReadCmd(TRACE_DIR)`);
782
+ lines.push(` __agentCalls++`);
741
783
  lines.push(` const out = await agent('Run EXACTLY this one shell command via your Bash tool and reply with ONLY its raw stdout: ' + cmd, { label: 'ckpt:read', phase: phaseName, effort: 'low' }) // loop-lint: infra-agent`);
742
784
  lines.push(` const parsed = parseCheckpointRead(typeof out === 'string' ? out : '')`);
743
785
  lines.push(` __ckptEntries = parsed.entries`);
@@ -760,6 +802,7 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
760
802
  lines.push(` if (line === null) { log('checkpoint: ' + stage + ' not persisted (null/oversize/unserializable — named, never silent)'); return }`);
761
803
  lines.push(` try {`);
762
804
  lines.push(` const cmd = checkpointAppendCmd(TRACE_DIR, line)`);
805
+ lines.push(` __agentCalls++`);
763
806
  lines.push(` await agent('Run EXACTLY this one shell command via your Bash tool and reply with only OK: ' + cmd, { label: 'ckpt:write:' + stage, phase: phaseName, effort: 'low' }) // loop-lint: infra-agent`);
764
807
  lines.push(` } catch (_ce) { log('checkpoint append for ' + stage + ' threw: ' + __errText(_ce) + ' — run continues (the step outcome stands; the next run re-runs this step)') }`);
765
808
  lines.push(`}`);
@@ -776,15 +819,41 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
776
819
  lines.push(`// SUCCESSFUL step (the step's own catch settled it as failed). The whole capture — pair`);
777
820
  lines.push(`// construction, serialization and write — now rides ONE catch, the same discipline as`);
778
821
  lines.push(`// __errText/__phaseFlush: a capture failure is a SECONDARY logged event, never an outcome.`);
779
- lines.push(`async function __tpCapture(stage, phaseName, input, output, model) {`);
822
+ lines.push(`const __captureFailures = []`);
823
+ lines.push(`async function __tpCapture(stage, phaseName, input, output, model, resumed) {`);
780
824
  lines.push(` if (TRACE_DIR === null) { return }`);
825
+ lines.push(` let __captureMode = null`);
781
826
  lines.push(` try {`);
782
- lines.push(` const pair = buildTrainingPair({ slug: RUN_ID, stage: stage, ts: null, input: input, output: output, evaluation: null, provenance: { model: model === null ? 'unknown' : model, role: stage } })`);
827
+ lines.push(` // enabled is true because this entire wiring block is gated at render time by the subsystem opt-in.`);
828
+ lines.push(` const recordCount = output === null || output === undefined ? 0 : 1`);
829
+ lines.push(` const mode = decideCaptureMode({ enabled: true, resumed: resumed === true, recordCount: recordCount })`);
830
+ lines.push(` __captureMode = mode`);
831
+ lines.push(` if (mode === 'skip-disabled') { return }`);
832
+ lines.push(` if (mode === 'skip-empty') { log('training-pair: ' + stage + ' not captured (null/undefined output — named, never silent)'); __captureFailures.push(captureFailureRecord(stage, mode, 'empty-output', null)); return }`);
833
+ lines.push(` const pair = buildTrainingPair({ slug: RUN_ID, stage: stage, ts: null, input: input, output: output, evaluation: null, provenance: { model: model === null ? 'unknown' : model, role: stage }, captureMode: mode === 'backfill' ? 'backfill' : 'capture', resumed: resumed === true })`);
783
834
  lines.push(` const line = serializeTrainingPair(pair)`);
784
- lines.push(` if (line === null) { log('training-pair: ' + stage + ' not captured (unserializable) — named, never silent'); return }`);
785
- lines.push(` const cmd = trainingPairAppendCmd(TRACE_DIR, RUN_ID, stage, line)`);
786
- lines.push(` await agent('Run EXACTLY this one shell command via your Bash tool and reply with only OK: ' + cmd, { label: 'tp:write:' + stage, phase: phaseName, effort: 'low' }) // loop-lint: infra-agent`);
787
- lines.push(` } catch (_ce) { log('training-pair capture for ' + stage + ' threw: ' + __errText(_ce) + ' — run continues (capture is never load-bearing)') }`);
835
+ lines.push(` if (line === null) { log('training-pair: ' + stage + ' not captured (unserializable) — named, never silent'); __captureFailures.push(captureFailureRecord(stage, mode, 'unserializable', null)); return }`);
836
+ lines.push(` if (mode === 'capture') {`);
837
+ lines.push(` const cmd = trainingPairAppendCmd(TRACE_DIR, RUN_ID, stage, line)`);
838
+ lines.push(` __agentCalls++`);
839
+ lines.push(` await agent('Run EXACTLY this one shell command via your Bash tool and reply with only OK: ' + cmd, { label: 'tp:write:' + stage, phase: phaseName, effort: 'low' }) // loop-lint: infra-agent`);
840
+ lines.push(` return`);
841
+ lines.push(` }`);
842
+ lines.push(` if (mode === 'backfill') {`);
843
+ lines.push(` // Exclude pair.slug (RUN_ID) and pair.ts (null) from the mark key: normalized input/output`);
844
+ lines.push(` // identify the pair across runIds, which is the cross-run at-most-once property.`);
845
+ lines.push(` const markKey = fnv1a64(stage + '\\0' + pair.input + '\\0' + pair.output)`);
846
+ lines.push(` const cmd = trainingPairBackfillCmd(TRACE_DIR, RUN_ID, stage, [line], markKey)`);
847
+ lines.push(` __agentCalls++`);
848
+ lines.push(` const readback = await agent('Run EXACTLY this one shell command via your Bash tool and reply with ONLY its raw stdout: ' + cmd, { label: 'tp:backfill:' + stage, phase: phaseName, effort: 'low' }) // loop-lint: infra-agent`);
849
+ lines.push(` const status = typeof readback === 'string' ? readback.trim() : ''`);
850
+ lines.push(` if (status === TP_BACKFILL_OK) { log('training-pair: ' + stage + ' backfilled from the checkpoint'); return }`);
851
+ lines.push(` if (status === TP_BACKFILL_SKIP) { log('training-pair: ' + stage + ' pair file already existed; nothing written'); return }`);
852
+ lines.push(` if (status === TP_BACKFILL_DUP) { log('training-pair: ' + stage + ' another run already captured this pair; nothing written'); return }`);
853
+ lines.push(` log('training-pair: ' + stage + ' checkpoint backfill UNVERIFIED: ' + __errText(readback))`);
854
+ lines.push(` __captureFailures.push(captureFailureRecord(stage, mode, 'backfill-unverified', readback))`);
855
+ lines.push(` }`);
856
+ lines.push(` } catch (_ce) { log('training-pair capture for ' + stage + ' threw: ' + __errText(_ce) + ' — run continues (capture is never load-bearing)'); __captureFailures.push(captureFailureRecord(stage, __captureMode, 'threw', __errText(_ce))) }`);
788
857
  lines.push(`}`);
789
858
  }
790
859
  if (plan.steps.some((s) => s.kind === 'gate')) {
@@ -966,14 +1035,17 @@ export function renderPlan(plan: LoopPlan): RenderResult {
966
1035
 
967
1036
  const header = `// ── LOOP-PLAN plan=loop-plan/1 digest=sha256:${digest} exec-fp=sha256:${execFp} generator=${LOOP_RENDER_GENERATOR} ──`;
968
1037
  const runtime = renderRuntime(norm, digest, execFp, blobs);
1038
+ const completedValue = `{ phase: 'COMPLETED', runId: RUN_ID, planDigest: PLAN_DIGEST, execFp: EXEC_FP }`;
969
1039
 
970
1040
  const ending = [
971
1041
  G('epilogue'),
972
1042
  `traceCloseIfOn()`,
973
1043
  `function traceCloseIfOn() { ${norm.trace?.emit === true ? 'traceClose(__traceState)' : '/* trace off */'} }`,
1044
+ `await __phaseFlush(${jsString(phaseOrder[phaseOrder.length - 1] ?? 'End')})`,
1045
+ `await __ledgerAppend(${jsString(phaseOrder[phaseOrder.length - 1] ?? 'End')}, 'completed')`,
974
1046
  `// the COMPLETED return rides the single exit too (round-5 B3): the epilogue flush happens inside`,
975
1047
  `// __settleStep, so a flush rejection is a logged secondary event, never a replaced COMPLETED.`,
976
- `return await __settleStep({ stepId: '__epilogue__', phase: ${jsString(phaseOrder[phaseOrder.length - 1] ?? 'End')}, outcome: 'terminal', value: { phase: 'COMPLETED', runId: RUN_ID, planDigest: PLAN_DIGEST, execFp: EXEC_FP } })`,
1048
+ `return await __settleStep({ stepId: '__epilogue__', phase: ${jsString(phaseOrder[phaseOrder.length - 1] ?? 'End')}, outcome: 'terminal', value: ${completedValue} })`,
977
1049
  GE('epilogue'),
978
1050
  ].join('\n');
979
1051
 
@@ -982,8 +1054,12 @@ export function renderPlan(plan: LoopPlan): RenderResult {
982
1054
  header,
983
1055
  ...blobChunks,
984
1056
  runtime,
1057
+ `try {`,
985
1058
  ...stepChunks,
986
1059
  ending,
1060
+ norm.subsystems?.trainingPairs === true
1061
+ ? `} catch (__runErr) { await __ledgerAppend(${jsString(phaseOrder[phaseOrder.length - 1] ?? 'End')}, 'failed'); if (__captureFailures.length > 0) { log('training-pair capture failures this run: ' + __captureFailures.length + ' — ' + __captureFailures.map(function (f) { return f.stage + ':' + f.reason }).join(', ')) } throw __runErr }`
1062
+ : `} catch (__runErr) { await __ledgerAppend(${jsString(phaseOrder[phaseOrder.length - 1] ?? 'End')}, 'failed'); throw __runErr }`,
987
1063
  '',
988
1064
  ].join('\n\n');
989
1065
 
package/src/loop-trace.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  import type { TraceProjection } from './loop-plan.js';
27
27
 
28
28
  /** Blob version stamp for the emitter half (read by scripts/gen-loop-blobs.mjs). */
29
- export const LOOP_TRACE_BLOB_VERSION = '1.0.0';
29
+ export const LOOP_TRACE_BLOB_VERSION = '1.1.0';
30
30
 
31
31
  export const LOOP_TRACE_SCHEMA_VERSION = 1;
32
32
 
@@ -211,6 +211,83 @@ export function traceFlushCmd(state: TraceState, traceFileAbs: string): string |
211
211
  return 'mkdir -p ' + dir + ' && ' + printfs;
212
212
  }
213
213
 
214
+ /**
215
+ * Build the feature-ADR live-panel telemetry leg. Totality comes from the caller's grouped splice:
216
+ * returning the bare command lets that splice preserve the trace flush's exit status while
217
+ * swallowing only the panel leg's failure. The `loop` producer marker stops a generated loop's
218
+ * high-frequency zero counters from displacing a live `/feature-adr` run's meaningful panel.
219
+ */
220
+ export function traceFaRecordCmd(dzBin: unknown, slug: unknown, stepLabel: unknown, projectAbs: unknown): string | null {
221
+ if (typeof slug !== 'string' || slug === ''
222
+ || typeof stepLabel !== 'string' || stepLabel === ''
223
+ || typeof projectAbs !== 'string' || projectAbs === '') return null;
224
+ const bin = typeof dzBin === 'string' && dzBin !== '' ? dzBin : 'dz';
225
+ const cmd = traceShellQuote(bin) + ' statusline --fa-record --slug ' + traceShellQuote(slug)
226
+ + ' --step ' + traceShellQuote(stepLabel) + ' --kind loop --project ' + traceShellQuote(projectAbs);
227
+ return cmd + ' >/dev/null 2>&1';
228
+ }
229
+
230
+ /** Build one feature-ADR run-cost row without manufacturing wall-clock data in JavaScript. */
231
+ export function traceLedgerLine(opts: {
232
+ slug: unknown;
233
+ runId?: unknown;
234
+ planDigest?: unknown;
235
+ /**
236
+ * `agents` is the TOTAL number of agent invocations this run made — model dispatches AND infra
237
+ * agents (trace flush, checkpoint read/write, training-pair write/backfill, landed-barrier probes,
238
+ * and this ledger writer itself) — counted at write time. It is NOT the trace's model-dispatch
239
+ * count; the ledger's `agents` column means `agent_count` from the Workflow completion
240
+ * notification, and this row must not silently redefine it.
241
+ */
242
+ agents?: unknown;
243
+ date?: unknown;
244
+ outcome?: unknown;
245
+ }): string | null {
246
+ try {
247
+ if (typeof opts.slug !== 'string' || opts.slug === '') return null;
248
+ const agents = typeof opts.agents === 'number'
249
+ && Number.isFinite(opts.agents)
250
+ && Number.isInteger(opts.agents)
251
+ && opts.agents >= 0
252
+ ? opts.agents
253
+ : 0;
254
+ const date = typeof opts.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(opts.date) ? opts.date : null;
255
+ const outcome = typeof opts.outcome === 'string' && opts.outcome !== '' ? opts.outcome : 'unknown';
256
+ const line = JSON.stringify({
257
+ slug: opts.slug,
258
+ stage: 'loop-run',
259
+ tier: null,
260
+ tokens: null,
261
+ minutes: null,
262
+ agents,
263
+ coder: null,
264
+ grade: null,
265
+ date,
266
+ auto: true,
267
+ outcome,
268
+ runId: typeof opts.runId === 'string' ? opts.runId : null,
269
+ planDigest: typeof opts.planDigest === 'string' ? opts.planDigest : null,
270
+ });
271
+ return line.length <= 4000 ? line : null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ /** Build the single command that appends a run-cost row and confirms the write. */
278
+ export function traceLedgerAppendCmd(repoAbs: unknown, line: unknown): string | null {
279
+ if (typeof repoAbs !== 'string' || repoAbs === '' || typeof line !== 'string' || line === '') return null;
280
+ const dir = traceShellQuote(repoAbs + '/.dz/feature-adr');
281
+ const file = traceShellQuote(repoAbs + '/.dz/feature-adr/run-cost-ledger.jsonl');
282
+ // The field token has a fixed position, and JSON-escaped scalar values (including slug,
283
+ // runId, and planDigest) cannot introduce the raw `"date":null` token targeted by sed.
284
+ return 'mkdir -p ' + dir
285
+ + " && printf '%s' " + traceShellQuote(line)
286
+ + ' | sed "s/\\"date\\":null/\\"date\\":\\"$(date -u +%Y-%m-%d)\\"/" >> ' + file
287
+ + " && printf '\\n' >> " + file
288
+ + ' && echo LEDGER-OK';
289
+ }
290
+
214
291
  // ─────────────────────────────────────────────────────────────────────────────
215
292
  // READER HALF — parseTrace / runInvariants / assembleTimeline / renderTimelineHtml.
216
293
  // Pure over strings; the CLI does the fs.