slice-tournament-zoo 0.5.6

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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Selection pressure — the pressure log (F9, N12 vocabulary).
3
+ *
4
+ * Culled specimens' diffs + judge critiques + hack findings are persisted to
5
+ * `50-pressure/slice-NN/` as structured negative-exemplar context. If the
6
+ * failure-replan loop activates, the top-K=4 surviving summaries (PDR pattern)
7
+ * form the refinement context for the next round of specimens.
8
+ */
9
+ import type { Advantage, HackFinding, SpecimenId } from "./types.js";
10
+ import { mostInformative } from "./grpo.js";
11
+
12
+ export const PDR_K = 4;
13
+
14
+ export interface CulledSpecimen {
15
+ specimen: SpecimenId;
16
+ /** Why it was culled (gate-fail reason or judge rank). */
17
+ reason: string;
18
+ /** Unified diff of the specimen's attempt (negative exemplar). */
19
+ diff: string;
20
+ /** Judge critique prose, if it reached the judge. */
21
+ critique: string;
22
+ hackFindings: HackFinding[];
23
+ }
24
+
25
+ export interface PressureLog {
26
+ sliceId: string;
27
+ culled: CulledSpecimen[];
28
+ }
29
+
30
+ /** Render the pressure log as a markdown doc body for 50-pressure/slice-NN/. */
31
+ export function renderPressureLog(log: PressureLog): string {
32
+ const parts: string[] = [`# Pressure log — ${log.sliceId}\n`];
33
+ for (const c of log.culled) {
34
+ parts.push(`## specimen-${c.specimen}`);
35
+ parts.push(`- **culled because:** ${c.reason}`);
36
+ if (c.hackFindings.length > 0) {
37
+ parts.push(
38
+ `- **hack findings:** ${c.hackFindings
39
+ .map((f) => `${f.pattern} @ ${f.location}`)
40
+ .join("; ")}`,
41
+ );
42
+ }
43
+ if (c.critique) parts.push(`- **judge critique:** ${c.critique}`);
44
+ parts.push("\n```diff\n" + c.diff.trim() + "\n```\n");
45
+ }
46
+ return parts.join("\n");
47
+ }
48
+
49
+ /**
50
+ * Build the PDR-style refinement context (F9): the top-K most informative
51
+ * surviving summaries to seed the next round of specimens. We rank culled
52
+ * specimens by |GRPO advantage| (most informative first) and take K.
53
+ */
54
+ export function refinementContext(
55
+ log: PressureLog,
56
+ advantages: Advantage[],
57
+ k = PDR_K,
58
+ ): string {
59
+ const order = mostInformative(advantages);
60
+ const byName = new Map(log.culled.map((c) => [c.specimen, c]));
61
+ const picked: CulledSpecimen[] = [];
62
+ for (const s of order) {
63
+ const c = byName.get(s);
64
+ if (c) picked.push(c);
65
+ if (picked.length >= k) break;
66
+ }
67
+ // If fewer advantages than culled (e.g. all eliminated pre-judge), top up.
68
+ for (const c of log.culled) {
69
+ if (picked.length >= k) break;
70
+ if (!picked.includes(c)) picked.push(c);
71
+ }
72
+ return [
73
+ "# Refinement context (PDR top-K negative exemplars)",
74
+ ...picked.map(
75
+ (c, i) =>
76
+ `## ${i + 1}. specimen-${c.specimen} — avoid this failure mode\n${c.reason}\n${
77
+ c.hackFindings.map((f) => `- avoid: ${f.remediation}`).join("\n") || ""
78
+ }`,
79
+ ),
80
+ ].join("\n\n");
81
+ }
package/src/project.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Project-level driver state + DAG ordering (the multi-slice layer).
3
+ *
4
+ * STZ runs many slices through a dependency DAG. This module is the
5
+ * deterministic spine for that: the project manifest (declarative slice DAG),
6
+ * the project state (mutable phase + slice rollup), topological ordering, and
7
+ * the "next runnable slice" computation. It mirrors `state.ts` conventions.
8
+ *
9
+ * The authority rule (no drift): per-slice status is DERIVED from each slice's
10
+ * own `40-slices/<id>/state.json` via the existing per-slice helpers, never
11
+ * trusted from a project-level copy. So `project-status` writing nothing and
12
+ * re-deriving on every call IS the resume primitive.
13
+ */
14
+ import { writeFile, readFile, mkdir } from "node:fs/promises";
15
+ import { existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import {
18
+ PROJECT_PHASES,
19
+ STZ_ROLES,
20
+ type ProjectPhase,
21
+ type ProjectPhaseStatus,
22
+ type ProjectState,
23
+ type ProjectSliceEntry,
24
+ type SliceRunStatus,
25
+ type RunConfig,
26
+ type SlicingGranularity,
27
+ type MutationPolicy,
28
+ type ConventionStrictness,
29
+ type StzRole,
30
+ } from "./types.js";
31
+ import { STZ_DIR } from "./taxonomy.js";
32
+ import { loadState, stateExists, isComplete } from "./state.js";
33
+ import type { SliceState } from "./types.js";
34
+
35
+ export function projectManifestPath(root: string): string {
36
+ return join(root, STZ_DIR, "00-intent", "project.json");
37
+ }
38
+
39
+ export function projectStatePath(root: string): string {
40
+ return join(root, STZ_DIR, "90-audit", "project-state.json");
41
+ }
42
+
43
+ /** Project phase → the `.stz/` tier its artifacts live under. */
44
+ export const PROJECT_PHASE_TIER: Record<ProjectPhase, string> = {
45
+ elicitation: "00-intent",
46
+ research: "10-research",
47
+ "ground-truth": "10-research/internal",
48
+ standards: "20-standards",
49
+ "testing-conventions": "30-tests",
50
+ "slice-disaggregation": "40-slices",
51
+ };
52
+
53
+ export function freshProjectState(projectId: string): ProjectState {
54
+ const phaseStatus = Object.fromEntries(
55
+ PROJECT_PHASES.map((p) => [p, "pending" as ProjectPhaseStatus]),
56
+ ) as Record<ProjectPhase, ProjectPhaseStatus>;
57
+ return {
58
+ schemaVersion: 1,
59
+ projectId,
60
+ phaseStatus,
61
+ sliceStatus: {},
62
+ events: [],
63
+ };
64
+ }
65
+
66
+ export function appendProjectEvent(
67
+ state: ProjectState,
68
+ phase: ProjectPhase | "lifecycle" | "slice",
69
+ kind: string,
70
+ detail: string,
71
+ ): ProjectState {
72
+ state.events.push({ seq: state.events.length, phase, kind, detail });
73
+ return state;
74
+ }
75
+
76
+ export async function saveProjectState(root: string, state: ProjectState): Promise<void> {
77
+ const p = projectStatePath(root);
78
+ await mkdir(join(p, ".."), { recursive: true });
79
+ await writeFile(p, JSON.stringify(state, null, 2) + "\n", "utf8");
80
+ }
81
+
82
+ export async function loadProjectState(root: string): Promise<ProjectState> {
83
+ return JSON.parse(await readFile(projectStatePath(root), "utf8")) as ProjectState;
84
+ }
85
+
86
+ export function projectStateExists(root: string): boolean {
87
+ return existsSync(projectStatePath(root));
88
+ }
89
+
90
+ // ── topological ordering ────────────────────────────────────────────────────
91
+
92
+ export type TopoResult =
93
+ | { ok: true; order: string[] }
94
+ | { ok: false; error: "cycle"; cycle: string[] }
95
+ | { ok: false; error: "dangling"; from: string; missing: string };
96
+
97
+ /**
98
+ * Kahn's algorithm over the slice DAG. The ready frontier is sorted ascending
99
+ * by id at every step so the order is fully deterministic (N6). Detects
100
+ * dangling dependencies (a depends-on id not in the set) and cycles.
101
+ */
102
+ export function topoOrder(slices: ProjectSliceEntry[]): TopoResult {
103
+ const ids = new Set(slices.map((s) => s.id));
104
+ for (const s of slices) {
105
+ for (const dep of s.dependsOn) {
106
+ if (!ids.has(dep)) return { ok: false, error: "dangling", from: s.id, missing: dep };
107
+ }
108
+ }
109
+ const indegree = new Map<string, number>();
110
+ const dependents = new Map<string, string[]>();
111
+ for (const s of slices) {
112
+ indegree.set(s.id, s.dependsOn.length);
113
+ for (const dep of s.dependsOn) {
114
+ const arr = dependents.get(dep) ?? [];
115
+ arr.push(s.id);
116
+ dependents.set(dep, arr);
117
+ }
118
+ }
119
+ const order: string[] = [];
120
+ let ready = [...indegree.entries()].filter(([, d]) => d === 0).map(([id]) => id).sort();
121
+ while (ready.length > 0) {
122
+ const id = ready.shift()!;
123
+ order.push(id);
124
+ for (const dependent of dependents.get(id) ?? []) {
125
+ const d = (indegree.get(dependent) ?? 0) - 1;
126
+ indegree.set(dependent, d);
127
+ if (d === 0) {
128
+ ready.push(dependent);
129
+ ready.sort();
130
+ }
131
+ }
132
+ }
133
+ if (order.length < slices.length) {
134
+ const cycle = slices.map((s) => s.id).filter((id) => !order.includes(id));
135
+ return { ok: false, error: "cycle", cycle };
136
+ }
137
+ return { ok: true, order };
138
+ }
139
+
140
+ // ── status derivation (the no-drift rule) ───────────────────────────────────
141
+
142
+ /** Derive a slice's rollup status from its own per-slice state.json. */
143
+ export async function deriveSliceStatus(root: string, sliceId: string): Promise<SliceRunStatus> {
144
+ if (!stateExists(root, sliceId)) return "pending";
145
+ let state: SliceState;
146
+ try {
147
+ state = await loadState(root, sliceId);
148
+ } catch {
149
+ return "pending";
150
+ }
151
+ if (state.escalation === "halted") return "halted";
152
+ if (isComplete(state)) return "done";
153
+ // "running" means the tournament half is actually in progress — not merely
154
+ // that the project-level early phases were pre-seeded done. So: any phase
155
+ // explicitly "running", OR any tournament-half phase already "done".
156
+ const anyRunning = Object.values(state.phaseStatus).some((s) => s === "running");
157
+ const tournamentHalf = ["test-authoring", "planning", "tournament", "judgment"] as const;
158
+ const tournamentStarted = tournamentHalf.some((p) => state.phaseStatus[p] === "done");
159
+ if (anyRunning || tournamentStarted) return "running";
160
+ return "pending";
161
+ }
162
+
163
+ export interface NextRunnable {
164
+ order: string[];
165
+ frontier: string[];
166
+ next: string | null;
167
+ }
168
+
169
+ /**
170
+ * Compute the runnable frontier and the single deterministic next slice.
171
+ * The frontier is every slice whose dependencies are all `done` and which is
172
+ * not itself `done`/`halted`/`running`. `next` is the id-sorted first of the
173
+ * frontier (a single pick). Returns empty/null on a non-ok topo order.
174
+ */
175
+ export async function nextRunnable(
176
+ slices: ProjectSliceEntry[],
177
+ statusOf: (id: string) => Promise<SliceRunStatus>,
178
+ ): Promise<NextRunnable & { topo: TopoResult }> {
179
+ const topo = topoOrder(slices);
180
+ if (!topo.ok) return { order: [], frontier: [], next: null, topo };
181
+ const status = new Map<string, SliceRunStatus>();
182
+ for (const id of topo.order) status.set(id, await statusOf(id));
183
+ const byId = new Map(slices.map((s) => [s.id, s]));
184
+ const frontier = topo.order.filter((id) => {
185
+ const st = status.get(id);
186
+ if (st === "done" || st === "halted" || st === "running") return false;
187
+ const deps = byId.get(id)!.dependsOn;
188
+ return deps.every((d) => status.get(d) === "done");
189
+ });
190
+ return { order: topo.order, frontier, next: frontier[0] ?? null, topo };
191
+ }
192
+
193
+ // ── run configuration (0.3.0) ───────────────────────────────────────────────
194
+
195
+ export function runConfigPath(root: string): string {
196
+ return join(root, STZ_DIR, "00-intent", "run-config.json");
197
+ }
198
+
199
+ export function runConfigExists(root: string): boolean {
200
+ return existsSync(runConfigPath(root));
201
+ }
202
+
203
+ /**
204
+ * Default run config — a balanced starting point used when the user never set
205
+ * one (every downstream consumer falls back to this, so the pipeline always has
206
+ * a complete config). Model values are spawn aliases so they drop straight into
207
+ * an Agent `model` override: a cheap model for high-volume research, the strong
208
+ * model for judging where quality matters most.
209
+ */
210
+ export const DEFAULT_MODELS: Record<StzRole, string> = {
211
+ planning: "sonnet",
212
+ research: "haiku",
213
+ execution: "sonnet",
214
+ testing: "sonnet",
215
+ validation: "sonnet",
216
+ judging: "opus",
217
+ };
218
+
219
+ export function defaultRunConfig(): RunConfig {
220
+ return {
221
+ schemaVersion: 1,
222
+ granularity: "balanced",
223
+ fanout: 4,
224
+ models: { ...DEFAULT_MODELS },
225
+ strictness: {
226
+ coverageTarget: 0.9,
227
+ mutationPolicy: "standard",
228
+ conventions: "standard",
229
+ },
230
+ // Human-in-the-loop by default — a fully autonomous run is opt-in (0.4.0).
231
+ darkFactory: false,
232
+ };
233
+ }
234
+
235
+ const GRANULARITIES: readonly SlicingGranularity[] = ["coarse", "balanced", "fine"];
236
+ const MUTATION_POLICIES: readonly MutationPolicy[] = ["off", "lenient", "standard", "strict"];
237
+ const CONVENTION_STRICTNESS: readonly ConventionStrictness[] = ["relaxed", "standard", "strict"];
238
+
239
+ /** Lower bound on specimen fan-out — a tournament needs at least a pair. */
240
+ export const FANOUT_MIN = 2;
241
+ /** Upper bound on specimen fan-out — the published RTV+PDR optimum for the
242
+ * cloud/CI profile (N3/F6). Workstation runs typically use far fewer. */
243
+ export const FANOUT_MAX = 16;
244
+
245
+ /**
246
+ * Merge a partial config over the defaults and validate. Enum fields are
247
+ * rejected if present-but-invalid (a typo must not silently fall back); fanout
248
+ * is clamped to [FANOUT_MIN, FANOUT_MAX] and coverageTarget to [0, 1]. Model
249
+ * values stay free-form (the get-shit-done "Other" pattern) — any string passes.
250
+ */
251
+ export function normalizeRunConfig(partial: Partial<RunConfig> | undefined): RunConfig {
252
+ const base = defaultRunConfig();
253
+ const p = partial ?? {};
254
+
255
+ if (p.granularity !== undefined && !GRANULARITIES.includes(p.granularity)) {
256
+ throw new Error(`invalid granularity: ${p.granularity} (expected ${GRANULARITIES.join("|")})`);
257
+ }
258
+ const granularity = p.granularity ?? base.granularity;
259
+
260
+ let fanout = base.fanout;
261
+ if (p.fanout !== undefined) {
262
+ const n = Math.round(Number(p.fanout));
263
+ if (!Number.isFinite(n)) throw new Error(`invalid fanout: ${p.fanout}`);
264
+ fanout = Math.max(FANOUT_MIN, Math.min(FANOUT_MAX, n));
265
+ }
266
+
267
+ const models: Record<StzRole, string> = { ...base.models };
268
+ if (p.models) {
269
+ for (const role of STZ_ROLES) {
270
+ const v = p.models[role];
271
+ if (v !== undefined && String(v).trim() !== "") models[role] = String(v).trim();
272
+ }
273
+ }
274
+
275
+ const s: Partial<RunConfig["strictness"]> = p.strictness ?? {};
276
+ if (s.mutationPolicy !== undefined && !MUTATION_POLICIES.includes(s.mutationPolicy)) {
277
+ throw new Error(`invalid mutationPolicy: ${s.mutationPolicy} (expected ${MUTATION_POLICIES.join("|")})`);
278
+ }
279
+ if (s.conventions !== undefined && !CONVENTION_STRICTNESS.includes(s.conventions)) {
280
+ throw new Error(`invalid conventions strictness: ${s.conventions} (expected ${CONVENTION_STRICTNESS.join("|")})`);
281
+ }
282
+ let coverageTarget = base.strictness.coverageTarget;
283
+ if (s.coverageTarget !== undefined) {
284
+ const c = Number(s.coverageTarget);
285
+ if (!Number.isFinite(c)) throw new Error(`invalid coverageTarget: ${s.coverageTarget}`);
286
+ coverageTarget = Math.max(0, Math.min(1, c));
287
+ }
288
+
289
+ // darkFactory is a plain boolean flag; accept the JSON literal or a stringy
290
+ // "true"/"false" (it can arrive from a CLI arg), default to the base value.
291
+ let darkFactory = base.darkFactory;
292
+ if (p.darkFactory !== undefined) {
293
+ darkFactory = p.darkFactory === true || String(p.darkFactory).trim().toLowerCase() === "true";
294
+ }
295
+
296
+ return {
297
+ schemaVersion: 1,
298
+ granularity,
299
+ fanout,
300
+ models,
301
+ strictness: {
302
+ coverageTarget,
303
+ mutationPolicy: s.mutationPolicy ?? base.strictness.mutationPolicy,
304
+ conventions: s.conventions ?? base.strictness.conventions,
305
+ },
306
+ darkFactory,
307
+ };
308
+ }
309
+
310
+ export async function saveRunConfig(root: string, config: RunConfig): Promise<void> {
311
+ const p = runConfigPath(root);
312
+ await mkdir(join(p, ".."), { recursive: true });
313
+ await writeFile(p, JSON.stringify(config, null, 2) + "\n", "utf8");
314
+ }
315
+
316
+ /** Load the persisted run config, or the default if none was ever set. */
317
+ export async function loadRunConfig(root: string): Promise<RunConfig> {
318
+ if (!runConfigExists(root)) return defaultRunConfig();
319
+ const raw = JSON.parse(await readFile(runConfigPath(root), "utf8")) as Partial<RunConfig>;
320
+ return normalizeRunConfig(raw);
321
+ }
322
+
323
+ /**
324
+ * Flip dark-factory mode in place (0.4.0) without disturbing any other field.
325
+ * This is a LOAD-MODIFY-SAVE on the existing config — deliberately NOT routed
326
+ * through `normalizeRunConfig(partial)`, which merges over the *defaults* and
327
+ * would silently reset fanout/models/strictness mid-run. Returns the resolved
328
+ * config so the caller can echo it back.
329
+ */
330
+ export async function setDarkFactory(root: string, enabled: boolean): Promise<RunConfig> {
331
+ const current = await loadRunConfig(root);
332
+ const next: RunConfig = { ...current, darkFactory: enabled };
333
+ await saveRunConfig(root, next);
334
+ return next;
335
+ }
package/src/seal.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Sealed held-out suite integrity (L1/F10).
3
+ *
4
+ * The held-out suite (and the test-author's reference implementation that proves
5
+ * it is satisfiable) is frozen BEFORE the tournament and must not change while
6
+ * specimens compete — otherwise the grader could be tuned to favour one. This
7
+ * module is the deterministic record of that freeze:
8
+ *
9
+ * - `seal` — hash every file under `30-tests/held-out/` into SEAL.json.
10
+ * - `verifySeal` — re-hash and report any drift (the gate before judging).
11
+ * - `amendSeal` — the ONLY sanctioned way to change a sealed file: records
12
+ * per-file from→to hashes + a reason into the manifest.
13
+ *
14
+ * The manifest is timestamp-free (N6 replayability); amendment append-order is
15
+ * the audit sequence. SEAL.json lives inside the directory it hashes and is
16
+ * excluded from its own manifest. File keys are POSIX-relative and sorted, so
17
+ * the manifest is byte-stable across runs and machines.
18
+ *
19
+ * Language-specific compile/run (the smoke gate that proves the suite is green
20
+ * against the reference before sealing) is the orchestrator's job, not this
21
+ * module's — the bridge owns only the deterministic hashing/verify/amend.
22
+ */
23
+ import { createHash } from "node:crypto";
24
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
25
+ import { writeFile } from "node:fs/promises";
26
+ import { join, relative, sep } from "node:path";
27
+ import { stzPath } from "./taxonomy.js";
28
+
29
+ const HELD_OUT_REL = join("30-tests", "held-out");
30
+ export const SEAL_NAME = "SEAL.json";
31
+
32
+ export interface SealAmendment {
33
+ reason: string;
34
+ changed: { file: string; from: string | null; to: string | null }[];
35
+ }
36
+
37
+ export interface SealManifest {
38
+ schemaVersion: 1;
39
+ /** POSIX-relative path → sha256 of the file's bytes, for every held-out file. */
40
+ files: Record<string, string>;
41
+ /** Audit log of sanctioned post-freeze changes, in application order. */
42
+ amendments: SealAmendment[];
43
+ }
44
+
45
+ export function heldOutDir(root: string): string {
46
+ return stzPath(root, HELD_OUT_REL);
47
+ }
48
+ export function sealPath(root: string): string {
49
+ return join(heldOutDir(root), SEAL_NAME);
50
+ }
51
+
52
+ const toPosix = (p: string): string => p.split(sep).join("/");
53
+ const fromPosix = (p: string): string => p.split("/").join(sep);
54
+ const sha256 = (buf: Buffer): string => createHash("sha256").update(buf).digest("hex");
55
+
56
+ /** Every file under held-out (recursive), POSIX-relative, sorted, sans SEAL.json. */
57
+ export function heldOutFiles(root: string): string[] {
58
+ const base = heldOutDir(root);
59
+ if (!existsSync(base)) return [];
60
+ const out: string[] = [];
61
+ const walk = (dir: string) => {
62
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
63
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
64
+ );
65
+ for (const ent of entries) {
66
+ const abs = join(dir, ent.name);
67
+ if (ent.isDirectory()) walk(abs);
68
+ else {
69
+ const rel = toPosix(relative(base, abs));
70
+ if (rel !== SEAL_NAME) out.push(rel);
71
+ }
72
+ }
73
+ };
74
+ walk(base);
75
+ return out.sort();
76
+ }
77
+
78
+ /** Current on-disk hashes, keyed by POSIX-relative path (sorted insertion). */
79
+ export function computeHashes(root: string): Record<string, string> {
80
+ const base = heldOutDir(root);
81
+ const files: Record<string, string> = {};
82
+ for (const rel of heldOutFiles(root)) {
83
+ files[rel] = sha256(readFileSync(join(base, fromPosix(rel))));
84
+ }
85
+ return files;
86
+ }
87
+
88
+ export function readSeal(root: string): SealManifest | null {
89
+ if (!existsSync(sealPath(root))) return null;
90
+ return JSON.parse(readFileSync(sealPath(root), "utf8")) as SealManifest;
91
+ }
92
+
93
+ export async function writeSeal(root: string, manifest: SealManifest): Promise<void> {
94
+ await writeFile(sealPath(root), JSON.stringify(manifest, null, 2) + "\n", "utf8");
95
+ }
96
+
97
+ export type SealResult = {
98
+ sealed: boolean;
99
+ added: string[];
100
+ /** Already-sealed files whose bytes changed — NOT re-blessed; use amend. */
101
+ drifted: string[];
102
+ removed: string[];
103
+ total: number;
104
+ };
105
+
106
+ /**
107
+ * Freeze the held-out suite. On first run, hashes everything. On a later run
108
+ * (e.g. a new slice's suite was added) it ADDS the new files' hashes but
109
+ * refuses to silently re-bless an already-sealed file whose bytes changed —
110
+ * that is `amendSeal`'s job. Returns `sealed:false` when such drift blocks it.
111
+ */
112
+ export async function seal(root: string): Promise<SealResult> {
113
+ const current = computeHashes(root);
114
+ const prior = readSeal(root);
115
+ if (!prior) {
116
+ await writeSeal(root, { schemaVersion: 1, files: current, amendments: [] });
117
+ return { sealed: true, added: Object.keys(current), drifted: [], removed: [], total: Object.keys(current).length };
118
+ }
119
+ const added: string[] = [];
120
+ const drifted: string[] = [];
121
+ for (const [f, h] of Object.entries(current)) {
122
+ if (!(f in prior.files)) added.push(f);
123
+ else if (prior.files[f] !== h) drifted.push(f);
124
+ }
125
+ const removed = Object.keys(prior.files).filter((f) => !(f in current));
126
+ if (drifted.length || removed.length) {
127
+ // A sealed file changed/vanished — refuse to launder it through `seal`.
128
+ return { sealed: false, added, drifted, removed, total: Object.keys(prior.files).length };
129
+ }
130
+ const files = { ...prior.files };
131
+ for (const f of added) files[f] = current[f]!;
132
+ await writeSeal(root, { schemaVersion: 1, files, amendments: prior.amendments });
133
+ return { sealed: true, added, drifted: [], removed: [], total: Object.keys(files).length };
134
+ }
135
+
136
+ export type DriftEntry = { file: string; status: "modified" | "added" | "removed" };
137
+ export type VerifyResult = { sealed: boolean; ok: boolean; drift: DriftEntry[] };
138
+
139
+ /** Re-hash the held-out dir against SEAL.json. `ok:false` on any drift. */
140
+ export function verifySeal(root: string): VerifyResult {
141
+ const prior = readSeal(root);
142
+ if (!prior) return { sealed: false, ok: false, drift: [] };
143
+ const current = computeHashes(root);
144
+ const drift: DriftEntry[] = [];
145
+ for (const [f, h] of Object.entries(current)) {
146
+ if (!(f in prior.files)) drift.push({ file: f, status: "added" });
147
+ else if (prior.files[f] !== h) drift.push({ file: f, status: "modified" });
148
+ }
149
+ for (const f of Object.keys(prior.files)) {
150
+ if (!(f in current)) drift.push({ file: f, status: "removed" });
151
+ }
152
+ drift.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
153
+ return { sealed: true, ok: drift.length === 0, drift };
154
+ }
155
+
156
+ /**
157
+ * The sanctioned post-freeze change: re-hash, record per-file from→to (null on
158
+ * add/remove) plus the reason into the manifest's amendment log, and re-freeze
159
+ * to the new hashes. After this, `verifySeal` passes again — so a silent edit
160
+ * (one that skipped amend) is exactly what `verifySeal` is left to catch.
161
+ */
162
+ export async function amendSeal(root: string, reason: string): Promise<{ amended: boolean; changed: SealAmendment["changed"] }> {
163
+ const prior = readSeal(root);
164
+ if (!prior) return { amended: false, changed: [] };
165
+ const current = computeHashes(root);
166
+ const changed: SealAmendment["changed"] = [];
167
+ for (const [f, h] of Object.entries(current)) {
168
+ const from = prior.files[f] ?? null;
169
+ if (from !== h) changed.push({ file: f, from, to: h });
170
+ }
171
+ for (const f of Object.keys(prior.files)) {
172
+ if (!(f in current)) changed.push({ file: f, from: prior.files[f]!, to: null });
173
+ }
174
+ if (changed.length === 0) return { amended: false, changed: [] };
175
+ changed.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
176
+ await writeSeal(root, {
177
+ schemaVersion: 1,
178
+ files: current,
179
+ amendments: [...prior.amendments, { reason, changed }],
180
+ });
181
+ return { amended: true, changed };
182
+ }