@dzhechkov/harness-core 0.4.1 → 0.4.2

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.
@@ -26,7 +26,7 @@
26
26
 
27
27
  /** Blob version stamp read by scripts/gen-loop-blobs.mjs (feature loop-designer, ADR-004) — the
28
28
  * ONLY loop-designer change to this canonical file; bump when any blob-exported semantic changes. */
29
- export const BLOB_VERSION = '1.0.0';
29
+ export const BLOB_VERSION = '1.1.0';
30
30
 
31
31
  /** Stages the workflow checkpoints, in pipeline order. Cheap side-channel agents (usage probes,
32
32
  * fa-record, auto-cost selects) are never checkpointed; the opt-in Delivery gate re-runs by design
@@ -241,13 +241,59 @@ export function checkpointAppendCmd(fdirAbs: string, line: string): string {
241
241
  // dataset must honour the cross-model rule — QE pairs must come from a DIFFERENT
242
242
  // family than the coder's pairs; router/plan distill easily, code is hardest.
243
243
  //
244
- // Pure and deterministic like the checkpoint half: ts is PASSED IN (never Date.now()
244
+ // Pure and deterministic like the checkpoint half: ts is PASSED IN (never read from a clock
245
245
  // — the workflow sandbox forbids Date, and tests must stay deterministic); the
246
246
  // workflow mirrors these functions inline and fills ts shell-side via sed.
247
247
  // ─────────────────────────────────────────────────────────────────────────────
248
248
 
249
+ export type CaptureMode = 'capture' | 'backfill' | 'skip-disabled' | 'skip-empty';
250
+
251
+ /** Decide whether this completion is captured. A resumed stage is backfilled rather than
252
+ * skipped: its input (stage template + args) and checkpointed output are both in scope at the
253
+ * capture site, so the pair is deterministically reconstructible. trainingPairBackfillCmd's
254
+ * persistent atomic mark makes that write at-most-once, so concurrent invocations and later
255
+ * runIds cannot double-append the same pair. */
256
+ export function decideCaptureMode(opts: { enabled: boolean; resumed: boolean; recordCount: number }): CaptureMode {
257
+ if (!opts.enabled) return 'skip-disabled';
258
+ if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0) return 'skip-empty';
259
+ return opts.resumed ? 'backfill' : 'capture';
260
+ }
261
+
262
+ export type CaptureFailureReason = 'threw' | 'unserializable' | 'unverified' | 'backfill-unverified' | 'empty-output';
263
+
264
+ export interface CaptureFailureRecord {
265
+ stage: string;
266
+ mode: CaptureMode | null;
267
+ reason: CaptureFailureReason;
268
+ detail: string | null;
269
+ }
270
+
271
+ /** Normalize capture failures for collection by the caller. This recorder must never throw:
272
+ * replacing the original capture failure with a reporting failure would hide the real cause. */
273
+ export function captureFailureRecord(stage: unknown, mode: unknown, reason: unknown, detail: unknown): CaptureFailureRecord {
274
+ const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';
275
+ const normalizedMode: CaptureMode | null =
276
+ mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'
277
+ ? mode
278
+ : null;
279
+ const normalizedReason: CaptureFailureReason =
280
+ reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'
281
+ ? reason
282
+ : 'threw';
283
+ let normalizedDetail: string | null = null;
284
+ if (detail !== null && detail !== undefined) {
285
+ try {
286
+ const text = String(detail);
287
+ if (text !== '') normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;
288
+ } catch {
289
+ normalizedDetail = null;
290
+ }
291
+ }
292
+ return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };
293
+ }
294
+
249
295
  /** Training-pair record format version. Bump on any field-shape change. */
250
- export const TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-1';
296
+ export const TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';
251
297
 
252
298
  /** Oversize guard cap over input+output combined (same posture as
253
299
  * CHECKPOINT_MAX_RESULT_CHARS, sized for full stage prompts): an over-cap pair is
@@ -299,12 +345,15 @@ export interface TrainingPair {
299
345
  schema: string;
300
346
  slug: string;
301
347
  stage: string;
348
+ /** ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint. */
302
349
  ts: number | string | null;
303
350
  input: string;
304
351
  output: string;
305
352
  evaluation: TrainingPairEvaluation;
306
353
  provenance: TrainingPairProvenance;
307
354
  truncated: TrainingPairTruncation | null;
355
+ captureMode: 'capture' | 'backfill';
356
+ resumed: boolean;
308
357
  }
309
358
 
310
359
  /** Per-stage JSONL path, relative to the repo root. ONE file per stage. */
@@ -315,7 +364,7 @@ export function trainingPairPath(slug: string, stage: string): string {
315
364
  /** README dropped once into the capture dir. The caveat is documented ON DISK because the
316
365
  * directory is deliberately not gitignored (explicit owner decision, 2026-08). */
317
366
  export const TRAINPAIR_PRIVACY_NOTE =
318
- 'feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it.';
367
+ "feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.";
319
368
 
320
369
  /** Coerce a stage input/output to text: strings pass through; objects serialize to JSON;
321
370
  * an unserializable value degrades to String(v) — buildTrainingPair NEVER throws (capture
@@ -346,6 +395,8 @@ export function buildTrainingPair(opts: {
346
395
  output: unknown;
347
396
  evaluation?: Partial<TrainingPairEvaluation> | null;
348
397
  provenance?: Partial<TrainingPairProvenance> | null;
398
+ captureMode?: unknown;
399
+ resumed?: unknown;
349
400
  }): TrainingPair {
350
401
  let input = coerceText(opts.input);
351
402
  let output = coerceText(opts.output);
@@ -383,6 +434,8 @@ export function buildTrainingPair(opts: {
383
434
  minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,
384
435
  },
385
436
  truncated,
437
+ captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',
438
+ resumed: opts.resumed === true,
386
439
  };
387
440
  }
388
441
 
@@ -410,3 +463,49 @@ export function trainingPairAppendCmd(repoAbs: string, slug: string, stage: stri
410
463
  " && printf '%s\\n' " + shellQuote(line) + ' >> ' + shellQuote(fileAbs)
411
464
  );
412
465
  }
466
+
467
+ /** Readback sentinels for the caller to distinguish an at-most-once write from an existing pair. */
468
+ export const TP_BACKFILL_OK = 'TP-BACKFILL-OK';
469
+ export const TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';
470
+ export const TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';
471
+
472
+ /** Build the deterministic resume-backfill command. The persistent mkdir mark is the atomic
473
+ * at-most-once primitive; the inner file-absence guard also protects pair files created before
474
+ * marks existed. The mark is RELEASED when — and only when — the append fails, so a failed backfill
475
+ * stays retryable. The `[ -f ]` path keeps the mark because the pair genuinely exists.
476
+ * KNOWN RESIDUAL: a process killed (SIGKILL, sandbox timeout) between the `mkdir` claim and the end
477
+ * of the append still leaves a poisoned mark. That window is strictly narrower than "any append
478
+ * failure" and is the same externally-killed class this feature already names for the ledger row.
479
+ * `TP_BACKFILL_SKIP` means "the per-stage pair file already existed"; `TP_BACKFILL_DUP` means
480
+ * "another run already owns this content". The two strings are deliberately NON-PREFIXING because
481
+ * the two producers parse the readback differently — the generated loop compares `===` after
482
+ * `trim`, while the `feature-adr.js` twin tests an UNANCHORED regex; a prefixed name would be `DUP`
483
+ * to one parser and `SKIP` to the other from the same bytes. The default `markKey` is per-CONTENT
484
+ * only; a caller whose line embeds a per-run identifier must pass a run-independent `markKey`.
485
+ * Marks are deliberately never pruned. */
486
+ export function trainingPairBackfillCmd(repoAbs: string, slug: string, stage: string, lines: readonly string[], markKey?: string): string | null {
487
+ if (typeof repoAbs !== 'string' || repoAbs === '') return null;
488
+ if (typeof slug !== 'string' || slug === '') return null;
489
+ if (typeof stage !== 'string' || stage === '') return null;
490
+ if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== '')) return null;
491
+
492
+ const dirAbs = repoAbs + '/.dz/fa-training/' + slug;
493
+ const readmeAbs = repoAbs + '/.dz/fa-training/README.md';
494
+ const fileAbs = dirAbs + '/' + stage + '.jsonl';
495
+ const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';
496
+ const markStage = stage.replace(/\.\./g, '_').replace(/\//g, '_');
497
+ const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\0' + lines.join('\n')) : markKey;
498
+ const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;
499
+ const appends = lines
500
+ .map(line => "printf '%s\\n' " + shellQuote(line) + ' >> ' + shellQuote(fileAbs))
501
+ .join(' && ');
502
+ return (
503
+ 'mkdir -p ' + shellQuote(dirAbs) +
504
+ ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \'%s\\n\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +
505
+ ' && mkdir -p ' + shellQuote(markDir) +
506
+ ' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +
507
+ 'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +
508
+ '; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +
509
+ '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi'
510
+ );
511
+ }
package/src/index.ts CHANGED
@@ -127,7 +127,7 @@ export type {
127
127
  TeachGuardResult,
128
128
  } from './vector-tier.js';
129
129
  export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION } from './setup.js';
130
- export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStatePath } from './statusline.js';
130
+ export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath } from './statusline.js';
131
131
  export type { StatuslineData, FeatureAdrState, WriteFeatureAdrStateInput } from './statusline.js';
132
132
  export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES } from './agentdb-index.js';
133
133
  export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
@@ -41,7 +41,7 @@ export const BLOB_COVERAGE_MANIFEST: { coveredWorkflows: string[] } = {
41
41
  export const BLOBS: Record<string, LoopBlob> = {
42
42
  "checkpoints": {
43
43
  name: "checkpoints",
44
- version: "1.0.0",
44
+ version: "1.1.0",
45
45
  contentHash: "aa730483f52a9f6263751138d4514fe9a6a3f4f191897c86d1e035f3da890574",
46
46
  sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts",
47
47
  requires: [],
@@ -50,12 +50,12 @@ export const BLOBS: Record<string, LoopBlob> = {
50
50
  },
51
51
  "training-pairs": {
52
52
  name: "training-pairs",
53
- version: "1.0.0",
54
- contentHash: "a28f704ff78227e8254ed075cc7c51fa0c89bcac9fc191ee03ecd6ee1584fc58",
53
+ version: "1.1.0",
54
+ contentHash: "740b733d3d995e7031590f1707b442d0f0f9d7d585ada6f37bbf08c5db3351e9",
55
55
  sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts",
56
56
  requires: ["checkpoints"],
57
- exports: ["TRAINPAIR_SCHEMA_VERSION","TRAINPAIR_MAX_IO_CHARS","trainingPairFamily","trainingPairPath","TRAINPAIR_PRIVACY_NOTE","buildTrainingPair","serializeTrainingPair","trainingPairAppendCmd"],
58
- code: "const TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-1';\nconst TRAINPAIR_MAX_IO_CHARS = 48000;\nfunction trainingPairFamily(spec) {\n return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';\n}\nfunction trainingPairPath(slug, stage) {\n return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';\n}\nconst TRAINPAIR_PRIVACY_NOTE = 'feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it.';\nfunction coerceText(v) {\n if (typeof v === 'string')\n return v;\n if (v === null || v === undefined)\n return '';\n try {\n const s = JSON.stringify(v);\n return typeof s === 'string' ? s : String(v);\n }\n catch {\n return String(v);\n }\n}\nfunction buildTrainingPair(opts) {\n let input = coerceText(opts.input);\n let output = coerceText(opts.output);\n let truncated = null;\n if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {\n truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };\n const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);\n let inKeep = input.length;\n let outKeep = output.length;\n if (outKeep <= half)\n inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;\n else if (inKeep <= half)\n outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;\n else {\n inKeep = half;\n outKeep = TRAINPAIR_MAX_IO_CHARS - half;\n }\n if (inKeep < input.length)\n input = input.slice(0, inKeep) + '\\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';\n if (outKeep < output.length)\n output = output.slice(0, outKeep) + '\\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';\n }\n const ev = opts.evaluation || {};\n const pv = opts.provenance || {};\n return {\n schema: TRAINPAIR_SCHEMA_VERSION,\n slug: opts.slug,\n stage: opts.stage,\n ts: opts.ts === undefined ? null : opts.ts,\n input,\n output,\n evaluation: {\n grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,\n gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,\n lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s) => typeof s === 'string' && s !== '') : [],\n },\n provenance: {\n model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',\n family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),\n role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',\n tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,\n minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,\n },\n truncated,\n };\n}\nfunction serializeTrainingPair(pair) {\n try {\n const line = JSON.stringify(pair);\n return typeof line === 'string' ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction trainingPairAppendCmd(repoAbs, slug, stage, line) {\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs));\n}",
57
+ exports: ["TRAINPAIR_SCHEMA_VERSION","TRAINPAIR_MAX_IO_CHARS","trainingPairFamily","trainingPairPath","TRAINPAIR_PRIVACY_NOTE","buildTrainingPair","serializeTrainingPair","trainingPairAppendCmd","decideCaptureMode","captureFailureRecord","trainingPairBackfillCmd","TP_BACKFILL_OK","TP_BACKFILL_SKIP"],
58
+ code: "function decideCaptureMode(opts) {\n if (!opts.enabled)\n return 'skip-disabled';\n if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0)\n return 'skip-empty';\n return opts.resumed ? 'backfill' : 'capture';\n}\nfunction captureFailureRecord(stage, mode, reason, detail) {\n const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';\n const normalizedMode = mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'\n ? mode\n : null;\n const normalizedReason = reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'\n ? reason\n : 'threw';\n let normalizedDetail = null;\n if (detail !== null && detail !== undefined) {\n try {\n const text = String(detail);\n if (text !== '')\n normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;\n }\n catch {\n normalizedDetail = null;\n }\n }\n return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };\n}\nconst TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';\nconst TRAINPAIR_MAX_IO_CHARS = 48000;\nfunction trainingPairFamily(spec) {\n return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';\n}\nfunction trainingPairPath(slug, stage) {\n return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';\n}\nconst TRAINPAIR_PRIVACY_NOTE = \"feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.\";\nfunction coerceText(v) {\n if (typeof v === 'string')\n return v;\n if (v === null || v === undefined)\n return '';\n try {\n const s = JSON.stringify(v);\n return typeof s === 'string' ? s : String(v);\n }\n catch {\n return String(v);\n }\n}\nfunction buildTrainingPair(opts) {\n let input = coerceText(opts.input);\n let output = coerceText(opts.output);\n let truncated = null;\n if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {\n truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };\n const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);\n let inKeep = input.length;\n let outKeep = output.length;\n if (outKeep <= half)\n inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;\n else if (inKeep <= half)\n outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;\n else {\n inKeep = half;\n outKeep = TRAINPAIR_MAX_IO_CHARS - half;\n }\n if (inKeep < input.length)\n input = input.slice(0, inKeep) + '\\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';\n if (outKeep < output.length)\n output = output.slice(0, outKeep) + '\\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';\n }\n const ev = opts.evaluation || {};\n const pv = opts.provenance || {};\n return {\n schema: TRAINPAIR_SCHEMA_VERSION,\n slug: opts.slug,\n stage: opts.stage,\n ts: opts.ts === undefined ? null : opts.ts,\n input,\n output,\n evaluation: {\n grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,\n gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,\n lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s) => typeof s === 'string' && s !== '') : [],\n },\n provenance: {\n model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',\n family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),\n role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',\n tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,\n minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,\n },\n truncated,\n captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',\n resumed: opts.resumed === true,\n };\n}\nfunction serializeTrainingPair(pair) {\n try {\n const line = JSON.stringify(pair);\n return typeof line === 'string' ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction trainingPairAppendCmd(repoAbs, slug, stage, line) {\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs));\n}\nconst TP_BACKFILL_OK = 'TP-BACKFILL-OK';\nconst TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';\nconst TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';\nfunction trainingPairBackfillCmd(repoAbs, slug, stage, lines, markKey) {\n if (typeof repoAbs !== 'string' || repoAbs === '')\n return null;\n if (typeof slug !== 'string' || slug === '')\n return null;\n if (typeof stage !== 'string' || stage === '')\n return null;\n if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== ''))\n return null;\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';\n const markStage = stage.replace(/\\.\\./g, '_').replace(/\\//g, '_');\n const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\\0' + lines.join('\\n')) : markKey;\n const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;\n const appends = lines\n .map(line => \"printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs))\n .join(' && ');\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n ' && mkdir -p ' + shellQuote(markDir) +\n ' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +\n 'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +\n '; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +\n '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi');\n}",
59
59
  },
60
60
  "model-resolver": {
61
61
  name: "model-resolver",
@@ -95,12 +95,12 @@ export const BLOBS: Record<string, LoopBlob> = {
95
95
  },
96
96
  "trace": {
97
97
  name: "trace",
98
- version: "1.0.0",
99
- contentHash: "085ac8d78190d29a6d5068019be40b93ebbe8f8ec8590f2954a1dfa4da5c6803",
98
+ version: "1.1.0",
99
+ contentHash: "fc59098bb0deb747c4c1c9a6ba843df70edfe0dc5ef8370c0f7192afc7657890",
100
100
  sourcePath: "packages/@dzhechkov/harness-core/src/loop-trace.ts",
101
101
  requires: [],
102
- exports: ["LOOP_TRACE_SCHEMA_VERSION","TRACE_RUNID_RE","TRACE_KEY_RE","traceShellQuote","traceValidateEvent","traceInit","traceOnDispatch","traceOnSettle","traceClose","traceFlushCmd"],
103
- code: "const LOOP_TRACE_SCHEMA_VERSION = 1;\nconst TRACE_RUNID_RE = /^[a-z0-9-]{1,40}$/;\nconst TRACE_KEY_RE = /^[a-z0-9_.:-]{1,64}$/i;\nfunction traceShellQuote(s) {\n return \"'\" + String(s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\nfunction traceValidateEvent(e) {\n if (typeof e !== 'object' || e === null || Array.isArray(e))\n return 'event must be an object';\n const ev = e;\n if (ev['v'] !== 1)\n return 'v must be 1';\n if (typeof ev['runId'] !== 'string' || !TRACE_RUNID_RE.test(ev['runId']))\n return 'runId fails its VO regex';\n if (typeof ev['seq'] !== 'number' || !Number.isInteger(ev['seq']) || ev['seq'] < 1)\n return 'seq must be a positive integer';\n const kind = ev['event'];\n if (kind === 'dispatched') {\n if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '')\n return 'invocationId required';\n if (typeof ev['stepId'] !== 'string' || !TRACE_KEY_RE.test(ev['stepId']))\n return 'stepId fails its VO regex';\n if (ev['itemKey'] !== null && (typeof ev['itemKey'] !== 'string' || !TRACE_KEY_RE.test(ev['itemKey'])))\n return 'itemKey fails its VO regex';\n if (typeof ev['attempt'] !== 'number' || ev['attempt'] < 1)\n return 'attempt must be >= 1';\n if (typeof ev['phase'] !== 'string' || ev['phase'] === '')\n return 'phase required';\n if (!Array.isArray(ev['causedBy']) || ev['causedBy'].some((n) => typeof n !== 'number'))\n return 'causedBy must be a number array';\n return null;\n }\n if (kind === 'settled') {\n if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '')\n return 'invocationId required';\n if (ev['outcome'] !== 'ok' && ev['outcome'] !== 'null' && ev['outcome'] !== 'error')\n return 'outcome must be ok|null|error';\n return null;\n }\n if (kind === 'run.opened') {\n if (typeof ev['planDigest'] !== 'string' || typeof ev['execFp'] !== 'string')\n return 'run.opened needs planDigest + execFp';\n return null;\n }\n if (kind === 'run.closed') {\n const c = ev['counts'];\n if (typeof c !== 'object' || c === null)\n return 'run.closed needs counts';\n return null;\n }\n return 'unknown event kind';\n}\nfunction traceInit(runId, planDigest, execFp) {\n if (!TRACE_RUNID_RE.test(runId))\n throw new Error('loop-trace: runId fails ' + String(TRACE_RUNID_RE));\n const state = { runId, seq: 0, dispatched: 0, settled: 0, buffer: [] };\n const opened = { v: 1, runId, seq: ++state.seq, event: 'run.opened', planDigest, execFp };\n traceBuffer(state, opened);\n return state;\n}\nfunction traceBuffer(state, e) {\n const err = traceValidateEvent(e);\n if (err !== null)\n throw new Error('loop-trace: refusing non-conforming event (' + err + ') — the authoritative ordering source is never repaired later');\n state.buffer.push(JSON.stringify(e));\n}\nfunction traceOnDispatch(state, e) {\n const seq = ++state.seq;\n state.dispatched++;\n traceBuffer(state, {\n v: 1,\n runId: state.runId,\n seq,\n event: 'dispatched',\n invocationId: e.invocationId,\n stepId: e.stepId,\n itemKey: e.itemKey,\n attempt: e.attempt,\n phase: e.phase,\n model: e.model,\n causedBy: e.causedBy,\n });\n return seq;\n}\nfunction traceOnSettle(state, e) {\n const seq = ++state.seq;\n state.settled++;\n traceBuffer(state, { v: 1, runId: state.runId, seq, event: 'settled', invocationId: e.invocationId, outcome: e.outcome });\n return seq;\n}\nfunction traceClose(state) {\n const closed = {\n v: 1,\n runId: state.runId,\n seq: ++state.seq,\n event: 'run.closed',\n counts: { dispatched: state.dispatched, settled: state.settled },\n };\n traceBuffer(state, closed);\n}\nfunction traceFlushCmd(state, traceFileAbs) {\n if (state.buffer.length === 0)\n return null;\n const lines = state.buffer.splice(0, state.buffer.length);\n const file = traceShellQuote(traceFileAbs);\n const dir = traceShellQuote(traceFileAbs.replace(/\\/[^/]*$/, ''));\n const printfs = lines\n .map((l) => \"printf '%s\\\\n' \" + traceShellQuote(l) + ' | sed \"s/}$/,\\\\\"wallTime\\\\\":\\\\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\\\\"}/\" >> ' + file)\n .join(' && ');\n return 'mkdir -p ' + dir + ' && ' + printfs;\n}",
102
+ exports: ["LOOP_TRACE_SCHEMA_VERSION","TRACE_RUNID_RE","TRACE_KEY_RE","traceShellQuote","traceValidateEvent","traceInit","traceOnDispatch","traceOnSettle","traceClose","traceFlushCmd","traceFaRecordCmd","traceLedgerLine","traceLedgerAppendCmd"],
103
+ code: "const LOOP_TRACE_SCHEMA_VERSION = 1;\nconst TRACE_RUNID_RE = /^[a-z0-9-]{1,40}$/;\nconst TRACE_KEY_RE = /^[a-z0-9_.:-]{1,64}$/i;\nfunction traceShellQuote(s) {\n return \"'\" + String(s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\nfunction traceValidateEvent(e) {\n if (typeof e !== 'object' || e === null || Array.isArray(e))\n return 'event must be an object';\n const ev = e;\n if (ev['v'] !== 1)\n return 'v must be 1';\n if (typeof ev['runId'] !== 'string' || !TRACE_RUNID_RE.test(ev['runId']))\n return 'runId fails its VO regex';\n if (typeof ev['seq'] !== 'number' || !Number.isInteger(ev['seq']) || ev['seq'] < 1)\n return 'seq must be a positive integer';\n const kind = ev['event'];\n if (kind === 'dispatched') {\n if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '')\n return 'invocationId required';\n if (typeof ev['stepId'] !== 'string' || !TRACE_KEY_RE.test(ev['stepId']))\n return 'stepId fails its VO regex';\n if (ev['itemKey'] !== null && (typeof ev['itemKey'] !== 'string' || !TRACE_KEY_RE.test(ev['itemKey'])))\n return 'itemKey fails its VO regex';\n if (typeof ev['attempt'] !== 'number' || ev['attempt'] < 1)\n return 'attempt must be >= 1';\n if (typeof ev['phase'] !== 'string' || ev['phase'] === '')\n return 'phase required';\n if (!Array.isArray(ev['causedBy']) || ev['causedBy'].some((n) => typeof n !== 'number'))\n return 'causedBy must be a number array';\n return null;\n }\n if (kind === 'settled') {\n if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '')\n return 'invocationId required';\n if (ev['outcome'] !== 'ok' && ev['outcome'] !== 'null' && ev['outcome'] !== 'error')\n return 'outcome must be ok|null|error';\n return null;\n }\n if (kind === 'run.opened') {\n if (typeof ev['planDigest'] !== 'string' || typeof ev['execFp'] !== 'string')\n return 'run.opened needs planDigest + execFp';\n return null;\n }\n if (kind === 'run.closed') {\n const c = ev['counts'];\n if (typeof c !== 'object' || c === null)\n return 'run.closed needs counts';\n return null;\n }\n return 'unknown event kind';\n}\nfunction traceInit(runId, planDigest, execFp) {\n if (!TRACE_RUNID_RE.test(runId))\n throw new Error('loop-trace: runId fails ' + String(TRACE_RUNID_RE));\n const state = { runId, seq: 0, dispatched: 0, settled: 0, buffer: [] };\n const opened = { v: 1, runId, seq: ++state.seq, event: 'run.opened', planDigest, execFp };\n traceBuffer(state, opened);\n return state;\n}\nfunction traceBuffer(state, e) {\n const err = traceValidateEvent(e);\n if (err !== null)\n throw new Error('loop-trace: refusing non-conforming event (' + err + ') — the authoritative ordering source is never repaired later');\n state.buffer.push(JSON.stringify(e));\n}\nfunction traceOnDispatch(state, e) {\n const seq = ++state.seq;\n state.dispatched++;\n traceBuffer(state, {\n v: 1,\n runId: state.runId,\n seq,\n event: 'dispatched',\n invocationId: e.invocationId,\n stepId: e.stepId,\n itemKey: e.itemKey,\n attempt: e.attempt,\n phase: e.phase,\n model: e.model,\n causedBy: e.causedBy,\n });\n return seq;\n}\nfunction traceOnSettle(state, e) {\n const seq = ++state.seq;\n state.settled++;\n traceBuffer(state, { v: 1, runId: state.runId, seq, event: 'settled', invocationId: e.invocationId, outcome: e.outcome });\n return seq;\n}\nfunction traceClose(state) {\n const closed = {\n v: 1,\n runId: state.runId,\n seq: ++state.seq,\n event: 'run.closed',\n counts: { dispatched: state.dispatched, settled: state.settled },\n };\n traceBuffer(state, closed);\n}\nfunction traceFlushCmd(state, traceFileAbs) {\n if (state.buffer.length === 0)\n return null;\n const lines = state.buffer.splice(0, state.buffer.length);\n const file = traceShellQuote(traceFileAbs);\n const dir = traceShellQuote(traceFileAbs.replace(/\\/[^/]*$/, ''));\n const printfs = lines\n .map((l) => \"printf '%s\\\\n' \" + traceShellQuote(l) + ' | sed \"s/}$/,\\\\\"wallTime\\\\\":\\\\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\\\\"}/\" >> ' + file)\n .join(' && ');\n return 'mkdir -p ' + dir + ' && ' + printfs;\n}\nfunction traceFaRecordCmd(dzBin, slug, stepLabel, projectAbs) {\n if (typeof slug !== 'string' || slug === ''\n || typeof stepLabel !== 'string' || stepLabel === ''\n || typeof projectAbs !== 'string' || projectAbs === '')\n return null;\n const bin = typeof dzBin === 'string' && dzBin !== '' ? dzBin : 'dz';\n const cmd = traceShellQuote(bin) + ' statusline --fa-record --slug ' + traceShellQuote(slug)\n + ' --step ' + traceShellQuote(stepLabel) + ' --kind loop --project ' + traceShellQuote(projectAbs);\n return cmd + ' >/dev/null 2>&1';\n}\nfunction traceLedgerLine(opts) {\n try {\n if (typeof opts.slug !== 'string' || opts.slug === '')\n return null;\n const agents = typeof opts.agents === 'number'\n && Number.isFinite(opts.agents)\n && Number.isInteger(opts.agents)\n && opts.agents >= 0\n ? opts.agents\n : 0;\n const date = typeof opts.date === 'string' && /^\\d{4}-\\d{2}-\\d{2}$/.test(opts.date) ? opts.date : null;\n const outcome = typeof opts.outcome === 'string' && opts.outcome !== '' ? opts.outcome : 'unknown';\n const line = JSON.stringify({\n slug: opts.slug,\n stage: 'loop-run',\n tier: null,\n tokens: null,\n minutes: null,\n agents,\n coder: null,\n grade: null,\n date,\n auto: true,\n outcome,\n runId: typeof opts.runId === 'string' ? opts.runId : null,\n planDigest: typeof opts.planDigest === 'string' ? opts.planDigest : null,\n });\n return line.length <= 4000 ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction traceLedgerAppendCmd(repoAbs, line) {\n if (typeof repoAbs !== 'string' || repoAbs === '' || typeof line !== 'string' || line === '')\n return null;\n const dir = traceShellQuote(repoAbs + '/.dz/feature-adr');\n const file = traceShellQuote(repoAbs + '/.dz/feature-adr/run-cost-ledger.jsonl');\n return 'mkdir -p ' + dir\n + \" && printf '%s' \" + traceShellQuote(line)\n + ' | sed \"s/\\\\\"date\\\\\":null/\\\\\"date\\\\\":\\\\\"$(date -u +%Y-%m-%d)\\\\\"/\" >> ' + file\n + \" && printf '\\\\n' >> \" + file\n + ' && echo LEDGER-OK';\n}\nfunction invocations(run) {\n const out = new Map();\n for (const e of run.events) {\n if (e.event === 'dispatched') {\n out.set(e.invocationId, {\n invocationId: e.invocationId,\n stepId: e.stepId,\n itemKey: e.itemKey,\n dispatchSeq: e.seq,\n settleSeq: null,\n causedBy: e.causedBy,\n });\n }\n else if (e.event === 'settled') {\n const inv = out.get(e.invocationId);\n if (inv)\n inv.settleSeq = e.seq;\n }\n }\n return [...out.values()];\n}",
104
104
  },
105
105
  "ha-consult-router": {
106
106
  name: "ha-consult-router",
@@ -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