@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.
- package/.dz-manifest.json +104 -28
- package/README.md +15 -4
- 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-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +1 -0
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts +6 -2
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +54 -0
- package/dist/loop-lint.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 +17 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +18 -15
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +8 -0
- package/dist/loop-render.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/skills-verify.d.ts +40 -0
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +80 -10
- package/dist/skills-verify.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 +18 -18
- package/sbom.json +217 -27
- package/src/backlog.ts +176 -3
- package/src/index.ts +3 -0
- package/src/loop-blobs.generated.ts +1 -0
- package/src/loop-lint.ts +55 -1
- package/src/loop-plan-graph.ts +132 -0
- package/src/loop-plan.ts +35 -15
- package/src/loop-render.ts +8 -0
- package/src/model-recommender.ts +228 -0
- package/src/registry.ts +4 -1
- package/src/skills-verify.ts +108 -10
- package/src/trace-bundle.ts +743 -0
- package/src/usage.ts +42 -2
- 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
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
|
406
|
-
//
|
|
407
|
-
//
|
|
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
|
|
package/src/loop-render.ts
CHANGED
|
@@ -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
|
}
|