@junghanacs/entwurf 0.31.0 → 0.32.0

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 (56) hide show
  1. package/AGENTS.md +3 -2
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +46 -36
  4. package/VERIFY.md +35 -16
  5. package/demo/README.md +1 -1
  6. package/docs/acp-backend-rail.md +1 -1
  7. package/docs/setup-clean-host.md +112 -95
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +1 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +8 -10
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +3 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +153 -205
  13. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +6 -4
  14. package/mcp/entwurf-bridge/src/index.ts +1 -1
  15. package/package.json +14 -17
  16. package/pi/pi-durable/bootstrap.mjs +142 -19
  17. package/pi/pi-durable/carrier/LICENSE +21 -0
  18. package/pi/pi-durable/carrier/harness-setup.js +123 -0
  19. package/pi/pi-durable/carrier/prompt.js +74 -0
  20. package/pi/pi-durable/carrier/runtime.js +402 -0
  21. package/pi/pi-durable/carrier/sessions.js +56 -0
  22. package/pi/pi-durable/carrier/subagent.js +55 -0
  23. package/pi/pi-durable/carrier/tui.js +655 -0
  24. package/pi/pi-durable/carrier-relocation.mjs +160 -0
  25. package/pi/pi-durable/carrier-resolver.mjs +38 -0
  26. package/pi/pi-durable/overlay/upstream-pin.json +103 -11
  27. package/pi-extensions/lib/fresh-call-composition.ts +1 -1
  28. package/pi-extensions/lib/mux-fresh-call.ts +8 -10
  29. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +3 -3
  30. package/pi-extensions/lib/pi-durable-runtime.ts +197 -263
  31. package/pi-extensions/meta-bridge-pi-durable.ts +11 -7
  32. package/run.sh +508 -235
  33. package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
  34. package/scripts/check-fresh-call-dispatch.ts +1 -1
  35. package/scripts/check-gate-qualification.ts +86 -34
  36. package/scripts/check-herdr-activation.ts +5 -4
  37. package/scripts/check-pi-durable-contact.ts +26 -93
  38. package/scripts/check-pi-durable-receive.ts +22 -59
  39. package/scripts/check-pi-durable-send.ts +235 -75
  40. package/scripts/emit-pi-durable-carrier.ts +355 -0
  41. package/scripts/lib/mutation-qualify.ts +21 -45
  42. package/scripts/lib/pi-durable-operator-guard.ts +89 -0
  43. package/scripts/lib/qualification-receipt.ts +7 -11
  44. package/scripts/mutants/control-send-receipt.json +1 -1
  45. package/scripts/mutants/gate-qualification.json +13 -0
  46. package/scripts/mutants/mux-fresh-call.json +13 -13
  47. package/scripts/mutants/pack-install.json +3 -3
  48. package/scripts/mutants/pi-durable.json +442 -109
  49. package/scripts/mutants/pi-floor.json +24 -2
  50. package/scripts/pi-durable-contact-host.mjs +3 -3
  51. package/scripts/pi-durable-receive-host.mjs +3 -3
  52. package/scripts/pi-durable-send-host.mjs +76 -5
  53. package/scripts/smoke-herdr-raw-install-live.sh +1 -1
  54. package/scripts/smoke-pi-durable-fresh-live.ts +3 -3
  55. package/scripts/smoke-setup-verdict.sh +15 -15
  56. package/scripts/smoke-user-scope-citizen.sh +1 -1
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * emit-pi-durable-carrier — the MAINTAINER's one path from upstream source to the pi-durable
4
+ * carrier (#130). An operator never runs it: an installed package carries the emitted JS and reads
5
+ * no upstream source.
6
+ *
7
+ * node scripts/emit-pi-durable-carrier.ts --source <git repository holding the pinned commit> --write
8
+ * node scripts/emit-pi-durable-carrier.ts --source <git repository holding the pinned commit> --check
9
+ *
10
+ * Inputs, and nothing else: the pinned commit's OBJECTS (each declared file and the license, read as
11
+ * `<commit>:<path>` — never a worktree, so the repository's checkout state cannot enter), the packaged
12
+ * overlay patch, and this Node's `stripTypeScriptTypes` in strip mode (upstream runs these exact
13
+ * sources under Node's strip-only TypeScript). The patch is applied by git's own engine to a private
14
+ * scratch copy; the base blob and the patched result must carry the blob ids the patch's `index` line
15
+ * names. Each stripped file is then relocated by pi/pi-durable/carrier-relocation.mjs, and the targets
16
+ * the six files import must equal the pin's declared `distTargets` exactly.
17
+ *
18
+ * `--write` emits pi/pi-durable/carrier/{<files>,LICENSE} and fills the pin's generated fields
19
+ * (source blob ids, emitted sha256s, the Node version). `--check` re-emits in memory and compares:
20
+ * equal is the rebuild acceptance; any difference, another Node version, or an undeclared file in the
21
+ * carrier directory is a named FAIL. `--check` without a source holding the commit is a named SKIP —
22
+ * an unavailable observation, never rebuild acceptance. Read-only against the source repository.
23
+ *
24
+ * Exit: 0 written / equal, 1 named refusal or drift, 2 usage, 97 skip (no source).
25
+ */
26
+
27
+ import { spawnSync } from "node:child_process";
28
+ import { createHash } from "node:crypto";
29
+ import * as fs from "node:fs";
30
+ import { stripTypeScriptTypes } from "node:module";
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+ import { relocateCarrierSource } from "../pi/pi-durable/carrier-relocation.mjs";
34
+ import { skipLive } from "./lib/live-skip.ts";
35
+
36
+ const ROOT = path.resolve(import.meta.dirname, "..");
37
+ const DURABLE = path.join(ROOT, "pi", "pi-durable");
38
+ const PIN_PATH = path.join(DURABLE, "overlay", "upstream-pin.json");
39
+ const CARRIER_DIR = path.join(DURABLE, "carrier");
40
+ const LICENSE_NAME = "LICENSE";
41
+ const EMITTER_TOOL = "node:module.stripTypeScriptTypes";
42
+ const EMITTER_MODE = "strip";
43
+ const GIT_TIMEOUT_MS = 30_000;
44
+
45
+ interface CarrierFile {
46
+ name: string;
47
+ source: string;
48
+ patched: boolean;
49
+ blob: string;
50
+ sha256: string;
51
+ }
52
+
53
+ interface CarrierPin {
54
+ schemaVersion: number;
55
+ commit: string;
56
+ patches: string[];
57
+ carrier: {
58
+ emitter: { tool: string; mode: string; node: string };
59
+ license: { source: string; blob: string; sha256: string };
60
+ files: CarrierFile[];
61
+ distTargets: string[];
62
+ };
63
+ }
64
+
65
+ class Refusal extends Error {}
66
+ const refuse = (reason: string, detail: string): Refusal => new Refusal(`${reason}: ${detail}`);
67
+
68
+ const sha256 = (data: Buffer): string => createHash("sha256").update(data).digest("hex");
69
+ /** git's blob id, computed here so no object is ever written into the source repository. */
70
+ const blobId = (data: Buffer): string =>
71
+ createHash("sha1")
72
+ .update(Buffer.concat([Buffer.from(`blob ${data.length}\0`), data]))
73
+ .digest("hex");
74
+
75
+ /** The environment every git child gets: no inherited repository, index or work-tree redirection. */
76
+ function gitEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
77
+ const env: NodeJS.ProcessEnv = { GIT_OPTIONAL_LOCKS: "0", ...extra };
78
+ for (const [key, value] of Object.entries(process.env)) {
79
+ if (!key.startsWith("GIT_") && env[key] === undefined) env[key] = value;
80
+ }
81
+ return env;
82
+ }
83
+
84
+ /** Every read of the source repository: `--no-replace-objects`, so a `refs/replace/*` entry (or a
85
+ * replacement reached through alternates) can never stand in for a pinned object. */
86
+ export function sourceGitArgs(source: string, args: readonly string[]): string[] {
87
+ return ["--no-replace-objects", "-C", source, ...args];
88
+ }
89
+
90
+ /** One pinned object's bytes, `<commit>:<path>`, as the source repository stores it. */
91
+ export function readSourceObject(source: string, rev: string): Buffer {
92
+ return git(source, ["cat-file", "blob", rev]);
93
+ }
94
+
95
+ function git(source: string, args: string[]): Buffer {
96
+ const run = spawnSync("git", sourceGitArgs(source, args), {
97
+ env: gitEnv(),
98
+ maxBuffer: 64 * 1024 * 1024,
99
+ timeout: GIT_TIMEOUT_MS,
100
+ });
101
+ if (run.status !== 0) {
102
+ throw refuse(
103
+ "carrier-source-unreadable",
104
+ `git ${args.join(" ")} in ${source}: ${run.stderr?.toString().trim() || run.error?.message || `status ${run.status}`}`,
105
+ );
106
+ }
107
+ return run.stdout;
108
+ }
109
+
110
+ function readPin(): CarrierPin {
111
+ const pin = JSON.parse(fs.readFileSync(PIN_PATH, "utf8")) as CarrierPin;
112
+ if (pin.schemaVersion !== 2 || !/^[0-9a-f]{40}$/.test(pin.commit) || pin.patches?.length !== 1) {
113
+ throw refuse("carrier-pin-malformed", `${PIN_PATH}: schemaVersion 2, a full commit and one patch are required`);
114
+ }
115
+ if (pin.carrier.emitter.tool !== EMITTER_TOOL || pin.carrier.emitter.mode !== EMITTER_MODE) {
116
+ throw refuse(
117
+ "carrier-pin-malformed",
118
+ `the emitter is ${EMITTER_TOOL} in ${EMITTER_MODE} mode, not ${JSON.stringify(pin.carrier.emitter)}`,
119
+ );
120
+ }
121
+ if (pin.carrier.files.filter((f) => f.patched).length !== 1) {
122
+ throw refuse("carrier-pin-malformed", "exactly one carrier file carries the overlay patch");
123
+ }
124
+ return pin;
125
+ }
126
+
127
+ /** The overlay patch's one file and the two blob ids its `index` line names. */
128
+ function readPatch(pin: CarrierPin): { bytes: Buffer; file: string; base: string; result: string } {
129
+ const bytes = fs.readFileSync(path.join(DURABLE, "overlay", pin.patches[0]));
130
+ const text = bytes.toString("utf8");
131
+ const files = [...text.matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)];
132
+ const index = [...text.matchAll(/^index ([0-9a-f]+)\.\.([0-9a-f]+) 100644$/gm)];
133
+ if (files.length !== 1 || files[0][1] !== files[0][2] || index.length !== 1) {
134
+ throw refuse("carrier-patch-shape", `${pin.patches[0]} must change exactly one existing file`);
135
+ }
136
+ return { bytes, file: files[0][1], base: index[0][1], result: index[0][2] };
137
+ }
138
+
139
+ /** `base` with the patch applied by git, in a private scratch tree outside any repository. */
140
+ function applyPatch(file: string, base: Buffer, patch: Buffer): Buffer {
141
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "pi-durable-carrier-"));
142
+ try {
143
+ const target = path.join(scratch, file);
144
+ fs.mkdirSync(path.dirname(target), { recursive: true });
145
+ fs.writeFileSync(target, base);
146
+ const patchFile = path.join(scratch, "overlay.patch");
147
+ fs.writeFileSync(patchFile, patch);
148
+ const run = spawnSync("git", ["apply", "--whitespace=nowarn", "overlay.patch"], {
149
+ cwd: scratch,
150
+ env: gitEnv({ GIT_CEILING_DIRECTORIES: path.dirname(scratch) }),
151
+ timeout: GIT_TIMEOUT_MS,
152
+ });
153
+ if (run.status !== 0) {
154
+ throw refuse("carrier-patch-rejected", run.stderr?.toString().trim() || `git apply status ${run.status}`);
155
+ }
156
+ return fs.readFileSync(target);
157
+ } finally {
158
+ fs.rmSync(scratch, { recursive: true, force: true });
159
+ }
160
+ }
161
+
162
+ interface Emission {
163
+ files: Map<string, Buffer>;
164
+ generated: {
165
+ node: string;
166
+ license: { blob: string; sha256: string };
167
+ files: Map<string, { blob: string; sha256: string }>;
168
+ };
169
+ }
170
+
171
+ function emit(source: string, pin: CarrierPin): Emission {
172
+ if (git(source, ["cat-file", "-t", pin.commit]).toString().trim() !== "commit") {
173
+ throw refuse("carrier-source-unreadable", `${pin.commit} is not a commit in ${source}`);
174
+ }
175
+ const patch = readPatch(pin);
176
+ const names = pin.carrier.files.map((f) => f.name);
177
+ const targets = pin.carrier.distTargets;
178
+ if (new Set(targets).size !== targets.length) throw refuse("carrier-pin-malformed", "distTargets repeats a path");
179
+ const files = new Map<string, Buffer>();
180
+ const generatedFiles = new Map<string, { blob: string; sha256: string }>();
181
+ const used = new Set<string>();
182
+ for (const declared of pin.carrier.files) {
183
+ const original = readSourceObject(source, `${pin.commit}:${declared.source}`);
184
+ const blob = blobId(original);
185
+ let text = original;
186
+ if (declared.patched) {
187
+ if (declared.source !== patch.file || !blob.startsWith(patch.base)) {
188
+ throw refuse(
189
+ "carrier-patch-base",
190
+ `the patch changes ${patch.file} from ${patch.base}; ${declared.source} is ${blob}`,
191
+ );
192
+ }
193
+ text = applyPatch(declared.source, original, patch.bytes);
194
+ if (!blobId(text).startsWith(patch.result)) {
195
+ throw refuse(
196
+ "carrier-patch-result",
197
+ `patched ${declared.source} is ${blobId(text)}, the patch names ${patch.result}`,
198
+ );
199
+ }
200
+ }
201
+ const stripped = stripTypeScriptTypes(text.toString("utf8"), { mode: EMITTER_MODE });
202
+ const relocated = relocateCarrierSource(declared.name, stripped, { siblings: names, targets });
203
+ for (const target of relocated.used) used.add(target);
204
+ const bytes = Buffer.from(relocated.code, "utf8");
205
+ files.set(declared.name, bytes);
206
+ generatedFiles.set(declared.name, { blob, sha256: sha256(bytes) });
207
+ }
208
+ const unused = targets.filter((t) => !used.has(t));
209
+ if (unused.length > 0) throw refuse("carrier-targets-differ", `declared but not imported: ${unused.join(", ")}`);
210
+ const license = readSourceObject(source, `${pin.commit}:${pin.carrier.license.source}`);
211
+ files.set(LICENSE_NAME, license);
212
+ return {
213
+ files,
214
+ generated: {
215
+ node: process.version,
216
+ license: { blob: blobId(license), sha256: sha256(license) },
217
+ files: generatedFiles,
218
+ },
219
+ };
220
+ }
221
+
222
+ /** Files in the carrier directory that the pin does not declare. */
223
+ function undeclared(pin: CarrierPin): string[] {
224
+ const declared = new Set([...pin.carrier.files.map((f) => f.name), LICENSE_NAME]);
225
+ const present = fs.existsSync(CARRIER_DIR) ? fs.readdirSync(CARRIER_DIR) : [];
226
+ return present.filter((name) => !declared.has(name)).sort();
227
+ }
228
+
229
+ function write(pin: CarrierPin, emission: Emission): void {
230
+ const extra = undeclared(pin);
231
+ if (extra.length > 0) throw refuse("carrier-undeclared-file", `remove ${extra.join(", ")} from ${CARRIER_DIR} first`);
232
+ fs.mkdirSync(CARRIER_DIR, { recursive: true });
233
+ for (const [name, bytes] of emission.files) fs.writeFileSync(path.join(CARRIER_DIR, name), bytes);
234
+ pin.carrier.emitter.node = emission.generated.node;
235
+ pin.carrier.license.blob = emission.generated.license.blob;
236
+ pin.carrier.license.sha256 = emission.generated.license.sha256;
237
+ for (const file of pin.carrier.files) Object.assign(file, emission.generated.files.get(file.name));
238
+ fs.writeFileSync(PIN_PATH, formatPin(pin));
239
+ }
240
+
241
+ /** The pin as the repository formatter leaves it (biome, tabs, width 120): JSON.stringify, with an array
242
+ * of strings that fits on its line written inline. Values are compared field by field, never as text. */
243
+ function formatPin(pin: CarrierPin): string {
244
+ const text = JSON.stringify(pin, null, "\t").replace(
245
+ /^(\t*)("[^"\n]+": )\[\n((?:\t+"[^"\n]*",?\n)+)\t*\]/gm,
246
+ (block: string, indent: string, key: string, items: string) => {
247
+ const inline = `${key}[${items
248
+ .trim()
249
+ .split(/,?\n\t*/)
250
+ .join(", ")}]`;
251
+ return indent.length * 2 + inline.length + 1 <= 120 ? `${indent}${inline}` : block;
252
+ },
253
+ );
254
+ return `${text}\n`;
255
+ }
256
+
257
+ /** Every difference between the committed carrier + pin and a fresh emission. */
258
+ function compare(pin: CarrierPin, emission: Emission): string[] {
259
+ const drift: string[] = [];
260
+ if (pin.carrier.emitter.node !== emission.generated.node) {
261
+ drift.push(
262
+ `carrier-emitter-node-mismatch: the carrier was emitted by Node ${pin.carrier.emitter.node || "(none)"}, this is ${emission.generated.node}`,
263
+ );
264
+ }
265
+ const license = emission.generated.license;
266
+ if (pin.carrier.license.blob !== license.blob || pin.carrier.license.sha256 !== license.sha256) {
267
+ drift.push(`carrier-pin-drift: license is ${license.blob}/${license.sha256}`);
268
+ }
269
+ for (const file of pin.carrier.files) {
270
+ const want = emission.generated.files.get(file.name);
271
+ if (file.blob !== want?.blob || file.sha256 !== want?.sha256) {
272
+ drift.push(
273
+ `carrier-pin-drift: ${file.name} pin records ${file.blob}/${file.sha256}, emission is ${want?.blob}/${want?.sha256}`,
274
+ );
275
+ }
276
+ }
277
+ for (const [name, bytes] of emission.files) {
278
+ const onDisk = path.join(CARRIER_DIR, name);
279
+ if (!fs.existsSync(onDisk)) drift.push(`carrier-file-missing: ${name}`);
280
+ else if (!fs.readFileSync(onDisk).equals(bytes))
281
+ drift.push(`carrier-file-drift: ${name} differs from its emission`);
282
+ }
283
+ for (const name of undeclared(pin)) drift.push(`carrier-undeclared-file: ${name}`);
284
+ return drift;
285
+ }
286
+
287
+ function main(argv: readonly string[]): number {
288
+ const sourceAt = argv.indexOf("--source");
289
+ const source = sourceAt === -1 ? undefined : argv[sourceAt + 1];
290
+ const mode = argv.includes("--write") ? "write" : argv.includes("--check") ? "check" : undefined;
291
+ if (mode === undefined || argv.length !== (source === undefined ? 1 : 3) || (sourceAt !== -1 && !source)) {
292
+ console.error(
293
+ "usage: emit-pi-durable-carrier.ts --source <git repository holding the pinned commit> (--write | --check)",
294
+ );
295
+ return 2;
296
+ }
297
+ const pin = readPin();
298
+ if (mode === "check") {
299
+ const held =
300
+ source !== undefined &&
301
+ spawnSync("git", sourceGitArgs(source, ["cat-file", "-e", `${pin.commit}^{commit}`]), {
302
+ env: gitEnv(),
303
+ timeout: GIT_TIMEOUT_MS,
304
+ }).status === 0;
305
+ if (!held) {
306
+ skipLive(
307
+ "emit-pi-durable-carrier --check",
308
+ `no --source repository holding ${pin.commit} — the rebuild was NOT checked; pass a clone of ${(pin as { repository?: string }).repository ?? "upstream"} that contains it`,
309
+ );
310
+ }
311
+ }
312
+ if (source === undefined) {
313
+ console.error("emit-pi-durable-carrier --write needs --source");
314
+ return 2;
315
+ }
316
+ const emission = emit(path.resolve(source), pin);
317
+ if (mode === "write") {
318
+ write(pin, emission);
319
+ console.log(
320
+ `[emit-pi-durable-carrier] wrote ${emission.files.size} files to ${path.relative(ROOT, CARRIER_DIR)} from ${pin.commit} (${emission.generated.node})`,
321
+ );
322
+ return 0;
323
+ }
324
+ const drift = compare(pin, emission);
325
+ if (drift.length > 0) {
326
+ for (const line of drift) console.error(`[emit-pi-durable-carrier] ${line}`);
327
+ return 1;
328
+ }
329
+ console.log(
330
+ `[emit-pi-durable-carrier] ok — the carrier is the emission of ${pin.commit} (${emission.generated.node})`,
331
+ );
332
+ return 0;
333
+ }
334
+
335
+ // Run only when this file is the process entry (argv realpath, as the shipped entries decide it);
336
+ // the beside test imports the source-read seam above without emitting anything.
337
+ const invokedDirectly = (() => {
338
+ const entry = process.argv[1];
339
+ if (typeof entry !== "string" || entry.length === 0) return false;
340
+ try {
341
+ return fs.realpathSync(entry) === fs.realpathSync(import.meta.filename);
342
+ } catch {
343
+ // Bounded environment probe: an entry that cannot be resolved is not this file.
344
+ return false;
345
+ }
346
+ })();
347
+ if (invokedDirectly) {
348
+ try {
349
+ process.exitCode = main(process.argv.slice(2));
350
+ } catch (error) {
351
+ if (!(error instanceof Refusal) && !(error instanceof Error && "reason" in error)) throw error;
352
+ console.error(`[emit-pi-durable-carrier] ${error.message}`);
353
+ process.exitCode = 1;
354
+ }
355
+ }
@@ -426,50 +426,6 @@ function gitIn(dir: string, args: string[]): string {
426
426
  return r.stdout;
427
427
  }
428
428
 
429
- /**
430
- * Sweep stale snapshot dirs left by a SIGKILLed/powered-off run. Only dirs whose
431
- * recorded runner pid is dead are removed; a dir with no runner.json is removed only
432
- * once it is old (another runner may be mid-mkdtemp). Residue is inert tmp garbage —
433
- * the real checkout was never written — so this is tidiness, not recovery.
434
- */
435
- export function sweepStaleSnapshots(tmpRoot: string): string[] {
436
- const swept: string[] = [];
437
- let entries: string[];
438
- try {
439
- entries = fs.readdirSync(tmpRoot);
440
- } catch {
441
- return swept;
442
- }
443
- for (const name of entries) {
444
- if (!name.startsWith(SNAPSHOT_PREFIX)) continue;
445
- const dir = path.join(tmpRoot, name);
446
- const marker = path.join(dir, "runner.json");
447
- let dead = false;
448
- try {
449
- const rec = JSON.parse(fs.readFileSync(marker, "utf8")) as { pid?: number };
450
- if (typeof rec.pid !== "number") dead = true;
451
- else if (rec.pid !== process.pid) {
452
- try {
453
- process.kill(rec.pid, 0);
454
- } catch {
455
- dead = true;
456
- }
457
- }
458
- } catch {
459
- try {
460
- dead = Date.now() - fs.statSync(dir).mtimeMs > 60_000;
461
- } catch {
462
- continue;
463
- }
464
- }
465
- if (dead) {
466
- fs.rmSync(dir, { recursive: true, force: true });
467
- swept.push(dir);
468
- }
469
- }
470
- return swept;
471
- }
472
-
473
429
  export interface Snapshot {
474
430
  baseDir: string;
475
431
  repoDir: string;
@@ -488,7 +444,6 @@ export interface Snapshot {
488
444
  export function createRepoSnapshot(originDir: string, tmpRoot: string = os.tmpdir()): Snapshot {
489
445
  const baseDir = fs.mkdtempSync(path.join(tmpRoot, SNAPSHOT_PREFIX));
490
446
  fs.chmodSync(baseDir, 0o700);
491
- fs.writeFileSync(path.join(baseDir, "runner.json"), JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
492
447
  const repoDir = path.join(baseDir, "repo");
493
448
  fs.mkdirSync(repoDir, { mode: 0o700 });
494
449
 
@@ -529,6 +484,27 @@ export function createRepoSnapshot(originDir: string, tmpRoot: string = os.tmpdi
529
484
  return { baseDir, repoDir, fileCount: copied };
530
485
  }
531
486
 
487
+ /**
488
+ * The one snapshot lifecycle every real run uses (full body and each group): create
489
+ * exactly one snapshot, run, then remove exactly the `baseDir` this call created. It
490
+ * never lists `tmpRoot` and never removes a root it did not create here: a prior run's
491
+ * snapshot is that run's evidence, whatever its process state, and deleting it is the
492
+ * operator's decision, never the qualifier's. `tmpRoot` is passed only by self-test
493
+ * fixtures; real runs leave it undefined (the OS tmp dir).
494
+ */
495
+ export async function withOwnSnapshot<T>(
496
+ originDir: string,
497
+ tmpRoot: string | undefined,
498
+ run: (snap: Snapshot) => Promise<T>,
499
+ ): Promise<T> {
500
+ const snap = createRepoSnapshot(originDir, tmpRoot);
501
+ try {
502
+ return await run(snap);
503
+ } finally {
504
+ fs.rmSync(snap.baseDir, { recursive: true, force: true });
505
+ }
506
+ }
507
+
532
508
  /**
533
509
  * Content manifest of the snapshot tree — path+mode+sha256 for files, target for
534
510
  * symlinks, bare entries for dirs — excluding .git and node_modules. Porcelain alone
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The pi-durable checkout gates' coarse operator registration guard (#129; re-scoped by the #130 P1b
3
+ * review). Shared by check-pi-durable-contact, -send and -receive.
4
+ *
5
+ * Subject: a gate run leaves the operator's pi-durable REGISTRATIONS exactly as it found them. Existing
6
+ * registrations — earlier sessions' records, receivers, sender markers — are ordinary operator state
7
+ * and never a red by their presence; only a change across the run is. The snapshot holds, under the
8
+ * operator agent dir:
9
+ * - every pi-durable meta record (`meta-sessions/*.meta.json` whose backend is pi-durable) by name
10
+ * and sha256 of its bytes, so an edit under the same filename is seen;
11
+ * - every pi-durable receiver marker (`meta-receivers/*.json`, same backend filter), likewise;
12
+ * - every pi-durable sender marker (`meta-senders/pi-durable/*`), likewise;
13
+ * - the entry NAMES two levels into `experimental/durable-sessions` (cwd-hash dirs and their session
14
+ * dirs) — never a file inside them.
15
+ * Scope, stated: a coarse registration guard. It reads no auth, settings, transcript or SQLite/session
16
+ * content, makes no operator-byte claim beyond the files above, and NEVER deletes or repairs anything.
17
+ * A concurrent operator session that registers during a run turns it red; that is the coarse guard's
18
+ * honest answer, not a gate defect to suppress.
19
+ */
20
+
21
+ import { createHash } from "node:crypto";
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+
25
+ const sha256 = (file: string): string => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
26
+
27
+ function backendOf(file: string): unknown {
28
+ try {
29
+ return (JSON.parse(fs.readFileSync(file, "utf8")) as { backend?: unknown }).backend;
30
+ } catch {
31
+ // Bounded environment probe: an unreadable or non-JSON file is not a pi-durable registration.
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ /** `{name: sha256}` for the regular files in `dir` that pass `keep`, in name order; `{}` when absent. */
37
+ function digests(dir: string, keep: (name: string) => boolean): Record<string, string> {
38
+ if (!fs.existsSync(dir)) return {};
39
+ const out: Record<string, string> = {};
40
+ for (const name of fs.readdirSync(dir).sort()) {
41
+ const file = path.join(dir, name);
42
+ if (fs.lstatSync(file).isFile() && keep(name)) out[name] = sha256(file);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Entry names one and two levels under `dir` (`a`, `a/b`), sorted; nothing inside is opened. */
48
+ function names(dir: string): string[] {
49
+ if (!fs.existsSync(dir)) return [];
50
+ const out: string[] = [];
51
+ for (const first of fs.readdirSync(dir).sort()) {
52
+ out.push(first);
53
+ const at = path.join(dir, first);
54
+ if (fs.lstatSync(at).isDirectory()) for (const second of fs.readdirSync(at).sort()) out.push(`${first}/${second}`);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export interface OperatorPiDurableSnapshot {
60
+ readonly records: Record<string, string>;
61
+ readonly receivers: Record<string, string>;
62
+ readonly senders: Record<string, string>;
63
+ readonly durableSessions: string[];
64
+ }
65
+
66
+ /** The snapshot of `agentDir` (the operator's `~/.pi/agent`), read-only. */
67
+ export function operatorPiDurableSnapshot(agentDir: string): OperatorPiDurableSnapshot {
68
+ const sessions = path.join(agentDir, "meta-sessions");
69
+ const receivers = path.join(agentDir, "meta-receivers");
70
+ return {
71
+ records: digests(sessions, (n) => n.endsWith(".meta.json") && backendOf(path.join(sessions, n)) === "pi-durable"),
72
+ receivers: digests(receivers, (n) => n.endsWith(".json") && backendOf(path.join(receivers, n)) === "pi-durable"),
73
+ senders: digests(path.join(agentDir, "meta-senders", "pi-durable"), () => true),
74
+ durableSessions: names(path.join(agentDir, "experimental", "durable-sessions")),
75
+ };
76
+ }
77
+
78
+ /** The guard's label body: counts only, so a receipt names the subject without copying operator paths. */
79
+ export function describeOperatorSnapshot(snapshot: OperatorPiDurableSnapshot): string {
80
+ return `${Object.keys(snapshot.records).length} pi-durable records, ${Object.keys(snapshot.receivers).length} receivers, ${Object.keys(snapshot.senders).length} sender markers, ${snapshot.durableSessions.length} durable session entries`;
81
+ }
82
+
83
+ /** Unchanged across the run: same names, same bytes, same session entries. */
84
+ export function operatorSnapshotUnchanged(
85
+ before: OperatorPiDurableSnapshot,
86
+ after: OperatorPiDurableSnapshot,
87
+ ): boolean {
88
+ return JSON.stringify(before) === JSON.stringify(after);
89
+ }
@@ -38,12 +38,12 @@ import { createHash } from "node:crypto";
38
38
  import * as fs from "node:fs";
39
39
  import * as path from "node:path";
40
40
  import {
41
- createRepoSnapshot,
42
41
  type MutantSpec,
43
42
  originHead,
44
43
  originWorkSurfaceSha,
45
44
  qualifyMutants,
46
45
  reportPassed,
46
+ withOwnSnapshot,
47
47
  } from "./mutation-qualify.ts";
48
48
 
49
49
  export const RECEIPT_SCHEMA = "entwurf.qualification-group-receipt/v0";
@@ -471,16 +471,12 @@ export async function runGroupReceipt(
471
471
  const declaredInputs = declaredInputPaths(repoDir, groupSpecs, gate);
472
472
  const inputsBefore = fileFingerprints(repoDir, declaredInputs);
473
473
 
474
- const snap = createRepoSnapshot(repoDir, opts.tmpRoot);
475
- opts.log(
476
- `[gate-qualification --group] PARTIAL: ${groupSpecs.length}/${selected.length} mutants of ${groupKey}; snapshot ${snap.repoDir} (origin HEAD ${headBefore.slice(0, 12)})`,
477
- );
478
- let report: Awaited<ReturnType<typeof qualifyMutants>>;
479
- try {
480
- report = await qualifyMutants(snap, groupSpecs, opts.log);
481
- } finally {
482
- fs.rmSync(snap.baseDir, { recursive: true, force: true });
483
- }
474
+ const report = await withOwnSnapshot(repoDir, opts.tmpRoot, (snap) => {
475
+ opts.log(
476
+ `[gate-qualification --group] PARTIAL: ${groupSpecs.length}/${selected.length} mutants of ${groupKey}; snapshot ${snap.repoDir} (origin HEAD ${headBefore.slice(0, 12)})`,
477
+ );
478
+ return qualifyMutants(snap, groupSpecs, opts.log);
479
+ });
484
480
 
485
481
  const headAfter = originHead(repoDir);
486
482
  const workSurfaceAfter = originWorkSurfaceSha(repoDir);
@@ -181,7 +181,7 @@
181
181
  "claim": "PI-CORE-PIN-TRACKS-FLOOR",
182
182
  "title": "the queue oracle's pi-agent-core pin drifts off the floor the rest of the pi runtime is pinned to, so the oracle keeps answering honestly — about a pi nobody in this repo runs",
183
183
  "subject": "package.json",
184
- "find": ["\"@earendil-works/pi-agent-core\": \"1.0.2\","],
184
+ "find": ["\"@earendil-works/pi-agent-core\": \"1.0.4\","],
185
185
  "replace": ["\"@earendil-works/pi-agent-core\": \"0.87.1\","],
186
186
  "gate": ["bash", "run.sh", "check-dep-versions"],
187
187
  "timeoutSeconds": 300,
@@ -82,6 +82,19 @@
82
82
  "timeoutSeconds": 120,
83
83
  "signature": "[QK:QUALIFY-BUILD-GROUP-CONSISTENT]",
84
84
  "signatureSource": "scripts/check-gate-qualification.ts"
85
+ },
86
+ {
87
+ "claim": "QUALIFY-PRIOR-RUN-EVIDENCE-KEPT",
88
+ "title": "the snapshot seam lists its tmp root and removes every snapshot-prefixed root before making its own — the retired ambient sweep is back, and a prior run's evidence is deleted by the qualifier instead of left to the operator (scoped to an explicit tmpRoot, which only self-test fixtures pass, so the defect never reaches the real tmp dir)",
89
+ "subject": "scripts/lib/mutation-qualify.ts",
90
+ "find": ["\tconst snap = createRepoSnapshot(originDir, tmpRoot);"],
91
+ "replace": [
92
+ "\tif (tmpRoot !== undefined) for (const name of fs.readdirSync(tmpRoot)) if (name.startsWith(SNAPSHOT_PREFIX)) fs.rmSync(path.join(tmpRoot, name), { recursive: true, force: true });\n\tconst snap = createRepoSnapshot(originDir, tmpRoot);"
93
+ ],
94
+ "gate": ["bash", "run.sh", "check-gate-manifests"],
95
+ "timeoutSeconds": 120,
96
+ "signature": "[QK:QUALIFY-PRIOR-RUN-EVIDENCE-KEPT]",
97
+ "signatureSource": "scripts/check-gate-qualification.ts"
85
98
  }
86
99
  ]
87
100
  }
@@ -192,17 +192,6 @@
192
192
  "signature": "[QK:PIIMPORT-WORK-SURFACE]",
193
193
  "signatureSource": "run.sh"
194
194
  },
195
- {
196
- "claim": "PIIMPORT-DURABLE-BOOTSTRAP-EXACT",
197
- "title": "the #129 durable exception loses its file scope: the two experimental durable specifiers become allowed in every file, so a new pi private-subpath import elsewhere passes the floor",
198
- "subject": "run.sh",
199
- "find": [" | sed -E \"/^pi\\/pi-durable\\/bootstrap\\.mjs:[0-9]+:/ s#"],
200
- "replace": [" | sed -E \"s#"],
201
- "gate": ["bash", "run.sh", "check-pi-import-surface"],
202
- "timeoutSeconds": 60,
203
- "signature": "[QK:PIIMPORT-DURABLE-BOOTSTRAP-EXACT]",
204
- "signatureSource": "run.sh"
205
- },
206
195
  {
207
196
  "claim": "VITEST-FAILED-TITLE-ATTRIBUTION",
208
197
  "title": "the pilot's kill attribution collects PASSING test titles instead of failed ones, recreating the code-frame false-KILLED hole this lane exists to close",
@@ -725,7 +714,7 @@
725
714
  },
726
715
  {
727
716
  "claim": "FRESHCALL-PI-DURABLE-PREFLIGHT-PREMUTATION",
728
- "title": "the pi-durable runtime/unit preflight is skipped, so an absent or drifted operator runtime is discovered only after a window was placed",
717
+ "title": "the pi-durable preflight (model, framed input cap, carrier, units) is skipped, so a refusal it owns is discovered only after a window was placed",
729
718
  "subject": "pi-extensions/lib/mux-fresh-call.ts",
730
719
  "find": ["\tif (params.backend === \"pi-durable\") {\n"],
731
720
  "replace": ["\tif (false) {\n"],
@@ -736,7 +725,7 @@
736
725
  },
737
726
  {
738
727
  "claim": "FRESHCALL-PI-DURABLE-MANAGED-RUNTIME",
739
- "title": "pi-durable opens the bare app instead of the managed `entwurf pi-durable`, so nothing verifies the operator runtime against the pin before its resolver runs",
728
+ "title": "pi-durable opens the bare app instead of the managed `entwurf pi-durable`, so nothing verifies the carrier and its SDK set before its resolver runs",
740
729
  "subject": "pi-extensions/lib/mux-fresh-call.ts",
741
730
  "find": ["\t\"pi-durable\": \"entwurf\","],
742
731
  "replace": ["\t\"pi-durable\": \"pi\","],
@@ -766,6 +755,17 @@
766
755
  "timeoutSeconds": 120,
767
756
  "signature": "[QK:PD-FRESH-LIVE-FOREIGN-SENDER-REFUSED]",
768
757
  "signatureSource": "test/pi-durable-fresh-live-receipts.test.ts"
758
+ },
759
+ {
760
+ "claim": "PIIMPORT-CARRIER-FENCE",
761
+ "title": "the carrier fence stops naming bare imports, so a pi-coding-agent specifier or an undeclared package in an emitted carrier file passes the floor",
762
+ "subject": "run.sh",
763
+ "find": ["if (name === \"@earendil-works/pi-coding-agent\" || !declared.has(name)) console.log("],
764
+ "replace": ["if (false) console.log("],
765
+ "gate": ["bash", "run.sh", "check-pi-import-surface"],
766
+ "timeoutSeconds": 60,
767
+ "signature": "[QK:PIIMPORT-CARRIER-FENCE]",
768
+ "signatureSource": "run.sh"
769
769
  }
770
770
  ]
771
771
  }
@@ -6,7 +6,7 @@
6
6
  "claim": "PACK-INSTALL-PIN-MATCHER-BOUNDED",
7
7
  "title": "the pin-leak matcher loses its version boundary and blesses substring lookalikes — @0.85.10 reads as the pinned 0.85.1 and the gate announces a verified tree it never verified (independent review, 2026-08-25)",
8
8
  "subject": "run.sh",
9
- "find": [" grep '^@earendil-works+' | grep -Ev '@1\\.0\\.2(_|$)' || true"],
9
+ "find": [" grep '^@earendil-works+' | grep -Ev '@1\\.0\\.4(_|$)' || true"],
10
10
  "replace": [" grep '^@earendil-works+' | grep -v '@0\\.87\\.1' || true"],
11
11
  "gate": ["bash", "run.sh", "check-pack-pin-matcher"],
12
12
  "timeoutSeconds": 120,
@@ -17,8 +17,8 @@
17
17
  "claim": "PACK-INSTALL-PIN-MATCHER-COVERS-CLOSURE",
18
18
  "title": "the pin-leak matcher narrows back to the pi-* families and goes blind to a non-pi member of the same runtime closure — @earendil-works/chord (new in pi 0.85.0, a runtime dependency of pi-coding-agent/pi-agent-core/pi-client/pi-protocol) floats unpinned while the gate prints a verified pin (measured 2026-09-06 on a real 0.85.1 install tree)",
19
19
  "subject": "run.sh",
20
- "find": [" grep '^@earendil-works+' | grep -Ev '@1\\.0\\.2(_|$)' || true"],
21
- "replace": [" grep '^@earendil-works+pi-' | grep -Ev '@1\\.0\\.2(_|$)' || true"],
20
+ "find": [" grep '^@earendil-works+' | grep -Ev '@1\\.0\\.4(_|$)' || true"],
21
+ "replace": [" grep '^@earendil-works+pi-' | grep -Ev '@1\\.0\\.4(_|$)' || true"],
22
22
  "gate": ["bash", "run.sh", "check-pack-pin-matcher"],
23
23
  "timeoutSeconds": 120,
24
24
  "signature": "[QK:PACK-INSTALL-PIN-MATCHER-COVERS-CLOSURE]",