@dzhechkov/harness-core 0.4.2 → 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 (42) hide show
  1. package/.dz-manifest.json +104 -28
  2. package/README.md +3 -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/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/loop-plan-graph.d.ts +49 -0
  12. package/dist/loop-plan-graph.d.ts.map +1 -0
  13. package/dist/loop-plan-graph.js +128 -0
  14. package/dist/loop-plan-graph.js.map +1 -0
  15. package/dist/loop-plan.d.ts.map +1 -1
  16. package/dist/loop-plan.js +13 -15
  17. package/dist/loop-plan.js.map +1 -1
  18. package/dist/model-recommender.d.ts +91 -0
  19. package/dist/model-recommender.d.ts.map +1 -0
  20. package/dist/model-recommender.js +186 -0
  21. package/dist/model-recommender.js.map +1 -0
  22. package/dist/registry.d.ts.map +1 -1
  23. package/dist/registry.js +4 -1
  24. package/dist/registry.js.map +1 -1
  25. package/dist/trace-bundle.d.ts +209 -0
  26. package/dist/trace-bundle.d.ts.map +1 -0
  27. package/dist/trace-bundle.js +601 -0
  28. package/dist/trace-bundle.js.map +1 -0
  29. package/dist/usage.d.ts +7 -0
  30. package/dist/usage.d.ts.map +1 -1
  31. package/dist/usage.js +30 -2
  32. package/dist/usage.js.map +1 -1
  33. package/package.json +3 -3
  34. package/sbom.json +217 -27
  35. package/src/backlog.ts +176 -3
  36. package/src/index.ts +3 -0
  37. package/src/loop-plan-graph.ts +132 -0
  38. package/src/loop-plan.ts +13 -15
  39. package/src/model-recommender.ts +228 -0
  40. package/src/registry.ts +4 -1
  41. package/src/trace-bundle.ts +743 -0
  42. 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. */
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';
@@ -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';
@@ -0,0 +1,132 @@
1
+ /**
2
+ * loop-plan-graph (idea d25a3c8a) — the COMPLETENESS leg of loop-plan/1's closed-world checking.
3
+ *
4
+ * What existed before this module (the round-7 cross-family reviewer's ONE not-met bar item,
5
+ * SIGNOFF's "B-not-A reason 1"): `KNOWN_KEYS === INJECT` and the honesty test's `SCANNED` roster
6
+ * all compare artifacts DOWNSTREAM of FIELD_DOMAINS — equality proves the rosters are consistent
7
+ * with each other, never that they are COMPLETE against the interface source. The reviewer's
8
+ * constructive counterexample: declare `LoopStep.extra?: ExtraPolicy`, add only the parent
9
+ * `{t:'record'}` domain entry, and `extra: { enabeld: true }` escapes every check while every
10
+ * equality guard stays green — "a new record kind cannot escape is unproven and demonstrably
11
+ * false" (verbatim). The shipped mitigation was a documented four-step extension discipline — a
12
+ * layer-4 instruction, exactly the layer the cost-of-detection ladder says such a check must not
13
+ * live on.
14
+ *
15
+ * THE FIX (this module, layer 1): walk the interface graph from `LoopPlan` in the SOURCE TEXT,
16
+ * transitively collect every reachable named interface, and let the honesty test require that the
17
+ * reachable set is exactly the wired set. An interface reachable from LoopPlan but absent from the
18
+ * wiring fails BY CONSTRUCTION, naming itself — no memory, no discipline, no fourth manual step.
19
+ *
20
+ * PURE: operates on source text handed in by the caller; no fs, no clock. That is what lets the
21
+ * acceptance test run the reviewer's counterexample against a SABOTAGED COPY of the source and
22
+ * require a red, while the real source stays green.
23
+ */
24
+
25
+ /** One parsed field: its name and the DECLARED interface names its type text references. */
26
+ export interface GraphField {
27
+ readonly field: string;
28
+ readonly refs: readonly string[];
29
+ }
30
+
31
+ /** interface name → its fields (index signatures like `[xKey: \`x-${string}\`]` are excluded:
32
+ * they open no named-interface edge and are the extension escape hatch by design). */
33
+ export type InterfaceGraph = ReadonlyMap<string, readonly GraphField[]>;
34
+
35
+ /** Brace-matched interface extraction. A regex-only scan truncates at the first nested brace
36
+ * (inline object fields are everywhere in this file), so bodies are cut by depth counting. */
37
+ export function parseInterfaceGraph(source: string): InterfaceGraph {
38
+ const names = new Set<string>();
39
+ const headRe = /(?:^|\n)\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/g;
40
+ for (let m = headRe.exec(source); m !== null; m = headRe.exec(source)) names.add(m[1]!);
41
+
42
+ const graph = new Map<string, GraphField[]>();
43
+ headRe.lastIndex = 0;
44
+ for (let m = headRe.exec(source); m !== null; m = headRe.exec(source)) {
45
+ const name = m[1]!;
46
+ const open = source.indexOf('{', m.index + m[0].length);
47
+ if (open === -1) continue;
48
+ let depth = 0;
49
+ let close = -1;
50
+ for (let i = open; i < source.length; i += 1) {
51
+ const ch = source[i];
52
+ if (ch === '{') depth += 1;
53
+ else if (ch === '}') {
54
+ depth -= 1;
55
+ if (depth === 0) { close = i; break; }
56
+ }
57
+ }
58
+ if (close === -1) continue;
59
+ const body = source.slice(open + 1, close);
60
+
61
+ // Split the body into top-level entries at depth 0 (`;` inside an inline `{...}` must not cut).
62
+ const entries: string[] = [];
63
+ let entry = '';
64
+ let d = 0;
65
+ for (const ch of body) {
66
+ if (ch === '{' || ch === '(' || ch === '<' || ch === '[') d += 1;
67
+ else if (ch === '}' || ch === ')' || ch === '>' || ch === ']') d -= 1;
68
+ if (ch === ';' && d === 0) { entries.push(entry); entry = ''; continue; }
69
+ entry += ch;
70
+ }
71
+ if (entry.trim() !== '') entries.push(entry);
72
+
73
+ const fields: GraphField[] = [];
74
+ for (const raw of entries) {
75
+ // strip comments, then match `readonly? name?: TYPE`
76
+ const text = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '').trim();
77
+ if (text === '' || text.startsWith('[')) continue; // index signature — by-design escape hatch
78
+ const fm = /^(?:readonly\s+)?([A-Za-z_$][\w$]*)\s*\??:\s*([\s\S]+)$/.exec(text);
79
+ if (fm === null) continue;
80
+ const typeText = fm[2]!;
81
+ const refs = new Set<string>();
82
+ const idRe = /[A-Za-z_$][\w$]*/g;
83
+ for (let im = idRe.exec(typeText); im !== null; im = idRe.exec(typeText)) {
84
+ if (names.has(im[0]) && im[0] !== name) refs.add(im[0]);
85
+ }
86
+ fields.push({ field: fm[1]!, refs: [...refs] });
87
+ }
88
+ graph.set(name, fields);
89
+ }
90
+ return graph;
91
+ }
92
+
93
+ /** Every interface reachable from `root` (inclusive), via any field's declared-interface refs —
94
+ * arrays, unions and nullables all count: `LoopStep[]`, `RetryProfile | null` open the same edge. */
95
+ export function reachableInterfaces(graph: InterfaceGraph, root: string): string[] {
96
+ const seen = new Set<string>();
97
+ const queue = [root];
98
+ while (queue.length > 0) {
99
+ const name = queue.shift()!;
100
+ if (seen.has(name) || !graph.has(name)) continue;
101
+ seen.add(name);
102
+ for (const f of graph.get(name)!) for (const ref of f.refs) if (!seen.has(ref)) queue.push(ref);
103
+ }
104
+ return [...seen].sort();
105
+ }
106
+
107
+ export interface GraphWiringReport {
108
+ readonly ok: boolean;
109
+ /** Reachable from the root but NOT in the wired roster — each one is exactly the reviewer's
110
+ * counterexample: a record kind whose key space is open while every equality guard stays green. */
111
+ readonly unwired: string[];
112
+ readonly reachable: string[];
113
+ /** Wired but no longer reachable — a stale roster entry (the reverse rot). */
114
+ readonly stale: string[];
115
+ }
116
+
117
+ /** The completeness check the equality guards could not perform: reachable(source) vs wired. */
118
+ export function checkGraphWiring(source: string, wired: readonly string[], root = 'LoopPlan'): GraphWiringReport {
119
+ const graph = parseInterfaceGraph(source);
120
+ const reachable = reachableInterfaces(graph, root);
121
+ const wiredSet = new Set(wired);
122
+ const reachableSet = new Set(reachable);
123
+ const unwired = reachable.filter((n) => !wiredSet.has(n));
124
+ // The wired roster (KNOWN_KEYS) legitimately mixes interface names with INLINE-record FIELD names
125
+ // (`artifacts`, `budget`, `checkpointing`, …) — those are the inlineSubFields machinery's
126
+ // business, not this check's. Staleness is judged only for entries that ARE declared interfaces
127
+ // in this source: a declared-but-unreachable interface in the roster is real rot; an inline field
128
+ // name is not an interface and must not be reported as one (caught on the first live run: five
129
+ // false stale entries, all inline fields).
130
+ const stale = [...wiredSet].filter((n) => graph.has(n) && !reachableSet.has(n)).sort();
131
+ return { ok: unwired.length === 0 && stale.length === 0, unwired, reachable, stale };
132
+ }
package/src/loop-plan.ts CHANGED
@@ -389,22 +389,20 @@ export const FIELD_DOMAINS: Record<string, FieldDomain> = {
389
389
  // domain entry fails the honesty test; adding it WITH one makes it known here automatically. There
390
390
  // is exactly one roster, and it is the source's.
391
391
  //
392
- // WHAT THIS DOES AND DOES NOT PROVE (QE round-7, the cross-family reviewer's ONE not-met bar item,
393
- // CONCEDED). PROVEN, and tested: every record path CURRENTLY WIRED here is closed no key of any
394
- // present-day spelling reaches the plan without a diagnostic, and the accepted roster is the
395
- // source's, not a second hand-list. NOT PROVEN, and it would be an overclaim to say otherwise: that
396
- // a record kind added in the FUTURE is closed AUTOMATICALLY. Three things remain hand-bounded —
397
- // (a) the honesty test's `SCANNED` names the interfaces it scans, (b) the fuzz's `INJECT` names the
398
- // injection sites, and (c) `checkKeys`' parser DESCENT names which nested records it walks. The
399
- // reviewer's constructive counterexample: declare `LoopStep.extra?: ExtraPolicy` with only a
400
- // `LoopStep.extra: {t:'record'}` domain entry and an `ExtraPolicy { enabled?: boolean }` interface
401
- // unless someone ALSO hand-adds ExtraPolicy's own domains, the descent, `SCANNED` and `INJECT`, then
402
- // `extra: { enabeld: true }` escapes closed-world checking WHILE the equality assertions stay green.
403
- // That is a future-extension / proof-maintenance hole, not an input bypass that works today.
392
+ // WHAT THIS PROVES (updated 2026-08-17, idea d25a3c8a the round-7 not-met bar item is now MET).
393
+ // PROVEN, and tested: every record path wired here is closed, the accepted roster is the source's,
394
+ // AND the roster is COMPLETE against the interface graph: `loop-plan-graph.ts` walks the interfaces
395
+ // reachable from LoopPlan in this file's SOURCE and the honesty test requires reachable == wired ==
396
+ // SCANNED. The reviewer's constructive counterexample (`LoopStep.extra?: ExtraPolicy` with a
397
+ // parent-only domain entry) is the ACCEPTANCE TEST: it goes red naming ExtraPolicy, by
398
+ // construction, before any hand step. Still out of scope, said plainly: interfaces referenced only
399
+ // through type ALIASES are followed one identifier deep (the graph collects declared-interface
400
+ // names from the field's type text); an alias chain that hides an interface behind a non-interface
401
+ // alias would need the alias declared in this file to be walked.
404
402
  //
405
- // EXTENSION DISCIPLINE (the manual step the derivation does not perform for you). When you add a
406
- // NEW nested record-typed field, do ALL FOUR in the same change, or the key space it opens is not
407
- // closed: 1) add `<NewIface>.<field>` entries to FIELD_DOMAINS for every field of the new interface
403
+ // EXTENSION CONVENIENCE (no longer load-bearing the graph-completeness test reddens on a missed
404
+ // step by construction; this list just tells you what the red means). When you add a NEW nested
405
+ // record-typed field: 1) add `<NewIface>.<field>` entries to FIELD_DOMAINS for every field of the new interface
408
406
  // (not just the `{t:'record'}` entry on its PARENT); 2) add the new interface to the honesty test's
409
407
  // `SCANNED`; 3) add an `INJECT` site for it in the closed-world fuzz; 4) descend into it in
410
408
  // `checkKeys`. A structural fix that derives 2–4 from the interface graph — so a new record kind is
@@ -0,0 +1,228 @@
1
+ /**
2
+ * model-recommender (backlog a9c3dd5c, function 3) — the PURE half of `dz routing recommend`.
3
+ *
4
+ * NOT a fifth analyzer (ADR-001 D1): this module HARVESTS per-stage (model → success) samples out of
5
+ * the harness's own workflow records (and imported run-meta sidecars) and hands them to the EXISTING
6
+ * `selectAutoCost` brain in routing-outcomes.ts — the same brain the `auto-cost` plan spec reads. The
7
+ * store that brain trusts (`.dz/routing-outcomes.json`) had never been fed before this feature.
8
+ *
9
+ * Honesty rules, load-bearing:
10
+ * - The ONLY grade a record carries is RUN-level. Attributing it to every stage's model is an
11
+ * INFERENCE, and the printed basis states the rule rather than implying it (ADR-001 D2).
12
+ * - Cross-family QE is UNREPRESENTABLE, not filtered: the qe pick is computed with the family
13
+ * parameter forced to the cross of the code pick's family (ADR-001 D3).
14
+ * - `--apply` idempotency lives here as a pure plan (`planFeed`): double-feeding the same runs
15
+ * would manufacture confidence the data does not contain (ADR-001 D4).
16
+ *
17
+ * No fs, no clock, no randomness — the CLI reads records and does the I/O.
18
+ */
19
+
20
+ import { COST_LADDER, selectAutoCost, type AutoCostPick, type Family, type ModelRung } from './routing-outcomes.js';
21
+
22
+ /** success ⇔ grade ≥ this floor. DATA, exported, and printed in every basis (FR-4). */
23
+ export const GRADE_SUCCESS_FLOOR = 'B';
24
+ const SUCCESS_GRADES = new Set(['A+', 'A', 'A-', 'B+', 'B']);
25
+
26
+ export function gradeIsSuccess(grade: string): boolean {
27
+ return SUCCESS_GRADES.has(grade.trim().toUpperCase());
28
+ }
29
+
30
+ export interface HarvestSample {
31
+ readonly runId: string;
32
+ readonly ts: string | null;
33
+ readonly tier: string;
34
+ readonly stage: string;
35
+ /** Normalized to a COST_LADDER rung id (e.g. `codex:gpt-5.5:xhigh (usage-switched)` → `gpt-5.5`). */
36
+ readonly model: string;
37
+ readonly success: boolean;
38
+ readonly grade: string;
39
+ }
40
+
41
+ export interface Harvest {
42
+ readonly samples: HarvestSample[];
43
+ readonly runsUsed: number;
44
+ readonly window: { min: string; max: string } | null;
45
+ /** Records that contributed nothing, by WHY — printed in the basis, never silent (FR-7). */
46
+ readonly skipped: { noResult: number; noModels: number; noGrade: number; unknownModel: number };
47
+ /** The attribution rule, stated for the reader of every recommendation. */
48
+ readonly rule: string;
49
+ }
50
+
51
+ const RULE_TEXT =
52
+ `success ⇔ QE grade ≥ ${GRADE_SUCCESS_FLOOR} (run-level); the run's ONE grade is attributed to every ` +
53
+ `stage's model of that run — an inference, stated here because a hidden basis is an opinion in uniform`;
54
+
55
+ /** `codex:gpt-5.5:xhigh (usage-switched)` → `gpt-5.5`; claude ids pass through; unknown → null. */
56
+ export function normalizeModelId(raw: unknown): string | null {
57
+ if (typeof raw !== 'string' || raw === '') return null;
58
+ let id = raw.replace(/ \(usage-switched\)$/, '').trim();
59
+ const codex = /^codex:([^:]+)(?::[a-z]+)?$/.exec(id);
60
+ if (codex !== null) id = codex[1]!;
61
+ return COST_LADDER.some((r) => r.id === id) ? id : null;
62
+ }
63
+
64
+ function isRecord(v: unknown): v is Record<string, unknown> {
65
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
66
+ }
67
+
68
+ /** The run-level grade, wherever this record's era put it (`result.qeGrade`, `result.grade`,
69
+ * `result.qe.grade`). A string that does not look like a grade is not one. */
70
+ function extractGrade(result: Record<string, unknown>): string | null {
71
+ const looksLikeGrade = (v: unknown): v is string => typeof v === 'string' && /^[A-F][+-]?$/.test(v.trim().toUpperCase());
72
+ if (looksLikeGrade(result['qeGrade'])) return (result['qeGrade'] as string).trim().toUpperCase();
73
+ if (looksLikeGrade(result['grade'])) return (result['grade'] as string).trim().toUpperCase();
74
+ const qe = result['qe'];
75
+ if (isRecord(qe) && looksLikeGrade(qe['grade'])) return (qe['grade'] as string).trim().toUpperCase();
76
+ return null;
77
+ }
78
+
79
+ /** Harvest per-stage samples from already-read records (live harness records AND the `runMeta.records`
80
+ * of imported run-meta sidecars — they are the same shape by construction). */
81
+ export function harvestStageOutcomes(records: readonly unknown[]): Harvest {
82
+ const samples: HarvestSample[] = [];
83
+ const skipped = { noResult: 0, noModels: 0, noGrade: 0, unknownModel: 0 };
84
+ const runs = new Set<string>();
85
+ let min: string | null = null;
86
+ let max: string | null = null;
87
+ for (const source of records) {
88
+ if (!isRecord(source)) { skipped.noResult += 1; continue; }
89
+ const result = source['result'];
90
+ if (!isRecord(result)) { skipped.noResult += 1; continue; }
91
+ const modelsUsed = result['modelsUsed'];
92
+ if (!isRecord(modelsUsed)) { skipped.noModels += 1; continue; }
93
+ const grade = extractGrade(result);
94
+ if (grade === null) { skipped.noGrade += 1; continue; }
95
+ const runId = typeof source['runId'] === 'string' ? source['runId'] : JSON.stringify(modelsUsed).slice(0, 40);
96
+ const ts = typeof source['timestamp'] === 'string' ? source['timestamp'] : null;
97
+ const tier = typeof result['tier'] === 'string' && result['tier'] !== '' ? result['tier'] : 'unknown';
98
+ let contributed = false;
99
+ for (const [stage, rawModel] of Object.entries(modelsUsed)) {
100
+ const model = normalizeModelId(rawModel);
101
+ if (model === null) { skipped.unknownModel += 1; continue; }
102
+ contributed = true;
103
+ samples.push({ runId, ts, tier, stage, model, success: gradeIsSuccess(grade), grade });
104
+ }
105
+ if (contributed) {
106
+ runs.add(runId);
107
+ if (ts !== null) {
108
+ if (min === null || ts < min) min = ts;
109
+ if (max === null || ts > max) max = ts;
110
+ }
111
+ }
112
+ }
113
+ return {
114
+ samples,
115
+ runsUsed: runs.size,
116
+ window: min !== null && max !== null ? { min, max } : null,
117
+ skipped,
118
+ rule: RULE_TEXT,
119
+ };
120
+ }
121
+
122
+ export interface StageRecommendation {
123
+ readonly stage: string;
124
+ /** The spec string for `args.models` — claude rung ids pass through; openai rungs render as `codex:<id>:high`. */
125
+ readonly spec: string;
126
+ readonly pick: AutoCostPick;
127
+ readonly family: Family;
128
+ readonly samples: number;
129
+ /** `selectAutoCost` met its quality bar on ≥minSamples — otherwise this is cold-start, SAID. */
130
+ readonly insufficientData: boolean;
131
+ }
132
+
133
+ export interface Recommendation {
134
+ readonly perStage: StageRecommendation[];
135
+ readonly basis: {
136
+ readonly runsUsed: number;
137
+ readonly window: Harvest['window'];
138
+ readonly rule: string;
139
+ readonly skipped: Harvest['skipped'];
140
+ readonly crossFamilyNote: string;
141
+ };
142
+ }
143
+
144
+ function rungFamily(id: string): Family {
145
+ const rung: ModelRung | undefined = COST_LADDER.find((r) => r.id === id);
146
+ return rung !== undefined ? rung.family : 'claude';
147
+ }
148
+
149
+ function toSpec(id: string): string {
150
+ return rungFamily(id) === 'openai' ? `codex:${id}:high` : id;
151
+ }
152
+
153
+ const CROSS_NOTE =
154
+ 'the qe pick is computed with the family FORCED to the cross of the code pick — a same-family qe recommendation is unrepresentable (ADR-001 D3)';
155
+
156
+ /** Recommend per stage over the harvested samples (optionally one tier's slice). */
157
+ export function recommendModels(harvest: Harvest, opts: { tier?: string; qualityBar?: number; minSamples?: number } = {}): Recommendation {
158
+ const slice = opts.tier === undefined ? harvest.samples : harvest.samples.filter((s) => s.tier === opts.tier);
159
+ const tierLabel = opts.tier ?? 'all';
160
+ const byStage = new Map<string, HarvestSample[]>();
161
+ for (const s of slice) {
162
+ const bucket = byStage.get(s.stage) ?? [];
163
+ bucket.push(s);
164
+ byStage.set(s.stage, bucket);
165
+ }
166
+ const statsFor = (stage: string) => (model: string) => {
167
+ const rows = (byStage.get(stage) ?? []).filter((s) => s.model === model);
168
+ const successes = rows.filter((s) => s.success).length;
169
+ return { attempts: rows.length, successes, successRate: rows.length === 0 ? 0 : successes / rows.length };
170
+ };
171
+ const pickFor = (stage: string, family?: Family): StageRecommendation => {
172
+ const pick = selectAutoCost(stage, tierLabel, statsFor(stage), {
173
+ ...(opts.qualityBar !== undefined ? { qualityBar: opts.qualityBar } : {}),
174
+ ...(opts.minSamples !== undefined ? { minSamples: opts.minSamples } : {}),
175
+ ...(family !== undefined ? { family } : {}),
176
+ });
177
+ return {
178
+ stage,
179
+ spec: toSpec(pick.model),
180
+ pick,
181
+ family: rungFamily(pick.model),
182
+ samples: (byStage.get(stage) ?? []).length,
183
+ insufficientData: !pick.metBar,
184
+ };
185
+ };
186
+
187
+ const stages = [...byStage.keys()].sort();
188
+ const perStage: StageRecommendation[] = [];
189
+ // code first — the qe family depends on it (D3).
190
+ const code = pickFor('code');
191
+ for (const stage of stages) {
192
+ if (stage === 'qe') {
193
+ const cross: Family = code.family === 'claude' ? 'openai' : 'claude';
194
+ perStage.push(pickFor('qe', cross));
195
+ } else {
196
+ perStage.push(pickFor(stage));
197
+ }
198
+ }
199
+ if (!stages.includes('code')) perStage.unshift(code);
200
+ return {
201
+ perStage,
202
+ basis: { runsUsed: harvest.runsUsed, window: harvest.window, rule: harvest.rule, skipped: harvest.skipped, crossFamilyNote: CROSS_NOTE },
203
+ };
204
+ }
205
+
206
+ /* ── the idempotent feed plan (ADR-001 D4) ─────────────────────────────────────────────── */
207
+
208
+ export interface FeedPlan {
209
+ /** Samples whose runId has not been fed before — the CLI calls finalizeOutcome for each. */
210
+ readonly toFeed: HarvestSample[];
211
+ readonly skippedRuns: string[];
212
+ /** The new fed-set the CLI persists after feeding. */
213
+ readonly fedAfter: string[];
214
+ }
215
+
216
+ /** A run feeds ONCE. Double-feeding the same telemetry manufactures confidence the data does not
217
+ * contain — the second `--apply` must feed 0 and say which runs it skipped. */
218
+ export function planFeed(samples: readonly HarvestSample[], alreadyFed: readonly string[]): FeedPlan {
219
+ const fed = new Set(alreadyFed);
220
+ const toFeed: HarvestSample[] = [];
221
+ const skippedRuns = new Set<string>();
222
+ for (const s of samples) {
223
+ if (fed.has(s.runId)) skippedRuns.add(s.runId);
224
+ else toFeed.push(s);
225
+ }
226
+ const fedAfter = [...new Set([...alreadyFed, ...toFeed.map((s) => s.runId)])].sort();
227
+ return { toFeed, skippedRuns: [...skippedRuns].sort(), fedAfter };
228
+ }