@dzhechkov/harness-core 0.4.2 → 0.4.4

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 (62) hide show
  1. package/.dz-manifest.json +104 -28
  2. package/README.md +15 -4
  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-blobs.generated.d.ts.map +1 -1
  12. package/dist/loop-blobs.generated.js +1 -0
  13. package/dist/loop-blobs.generated.js.map +1 -1
  14. package/dist/loop-lint.d.ts +6 -2
  15. package/dist/loop-lint.d.ts.map +1 -1
  16. package/dist/loop-lint.js +54 -0
  17. package/dist/loop-lint.js.map +1 -1
  18. package/dist/loop-plan-graph.d.ts +49 -0
  19. package/dist/loop-plan-graph.d.ts.map +1 -0
  20. package/dist/loop-plan-graph.js +128 -0
  21. package/dist/loop-plan-graph.js.map +1 -0
  22. package/dist/loop-plan.d.ts +17 -0
  23. package/dist/loop-plan.d.ts.map +1 -1
  24. package/dist/loop-plan.js +18 -15
  25. package/dist/loop-plan.js.map +1 -1
  26. package/dist/loop-render.d.ts.map +1 -1
  27. package/dist/loop-render.js +8 -0
  28. package/dist/loop-render.js.map +1 -1
  29. package/dist/model-recommender.d.ts +91 -0
  30. package/dist/model-recommender.d.ts.map +1 -0
  31. package/dist/model-recommender.js +186 -0
  32. package/dist/model-recommender.js.map +1 -0
  33. package/dist/registry.d.ts.map +1 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/registry.js.map +1 -1
  36. package/dist/skills-verify.d.ts +40 -0
  37. package/dist/skills-verify.d.ts.map +1 -1
  38. package/dist/skills-verify.js +80 -10
  39. package/dist/skills-verify.js.map +1 -1
  40. package/dist/trace-bundle.d.ts +209 -0
  41. package/dist/trace-bundle.d.ts.map +1 -0
  42. package/dist/trace-bundle.js +601 -0
  43. package/dist/trace-bundle.js.map +1 -0
  44. package/dist/usage.d.ts +7 -0
  45. package/dist/usage.d.ts.map +1 -1
  46. package/dist/usage.js +30 -2
  47. package/dist/usage.js.map +1 -1
  48. package/package.json +18 -18
  49. package/sbom.json +217 -27
  50. package/src/backlog.ts +176 -3
  51. package/src/index.ts +3 -0
  52. package/src/loop-blobs.generated.ts +1 -0
  53. package/src/loop-lint.ts +55 -1
  54. package/src/loop-plan-graph.ts +132 -0
  55. package/src/loop-plan.ts +35 -15
  56. package/src/loop-render.ts +8 -0
  57. package/src/model-recommender.ts +228 -0
  58. package/src/registry.ts +4 -1
  59. package/src/skills-verify.ts +108 -10
  60. package/src/trace-bundle.ts +743 -0
  61. package/src/usage.ts +42 -2
  62. package/LICENSE +0 -21
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';
@@ -32,6 +32,7 @@ export const LOOP_BLOB_NAMES = ["checkpoints","training-pairs","model-resolver",
32
32
  * tracked dz-backlog item, deliberately NOT claimed here. */
33
33
  export const BLOB_COVERAGE_MANIFEST: { coveredWorkflows: string[] } = {
34
34
  "coveredWorkflows": [
35
+ ".claude/workflows/cfr-pipeline.js",
35
36
  ".claude/workflows/feature-adr.js",
36
37
  ".claude/workflows/health-advisor.js",
37
38
  "packages/@dzhechkov/skills-feature-adr/templates/.claude/workflows/feature-adr.js"
package/src/loop-lint.ts CHANGED
@@ -41,7 +41,7 @@ export interface LintRun {
41
41
  verdict: LintVerdict;
42
42
  mode: LintMode;
43
43
  findings: LintFinding[];
44
- /** Per-rule verdict map (17 rules — every rule reports, none silently skipped). */
44
+ /** Per-rule verdict map (18 rules — every rule reports, none silently skipped). */
45
45
  rules: Record<string, LintSeverity>;
46
46
  }
47
47
 
@@ -62,6 +62,7 @@ export const LINT_RULES = [
62
62
  'pause-wired',
63
63
  'dispatch-by-deliverable',
64
64
  'no-agent-outside-runstep',
65
+ 'tool-perimeter-declared',
65
66
  'size-budget',
66
67
  ] as const;
67
68
  export type LintRuleId = (typeof LINT_RULES)[number];
@@ -563,6 +564,58 @@ function ruleNoAgentOutsideRunstep(ctx: Ctx): LintFinding[] {
563
564
  return out;
564
565
  }
565
566
 
567
+ /** The `<server>:<capability>` grammar a declared perimeter entry must match (ADR-002 §1). Two or
568
+ * more colon-separated lowercase segments — a bare `gitlab` names a server with no capability and
569
+ * is exactly the shape that reads as "everything on that server". */
570
+ export const TOOL_PERIMETER_ENTRY_RE = /^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)+$/;
571
+
572
+ /**
573
+ * `tool-perimeter-declared` (cfr-pipeline ADR-002) — every DISPATCHING step (`agent`|`gate`)
574
+ * declares its MCP tool perimeter, well-formed and without duplicates, and the rendered contract
575
+ * line agrees with the declared array.
576
+ *
577
+ * GENERIC by construction: it knows nothing about GitLab, Jira, Wiki or JSM — only that a
578
+ * dispatching step must SAY what it may touch. **Absence FLAGS: silence is never permission.**
579
+ * `tools: []` is the correct, meaningful declaration for a step that touches no external tool.
580
+ *
581
+ * SEVERITY IS STAGED (checkpoint amendment 2, overriding ADR-002 §Rationale-5's FAIL-everywhere
582
+ * stance): WARN by default, FAIL only under `--require-plan`. That keeps the published 0.4.x lint
583
+ * contract of harness-core/harness-cli non-breaking while still making the rule a hard gate
584
+ * exactly where cfr-pipeline runs it. Plan-less scripts report `inconclusive` (`no-plan-binding`)
585
+ * like every other plan-anchored rule — never a silent pass.
586
+ */
587
+ function ruleToolPerimeterDeclared(ctx: Ctx): LintFinding[] {
588
+ if (ctx.projection === null) return noPlan('tool-perimeter-declared');
589
+ const sev: LintSeverity = ctx.mode === 'require-plan' ? 'fail' : 'warn';
590
+ const out: LintFinding[] = [];
591
+ for (const f of ctx.projection.facts) {
592
+ if (f.kind !== 'agent' && f.kind !== 'gate') continue;
593
+ if (f.tools === null) {
594
+ out.push({ rule: 'tool-perimeter-declared', severity: sev, message: `dispatching step ${f.id} declares no \`tools\` perimeter — absence FLAGS: silence is never permission (declare \`tools: []\` if the step touches no external tool)`, anchor: f.id });
595
+ continue;
596
+ }
597
+ const seen = new Set<string>();
598
+ for (const entry of f.tools) {
599
+ if (!TOOL_PERIMETER_ENTRY_RE.test(entry)) {
600
+ out.push({ rule: 'tool-perimeter-declared', severity: sev, message: `step ${f.id}: tool perimeter entry "${entry}" does not match <server>:<capability> (${String(TOOL_PERIMETER_ENTRY_RE)}) — a malformed entry cannot be matched against a real server inventory`, anchor: f.id });
601
+ }
602
+ if (seen.has(entry)) {
603
+ out.push({ rule: 'tool-perimeter-declared', severity: sev, message: `step ${f.id}: tool perimeter entry "${entry}" is declared twice — a duplicate hides a copy/paste of the wrong stage's perimeter`, anchor: f.id });
604
+ }
605
+ seen.add(entry);
606
+ }
607
+ // script cross-check: the rendered contract line must agree with the DECLARED array — a plan
608
+ // edit that never re-rendered would otherwise pass on the plan half alone.
609
+ if (f.tools.length > 0) {
610
+ const expected = 'declared MCP tool allowlist (plan tools): ' + f.tools.join(', ') + ' — use NOTHING outside it.';
611
+ if (!ctx.script.includes(expected)) {
612
+ out.push({ rule: 'tool-perimeter-declared', severity: sev, message: `step ${f.id}: the rendered script carries no contract line matching the declared perimeter [${f.tools.join(', ')}] — the script is stale against the plan, or the perimeter is decorative`, anchor: f.id });
613
+ }
614
+ }
615
+ }
616
+ return out;
617
+ }
618
+
566
619
  function ruleSizeBudget(ctx: Ctx): LintFinding[] {
567
620
  const n = ctx.lines.length;
568
621
  if (n > SIZE_BUDGET_WARN_LINES) {
@@ -623,6 +676,7 @@ export function lint(scriptText: string, opts: LintOptions = {}): LintRun {
623
676
  run('pause-wired', () => rulePauseWired(ctx));
624
677
  run('dispatch-by-deliverable', () => ruleDispatchByDeliverable(ctx));
625
678
  run('no-agent-outside-runstep', () => ruleNoAgentOutsideRunstep(ctx));
679
+ run('tool-perimeter-declared', () => ruleToolPerimeterDeclared(ctx));
626
680
  run('size-budget', () => ruleSizeBudget(ctx));
627
681
 
628
682
  const severities = Object.values(rules);
@@ -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
@@ -115,6 +115,20 @@ export interface LoopStep {
115
115
  * exec fingerprint in two review rounds; see roadmap. */
116
116
  checkpoint?: boolean;
117
117
  budget?: { maxAgents: number };
118
+ /**
119
+ * DECLARED MCP tool perimeter for this dispatching step (cfr-pipeline ADR-002).
120
+ *
121
+ * A `<server>:<capability>` allowlist (`^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)+$`, checked by the
122
+ * `tool-perimeter-declared` lint rule, not by the schema — the schema owns only the TYPE).
123
+ * ENACTED by `stepPromptAssembly`: a non-empty array renders a fixed contract line into the
124
+ * step's prompt, so the perimeter is COMMUNICATED to the agent rather than decorative. An empty
125
+ * array is meaningful and is the correct value for a step that touches no external tool.
126
+ *
127
+ * HONESTY, said once and never softened elsewhere: this is a DECLARATION, not enforcement.
128
+ * `agent()` exposes no tool restriction; real enforcement lives at the MCP server. No document
129
+ * may call this a sandbox.
130
+ */
131
+ tools?: string[];
118
132
  /** Dispatch route. v1 NARROWING (QE round 6): only 'inline' is enacted — 'codex-wrapper' and
119
133
  * 'codex-exec' are VALIDATED-AWAY (ENACT-DISPATCH). The fire-and-forget wrapper returns a stub
120
134
  * that reads as a clean result, and codex-exec had no live-proven enactment here; see roadmap. */
@@ -328,6 +342,7 @@ export const FIELD_DOMAINS: Record<string, FieldDomain> = {
328
342
  'LoopStep.cache': { t: 'record' },
329
343
  'LoopStep.checkpoint': { t: 'boolean' },
330
344
  'LoopStep.budget': { t: 'record' },
345
+ 'LoopStep.tools': { t: 'string[]' },
331
346
  'LoopStep.dispatch': { t: 'enum', values: ['inline', 'codex-wrapper', 'codex-exec'] },
332
347
  'LoopStep.pauseState': { t: 'string' },
333
348
  'artifacts.reads': { t: 'string[]' },
@@ -389,22 +404,20 @@ export const FIELD_DOMAINS: Record<string, FieldDomain> = {
389
404
  // domain entry fails the honesty test; adding it WITH one makes it known here automatically. There
390
405
  // is exactly one roster, and it is the source's.
391
406
  //
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.
407
+ // WHAT THIS PROVES (updated 2026-08-17, idea d25a3c8a the round-7 not-met bar item is now MET).
408
+ // PROVEN, and tested: every record path wired here is closed, the accepted roster is the source's,
409
+ // AND the roster is COMPLETE against the interface graph: `loop-plan-graph.ts` walks the interfaces
410
+ // reachable from LoopPlan in this file's SOURCE and the honesty test requires reachable == wired ==
411
+ // SCANNED. The reviewer's constructive counterexample (`LoopStep.extra?: ExtraPolicy` with a
412
+ // parent-only domain entry) is the ACCEPTANCE TEST: it goes red naming ExtraPolicy, by
413
+ // construction, before any hand step. Still out of scope, said plainly: interfaces referenced only
414
+ // through type ALIASES are followed one identifier deep (the graph collects declared-interface
415
+ // names from the field's type text); an alias chain that hides an interface behind a non-interface
416
+ // alias would need the alias declared in this file to be walked.
404
417
  //
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
418
+ // EXTENSION CONVENIENCE (no longer load-bearing the graph-completeness test reddens on a missed
419
+ // step by construction; this list just tells you what the red means). When you add a NEW nested
420
+ // record-typed field: 1) add `<NewIface>.<field>` entries to FIELD_DOMAINS for every field of the new interface
408
421
  // (not just the `{t:'record'}` entry on its PARENT); 2) add the new interface to the honesty test's
409
422
  // `SCANNED`; 3) add an `INJECT` site for it in the closed-world fuzz; 4) descend into it in
410
423
  // `checkKeys`. A structural fix that derives 2–4 from the interface graph — so a new record kind is
@@ -537,6 +550,9 @@ export const STEP_FIELD_KINDS: Record<string, readonly StepKind[]> = {
537
550
  budget: ALL_KINDS, // budget is an ACCOUNTING weight — summed into the rendered budget guard for every kind
538
551
  prompt: DISPATCHING_KINDS,
539
552
  artifacts: DISPATCHING_KINDS,
553
+ // the perimeter rides the PROMPT, so it enacts exactly where a prompt is dispatched — `tools`
554
+ // on a fanout/join/pause step would be an unperformed promise (KIND-1 rejects it).
555
+ tools: DISPATCHING_KINDS,
540
556
  model: DISPATCHING_KINDS,
541
557
  deliverable: DISPATCHING_KINDS,
542
558
  idempotent: DISPATCHING_KINDS,
@@ -1277,6 +1293,9 @@ export interface LintProjection {
1277
1293
  dispatch: DispatchRoute;
1278
1294
  cacheable: boolean;
1279
1295
  writes: string[];
1296
+ /** DECLARED tool perimeter, or `null` when the step declares none (ADR-002: absence FLAGS —
1297
+ * silence is never permission, so `null` and `[]` must stay distinguishable here). */
1298
+ tools: string[] | null;
1280
1299
  }[];
1281
1300
  pauses: { state: string; resumeArg: string }[];
1282
1301
  checkpointingEnabled: boolean;
@@ -1347,6 +1366,7 @@ export function toLintProjection(plan: LoopPlan): LintProjection {
1347
1366
  dispatch: s.dispatch ?? 'inline',
1348
1367
  cacheable: s.cacheable === true,
1349
1368
  writes: s.artifacts?.writes ?? [],
1369
+ tools: Array.isArray(s.tools) ? [...s.tools] : null,
1350
1370
  };
1351
1371
  });
1352
1372
 
@@ -199,6 +199,14 @@ function stepPromptAssembly(s: LoopStep, plan: LoopPlan): string[] {
199
199
  const fileNote = (s.deliverable ?? 'return-value') === 'file' ? '; your deliverable is the written file(s), not your reply' : '';
200
200
  lines.push(` ${jsString('declared outputs (plan artifacts.writes): ' + writes.join(', ') + ' — write them' + fileNote + '. The loop verifies they land.')},`);
201
201
  }
202
+ // ENACTS LoopStep.tools (cfr-pipeline ADR-002): the declared perimeter is COMMUNICATED to the
203
+ // agent in FIXED wording, so its presence is greppable by a layer-1 test. The second sentence is
204
+ // not decoration — it is the honesty clause the whole feature rests on: a declaration is not
205
+ // enforcement, and every environment this ships into today serves the tools as labeled stubs.
206
+ const tools = s.tools ?? [];
207
+ if (tools.length > 0) {
208
+ lines.push(` ${jsString('declared MCP tool allowlist (plan tools): ' + tools.join(', ') + ' — use NOTHING outside it. In this environment every one of these is a labeled STUB, not a live integration; enforcement lives at the MCP server, not here.')},`);
209
+ }
202
210
  if (s.kind === 'gate') {
203
211
  lines.push(` ${jsString('GATE PROTOCOL (kind: ' + (gateCfg?.kind ?? 'gate') + '): end your reply with exactly one line "GATE: PASS" or "GATE: FAIL" — the loop PARSES this verdict and never synthesizes one.')},`);
204
212
  }