@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
package/src/backlog.ts CHANGED
@@ -91,6 +91,9 @@ export type DedupAction = 'duplicate' | 'related' | 'new';
91
91
  /** Pure output of the classifier (04) — consumed by capture. */
92
92
  export interface DedupVerdict {
93
93
  readonly action: DedupAction;
94
+ /** Ids excluded from VECTOR candidacy because their text was edited without a re-embed yet. A
95
+ * consumer must read this before treating a `new` verdict as "compared against everything". */
96
+ readonly staleExcluded?: string[];
94
97
  /** Top-1 raw cosine (ADR-002 — never an RRF score). `-1` when there is nothing to compare against. */
95
98
  readonly cosine: number;
96
99
  readonly matchedId: string | undefined;
@@ -593,6 +596,160 @@ export function transitionIdeas(
593
596
  return { ok: true, dryRun, changes, errors: [], written: true };
594
597
  }
595
598
 
599
+ /* ── Edit a captured idea's TEXT (idea 1fde7bf6) ─────────────────────────────────────────────
600
+ *
601
+ * Why this verb exists at all: editing the store by hand does NOT re-embed the record, so its dedup
602
+ * vector keeps describing the OLD text and later duplicate checks run against something the record
603
+ * no longer says. The verb owns the text change; the CALLER owns the re-embed (it is async and needs
604
+ * the vector tier). Between the two, the record carries `embedStale` — see ADR-001: the guard against
605
+ * a stale vector lives where the HARM would be (the dedup verdict), not where the failure happened.
606
+ * ────────────────────────────────────────────────────────────────────────────────────────── */
607
+
608
+ /** Where an edit's PREVIOUS text is preserved. An edit destroys text and `reopen` cannot undo it the
609
+ * way it undoes `drop`, so the old text is appended here before the store is rewritten. */
610
+ export function editsLogPath(projectRoot: string): string {
611
+ return join(projectRoot, '.dz', 'backlog', 'edits.jsonl');
612
+ }
613
+
614
+ export interface EditReport {
615
+ readonly ok: boolean;
616
+ readonly dryRun: boolean;
617
+ readonly id?: string;
618
+ readonly previousText?: string;
619
+ readonly newText?: string;
620
+ readonly errors: string[];
621
+ readonly written: boolean;
622
+ }
623
+
624
+ /**
625
+ * Replace or extend ONE idea's text. Mirrors `transitionIdeas`' line discipline exactly: the file is
626
+ * split without discarding anything, every untouched line — including a line the parser cannot read —
627
+ * goes back BYTE-FOR-BYTE, and only the matched record's line is re-serialised. The store holds
628
+ * dozens of records; a whole-file JSON round-trip would reformat all of them to change one.
629
+ */
630
+ export function editIdea(
631
+ projectRoot: string,
632
+ prefix: string,
633
+ opts: { text?: string; append?: string; dryRun?: boolean; nowIso?: string } = {},
634
+ ): EditReport {
635
+ const dryRun = opts.dryRun === true;
636
+ const hasText = typeof opts.text === 'string' && opts.text !== '';
637
+ const hasAppend = typeof opts.append === 'string' && opts.append !== '';
638
+ if (hasText && hasAppend) {
639
+ return { ok: false, dryRun, errors: ['--text and --append are mutually exclusive — pick one'], written: false };
640
+ }
641
+ if (!hasText && !hasAppend) {
642
+ return { ok: false, dryRun, errors: ['nothing to do: give --text "<new text>" or --append "<more text>"'], written: false };
643
+ }
644
+ const path = ideasPath(projectRoot);
645
+ if (!existsSync(path)) {
646
+ return { ok: false, dryRun, errors: ['no backlog store — nothing captured yet (dz backlog add "<idea>")'], written: false };
647
+ }
648
+ let raw: string;
649
+ try {
650
+ raw = readFileSync(path, 'utf-8');
651
+ } catch (e) {
652
+ return { ok: false, dryRun, errors: [`cannot read ${path}: ${(e as Error).message}`], written: false };
653
+ }
654
+
655
+ // Same line discipline as transitionIdeas: nothing is discarded, corrupt lines are left alone.
656
+ const lines = raw.split('\n');
657
+ const parsed: { index: number; obj: Record<string, unknown>; id: string }[] = [];
658
+ for (let i = 0; i < lines.length; i += 1) {
659
+ const trimmed = (lines[i] ?? '').trim();
660
+ if (trimmed === '') continue;
661
+ try {
662
+ const obj = JSON.parse(trimmed) as Record<string, unknown>;
663
+ if (typeof obj.id === 'string' && obj.id !== '') parsed.push({ index: i, obj, id: obj.id });
664
+ } catch {
665
+ /* corrupt line — left byte-for-byte as-is */
666
+ }
667
+ }
668
+
669
+ if (!isSafeId(prefix)) {
670
+ return { ok: false, dryRun, errors: [`refusing an unsafe idea id: ${JSON.stringify(prefix)}`], written: false };
671
+ }
672
+ const res = resolveIdPrefix(parsed.map((p) => p.id), prefix);
673
+ if (res.kind === 'not-found') {
674
+ return { ok: false, dryRun, errors: [`no idea matches ${prefix} — run dz backlog list to see the ids`], written: false };
675
+ }
676
+ if (res.kind === 'ambiguous') {
677
+ return { ok: false, dryRun, errors: [`ambiguous prefix ${prefix} — matches ${res.matches.join(', ')}; give more characters`], written: false };
678
+ }
679
+ const entries = parsed.filter((p) => p.id === res.id);
680
+ if (entries.length > 1) {
681
+ // Deciding on the first line while rewriting one is how the sibling verb grew its twin bug.
682
+ return { ok: false, dryRun, errors: [`${res.id} appears ${entries.length}× in the store (duplicate lines; resolve the duplicate by hand)`], written: false };
683
+ }
684
+ const entry = entries[0]!;
685
+ const previousText = typeof entry.obj.text === 'string' ? (entry.obj.text as string) : '';
686
+ const newText = hasText ? (opts.text as string) : `${previousText}${previousText === '' ? '' : ' '}${opts.append as string}`;
687
+ if (newText === previousText) {
688
+ return { ok: true, dryRun, id: res.id, previousText, newText, errors: [], written: false };
689
+ }
690
+ if (dryRun) {
691
+ return { ok: true, dryRun, id: res.id, previousText, newText, errors: [], written: false };
692
+ }
693
+
694
+ // Only `text` changes, plus the stale marker. Every other field is carried through untouched.
695
+ entry.obj.text = newText;
696
+ entry.obj.embedStale = true;
697
+ lines[entry.index] = JSON.stringify(entry.obj);
698
+
699
+ const nowIso = opts.nowIso ?? new Date().toISOString();
700
+ const logPath = editsLogPath(projectRoot);
701
+ try {
702
+ mkdirSync(join(projectRoot, '.dz', 'backlog'), { recursive: true });
703
+ appendFileSync(logPath, `${JSON.stringify({ id: res.id, previousText, newText, ts: nowIso })}\n`);
704
+ } catch (e) {
705
+ // The trail is the ONLY copy of the previous text. Refuse rather than destroy it untraceably.
706
+ return { ok: false, dryRun, id: res.id, previousText, newText, errors: [`edit log write failed, store left untouched: ${(e as Error).message}`], written: false };
707
+ }
708
+
709
+ const tmp = `${path}.tmp-${process.pid}`;
710
+ try {
711
+ writeFileSync(tmp, lines.join('\n'));
712
+ renameSync(tmp, path);
713
+ } catch (e) {
714
+ try { unlinkSync(tmp); } catch { /* best-effort litter cleanup */ }
715
+ return { ok: false, dryRun, id: res.id, previousText, newText, errors: [`store write failed: ${(e as Error).message}`], written: false };
716
+ }
717
+ return { ok: true, dryRun, id: res.id, previousText, newText, errors: [], written: true };
718
+ }
719
+
720
+ /** Clear the stale marker after a successful re-embed. Separate from `editIdea` because the re-embed
721
+ * is async and belongs to the caller; a marker cleared without a re-embed would be a lie. */
722
+ export function clearEmbedStale(projectRoot: string, id: string): boolean {
723
+ const path = ideasPath(projectRoot);
724
+ if (!existsSync(path)) return false;
725
+ let raw: string;
726
+ try { raw = readFileSync(path, 'utf-8'); } catch { return false; }
727
+ const lines = raw.split('\n');
728
+ let touched = false;
729
+ for (let i = 0; i < lines.length; i += 1) {
730
+ const trimmed = (lines[i] ?? '').trim();
731
+ if (trimmed === '') continue;
732
+ try {
733
+ const obj = JSON.parse(trimmed) as Record<string, unknown>;
734
+ if (obj.id === id && obj.embedStale === true) {
735
+ delete obj.embedStale;
736
+ lines[i] = JSON.stringify(obj);
737
+ touched = true;
738
+ }
739
+ } catch { /* corrupt line — left alone */ }
740
+ }
741
+ if (!touched) return false;
742
+ const tmp = `${path}.tmp-clear-${process.pid}`;
743
+ try {
744
+ writeFileSync(tmp, lines.join('\n'));
745
+ renameSync(tmp, path);
746
+ return true;
747
+ } catch {
748
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
749
+ return false;
750
+ }
751
+ }
752
+
596
753
  /* ── Store privacy (idea ec4cd60d): raw ideas are prompt-class PRIVATE content, like recall-usage.jsonl. ── */
597
754
 
598
755
  export type GitignoreAction = 'created' | 'appended' | 'already-covered' | 'user-opted-out' | 'skipped';
@@ -741,6 +898,11 @@ export interface DedupCandidate {
741
898
  readonly id: string;
742
899
  readonly cosine: number;
743
900
  readonly containment?: number;
901
+ /** Set when the record's text was edited but its vector has not been rewritten yet (ADR-001,
902
+ * idea 1fde7bf6). Such a candidate is excluded from VECTOR candidacy — its cosine describes text
903
+ * the record no longer has. The exact-text net still applies: the marker degrades SIMILARITY,
904
+ * never IDENTITY. */
905
+ readonly embedStale?: boolean;
744
906
  }
745
907
 
746
908
  /**
@@ -762,10 +924,18 @@ export function classifyDedup(
762
924
  // HIGH-4: a non-finite cosine (NaN/±Infinity) sorts unpredictably and can shove a real 0.97 duplicate
763
925
  // out of the top slot → misclassified NEW. Drop non-finite candidates BEFORE sorting/banding (the
764
926
  // recurring repo `Number.isFinite` lesson).
765
- const sorted = candidates.filter((c) => Number.isFinite(c.cosine)).sort((a, b) => b.cosine - a.cosine);
927
+ // A record whose text was edited but whose vector has not been rewritten is EXCLUDED from vector
928
+ // candidacy — the same treatment a non-finite cosine gets, and for the same reason: the number does
929
+ // not describe the record. ADR-001: the guard sits where the HARM would be (this verdict, days after
930
+ // the edit) rather than where the failure happened (the edit's own output, which nobody re-reads).
931
+ const staleExcluded = candidates.filter((c) => c.embedStale === true).map((c) => c.id);
932
+ const sorted = candidates
933
+ .filter((c) => c.embedStale !== true)
934
+ .filter((c) => Number.isFinite(c.cosine))
935
+ .sort((a, b) => b.cosine - a.cosine);
766
936
  const top = sorted[0];
767
937
  const exactTextOnly = opts.exactTextOnly === true;
768
- if (top === undefined) return { action: 'new', cosine: -1, matchedId: undefined, topMatchId: undefined, relatedIds: [], exactTextOnly };
938
+ if (top === undefined) return { action: 'new', cosine: -1, matchedId: undefined, topMatchId: undefined, relatedIds: [], exactTextOnly, staleExcluded };
769
939
  const bands = new Map(sorted.map((c) => [c.id, dedupPairBand(c.cosine, c.containment, cfg)]));
770
940
  if (bands.get(top.id) === 'duplicate') {
771
941
  return {
@@ -775,6 +945,7 @@ export function classifyDedup(
775
945
  topMatchId: top.id,
776
946
  relatedIds: [],
777
947
  exactTextOnly,
948
+ staleExcluded,
778
949
  ...(top.containment !== undefined ? { containment: top.containment } : {}),
779
950
  };
780
951
  }
@@ -794,6 +965,7 @@ export function classifyDedup(
794
965
  topMatchId: top.id,
795
966
  relatedIds: [],
796
967
  exactTextOnly,
968
+ staleExcluded,
797
969
  subsetMatch: true,
798
970
  ...(subset.containment !== undefined ? { containment: subset.containment } : {}),
799
971
  ...demoted,
@@ -810,11 +982,12 @@ export function classifyDedup(
810
982
  topMatchId: top.id,
811
983
  relatedIds: related.map((c) => c.id),
812
984
  exactTextOnly,
985
+ staleExcluded,
813
986
  ...topContainment,
814
987
  ...demoted,
815
988
  };
816
989
  }
817
- return { action: 'new', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: [], exactTextOnly, ...topContainment };
990
+ return { action: 'new', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: [], exactTextOnly, ...topContainment , staleExcluded };
818
991
  }
819
992
 
820
993
  /** Injectable deps so the production dedup path is testable without a live agentdb. */
@@ -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
@@ -34,6 +34,7 @@ export * from './operations.js';
34
34
  export * from './workflows.js';
35
35
  // loop-designer (feature loop-designer): loop-plan/1 schema + generator + lint + trace planes.
36
36
  export * from './loop-plan.js';
37
+ export * from './loop-plan-graph.js';
37
38
  export * from './loop-render.js';
38
39
  // loop-lint: EXPLICIT export list (QE round-2 G14) — `dominators`, the deliberately-WEAKER
39
40
  // analysis kept in src/loop-lint.ts solely as AM-1's mutation seam, is NOT part of the published
@@ -53,6 +54,7 @@ export {
53
54
  type LintOptions,
54
55
  } from './loop-lint.js';
55
56
  export * from './loop-trace.js';
57
+ export * from './trace-bundle.js';
56
58
  export { BLOBS as LOOP_BLOBS, LOOP_BLOB_NAMES, BLOB_COVERAGE_MANIFEST } from './loop-blobs.generated.js';
57
59
  export type { LoopBlob } from './loop-blobs.generated.js';
58
60
  export * from './sign.js';
@@ -127,7 +129,7 @@ export type {
127
129
  TeachGuardResult,
128
130
  } from './vector-tier.js';
129
131
  export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION } from './setup.js';
130
- export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStatePath } from './statusline.js';
132
+ export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath } from './statusline.js';
131
133
  export type { StatuslineData, FeatureAdrState, WriteFeatureAdrStateInput } from './statusline.js';
132
134
  export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES } from './agentdb-index.js';
133
135
  export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
@@ -491,6 +493,7 @@ export * from './session-retro.js';
491
493
  export * from './feature-adr-setup.js';
492
494
  export * from './challenge-panel.js';
493
495
  export * from './routing-outcomes.js';
496
+ export * from './model-recommender.js';
494
497
  export * from './bto-optimize.js';
495
498
  export * from './discrimination-gate.js';
496
499
  export * from './guard.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",