@junghanacs/entwurf 0.30.1 → 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 (76) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +194 -0
  3. package/DELIVERY.md +1 -0
  4. package/README.md +64 -14
  5. package/VERIFY.md +36 -1
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +1 -1
  8. package/docs/setup-clean-host.md +121 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +7 -7
  10. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +7 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +7 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +28 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +28 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +92 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +236 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +746 -0
  19. package/mcp/entwurf-bridge/src/index.ts +7 -7
  20. package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
  21. package/package.json +19 -19
  22. package/pi/entwurf-capabilities.json +1 -0
  23. package/pi/pi-durable/bootstrap.mjs +404 -0
  24. package/pi/pi-durable/carrier/LICENSE +21 -0
  25. package/pi/pi-durable/carrier/harness-setup.js +123 -0
  26. package/pi/pi-durable/carrier/prompt.js +74 -0
  27. package/pi/pi-durable/carrier/runtime.js +402 -0
  28. package/pi/pi-durable/carrier/sessions.js +56 -0
  29. package/pi/pi-durable/carrier/subagent.js +55 -0
  30. package/pi/pi-durable/carrier/tui.js +655 -0
  31. package/pi/pi-durable/carrier-relocation.mjs +160 -0
  32. package/pi/pi-durable/carrier-resolver.mjs +38 -0
  33. package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
  34. package/pi/pi-durable/overlay/upstream-pin.json +108 -0
  35. package/pi-extensions/lib/entwurf-deliverability.ts +7 -1
  36. package/pi-extensions/lib/fresh-call-composition.ts +42 -7
  37. package/pi-extensions/lib/meta-sender-identity.ts +7 -1
  38. package/pi-extensions/lib/meta-session.ts +28 -2
  39. package/pi-extensions/lib/mux-fresh-call.ts +32 -2
  40. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
  41. package/pi-extensions/lib/pi-durable-runtime.ts +318 -0
  42. package/pi-extensions/meta-bridge-pi-durable.ts +1024 -0
  43. package/run.sh +624 -184
  44. package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
  45. package/scripts/check-fresh-call-dispatch.ts +18 -0
  46. package/scripts/check-gate-qualification.ts +87 -34
  47. package/scripts/check-herdr-activation.ts +5 -4
  48. package/scripts/check-pi-durable-contact.ts +767 -0
  49. package/scripts/check-pi-durable-receive.ts +1079 -0
  50. package/scripts/check-pi-durable-send.ts +1821 -0
  51. package/scripts/emit-pi-durable-carrier.ts +355 -0
  52. package/scripts/lib/mutation-qualify.ts +21 -45
  53. package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
  54. package/scripts/lib/pi-durable-operator-guard.ts +89 -0
  55. package/scripts/lib/pi-durable-r1.ts +100 -0
  56. package/scripts/lib/pi-durable-reap.ts +27 -0
  57. package/scripts/lib/pi-durable-receive.ts +137 -0
  58. package/scripts/lib/qualification-receipt.ts +7 -11
  59. package/scripts/mutants/control-send-receipt.json +1 -1
  60. package/scripts/mutants/copilot-birth.json +2 -2
  61. package/scripts/mutants/gate-qualification.json +13 -0
  62. package/scripts/mutants/meta-hook-session-switch.json +4 -2
  63. package/scripts/mutants/mux-fresh-call.json +69 -3
  64. package/scripts/mutants/omp-birth.json +2 -2
  65. package/scripts/mutants/omp-fresh.json +6 -4
  66. package/scripts/mutants/pack-install.json +3 -3
  67. package/scripts/mutants/pi-durable.json +723 -0
  68. package/scripts/mutants/pi-floor.json +24 -2
  69. package/scripts/pi-durable-contact-host.mjs +105 -0
  70. package/scripts/pi-durable-receive-host.mjs +127 -0
  71. package/scripts/pi-durable-send-host.mjs +284 -0
  72. package/scripts/smoke-herdr-raw-install-live.sh +1 -1
  73. package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
  74. package/scripts/smoke-setup-verdict.sh +22 -3
  75. package/scripts/smoke-user-scope-citizen.sh +1 -1
  76. package/scripts/tsconfig.json +1 -0
@@ -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,139 @@
1
+ /**
2
+ * The oracles of `smoke-pi-durable-fresh-live`, as pure functions over raw evidence, so the same code
3
+ * that judges a live run can re-judge its saved raw (Pi's JSONL and the pd native-store snapshot).
4
+ *
5
+ * Pi side: a message lands only as an exact payload with ONE anchored terminal sender envelope —
6
+ * `piCallbackSenders` already decides that shape; this adds the expected sender and returns the row.
7
+ * A body that merely MENTIONS the token and the sender's id (the fixture's own instruction did) is
8
+ * not a delivery.
9
+ *
10
+ * pd side: the native store's ROOT conversation only (no owner conversation or task), read through
11
+ * its entry kinds — `pi.system` (tool offering, applied as toolsAdded/toolsRemoved in order),
12
+ * `pi.assistant` (the model that answered, and the tool calls in order) and `pi.tool-result` (each
13
+ * call's settled result, joined by toolCallId).
14
+ */
15
+ import { piCallbackSenders } from "./codex-fresh-source-receipts.ts";
16
+
17
+ /** The one Pi transcript row whose exact payload arrived from `expectedSender`, or null. */
18
+ export function piExactDeliveryRow(
19
+ entries: readonly unknown[],
20
+ payload: string,
21
+ expectedSender: string,
22
+ ): { row: unknown; index: number } | null {
23
+ const hits: { row: unknown; index: number }[] = [];
24
+ entries.forEach((row, index) => {
25
+ const senders = piCallbackSenders([row], payload);
26
+ if (senders.length === 1 && senders[0] === expectedSender) hits.push({ row, index });
27
+ });
28
+ return hits.length === 1 ? (hits[0] as { row: unknown; index: number }) : null;
29
+ }
30
+
31
+ export interface PdNativeRow {
32
+ id: number;
33
+ conversation_id: number;
34
+ record: string;
35
+ }
36
+ export interface PdNativeSubmissionRow {
37
+ conversation_id: number;
38
+ status: string;
39
+ record: string;
40
+ }
41
+ export interface PdToolCall {
42
+ id: string;
43
+ name: string;
44
+ arguments: Record<string, unknown>;
45
+ result: { text: string; isError: boolean } | null;
46
+ }
47
+ export interface PdRootView {
48
+ offering: string[];
49
+ models: string[];
50
+ calls: PdToolCall[];
51
+ }
52
+
53
+ interface Message {
54
+ role?: unknown;
55
+ provider?: unknown;
56
+ model?: unknown;
57
+ content?: unknown;
58
+ toolsAdded?: unknown;
59
+ toolsRemoved?: unknown;
60
+ toolCallId?: unknown;
61
+ isError?: unknown;
62
+ }
63
+
64
+ function messagesOf(record: Record<string, unknown>): Message[] {
65
+ return Array.isArray(record.model) ? (record.model as Message[]) : [];
66
+ }
67
+ function names(list: unknown): string[] {
68
+ return Array.isArray(list)
69
+ ? list.flatMap((t) =>
70
+ t !== null && typeof t === "object" && typeof (t as { name?: unknown }).name === "string"
71
+ ? [(t as { name: string }).name]
72
+ : [],
73
+ )
74
+ : [];
75
+ }
76
+
77
+ /** The root conversation's offering, models and tool calls, from its own entries in id order. */
78
+ export function pdRootView(entries: readonly PdNativeRow[], rootConversationId: number): PdRootView {
79
+ let offering: string[] = [];
80
+ const models = new Set<string>();
81
+ const calls: PdToolCall[] = [];
82
+ const byId = new Map<string, PdToolCall>();
83
+ for (const row of [...entries].sort((a, b) => a.id - b.id)) {
84
+ if (row.conversation_id !== rootConversationId) continue;
85
+ const record = JSON.parse(row.record) as Record<string, unknown>;
86
+ for (const message of messagesOf(record)) {
87
+ if (record.kind === "pi.system") {
88
+ const removed = new Set(names(message.toolsRemoved));
89
+ offering = [...offering.filter((n) => !removed.has(n)), ...names(message.toolsAdded)];
90
+ } else if (record.kind === "pi.assistant" && message.role === "assistant") {
91
+ if (typeof message.provider === "string" && typeof message.model === "string")
92
+ models.add(`${message.provider}/${message.model}`);
93
+ for (const block of Array.isArray(message.content) ? message.content : []) {
94
+ const b = block as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown };
95
+ if (b.type !== "toolCall" || typeof b.id !== "string" || typeof b.name !== "string") continue;
96
+ const call: PdToolCall = {
97
+ id: b.id,
98
+ name: b.name,
99
+ arguments: (b.arguments ?? {}) as Record<string, unknown>,
100
+ result: null,
101
+ };
102
+ calls.push(call);
103
+ byId.set(b.id, call);
104
+ }
105
+ } else if (
106
+ record.kind === "pi.tool-result" &&
107
+ message.role === "toolResult" &&
108
+ typeof message.toolCallId === "string"
109
+ ) {
110
+ const call = byId.get(message.toolCallId);
111
+ if (call === undefined) continue;
112
+ const text = (Array.isArray(message.content) ? message.content : [])
113
+ .map((c) =>
114
+ (c as { type?: unknown; text?: unknown }).type === "text" ? String((c as { text: unknown }).text) : "",
115
+ )
116
+ .join("");
117
+ call.result = { text, isError: message.isError === true };
118
+ }
119
+ }
120
+ }
121
+ return { offering, models: [...models], calls };
122
+ }
123
+
124
+ /** The doorbell submission for exactly this mailbox message, on the root conversation. */
125
+ export function pdDoorbellSubmission(
126
+ submissions: readonly PdNativeSubmissionRow[],
127
+ rootConversationId: number,
128
+ requestId: string,
129
+ ): { status: string; record: Record<string, unknown> } | null {
130
+ const hits = submissions
131
+ .map((s) => ({ s, record: JSON.parse(s.record) as Record<string, unknown> }))
132
+ .filter(({ s, record }) => s.conversation_id === rootConversationId && record.requestId === requestId);
133
+ return hits.length === 1
134
+ ? {
135
+ status: (hits[0] as { s: PdNativeSubmissionRow }).s.status,
136
+ record: (hits[0] as { record: Record<string, unknown> }).record,
137
+ }
138
+ : null;
139
+ }
@@ -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
+ }