@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.
- package/.dz-manifest.json +104 -28
- package/README.md +3 -2
- package/dist/backlog.d.ts +35 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +167 -3
- package/dist/backlog.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +49 -0
- package/dist/loop-plan-graph.d.ts.map +1 -0
- package/dist/loop-plan-graph.js +128 -0
- package/dist/loop-plan-graph.js.map +1 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +13 -15
- package/dist/loop-plan.js.map +1 -1
- package/dist/model-recommender.d.ts +91 -0
- package/dist/model-recommender.d.ts.map +1 -0
- package/dist/model-recommender.js +186 -0
- package/dist/model-recommender.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -1
- package/dist/registry.js.map +1 -1
- package/dist/trace-bundle.d.ts +209 -0
- package/dist/trace-bundle.d.ts.map +1 -0
- package/dist/trace-bundle.js +601 -0
- package/dist/trace-bundle.js.map +1 -0
- package/dist/usage.d.ts +7 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +30 -2
- package/dist/usage.js.map +1 -1
- package/package.json +3 -3
- package/sbom.json +217 -27
- package/src/backlog.ts +176 -3
- package/src/index.ts +3 -0
- package/src/loop-plan-graph.ts +132 -0
- package/src/loop-plan.ts +13 -15
- package/src/model-recommender.ts +228 -0
- package/src/registry.ts +4 -1
- package/src/trace-bundle.ts +743 -0
- 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
|
-
|
|
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
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
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
|
|
406
|
-
//
|
|
407
|
-
//
|
|
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
|
+
}
|