@junghanacs/entwurf 0.12.8 → 0.12.10

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 (96) hide show
  1. package/AGENTS.md +134 -248
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +39 -1
  4. package/DELIVERY.md +5 -4
  5. package/README.md +148 -23
  6. package/VERIFY.md +3 -2
  7. package/demo/README.md +2 -2
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +24 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +168 -81
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +5 -3
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +16 -18
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +14 -13
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +11 -3
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +500 -54
  26. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
  27. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
  28. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
  29. package/mcp/entwurf-bridge/src/index.ts +200 -94
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +81 -39
  32. package/pi-extensions/lib/acp/models.ts +12 -12
  33. package/pi-extensions/lib/acp/overlay.ts +5 -3
  34. package/pi-extensions/lib/entwurf-core.ts +26 -9
  35. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  36. package/pi-extensions/lib/entwurf-fact-provider.ts +24 -19
  37. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  38. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  39. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  40. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  41. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  42. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  43. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  44. package/pi-extensions/lib/entwurf-v2-production.ts +22 -13
  45. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +11 -3
  46. package/pi-extensions/lib/meta-sender-identity.ts +15 -5
  47. package/pi-extensions/lib/meta-session.ts +543 -58
  48. package/pi-extensions/meta-bridge-hook.ts +8 -2
  49. package/run.sh +78 -67
  50. package/scripts/agy-bridge-config.py +249 -49
  51. package/scripts/agy-bridge.sh +59 -14
  52. package/scripts/agy-imprint.ts +15 -1
  53. package/scripts/check-acp-carrier-augment.ts +34 -2
  54. package/scripts/check-acp-sdk-surface.ts +22 -11
  55. package/scripts/check-acp-session-reuse.ts +1 -1
  56. package/scripts/check-acp-session-store.ts +3 -3
  57. package/scripts/check-agy-permission-matrix.py +655 -0
  58. package/scripts/check-agy-sender-identity.ts +83 -0
  59. package/scripts/check-entwurf-control-rpc.ts +2 -2
  60. package/scripts/check-entwurf-deliverability.ts +9 -6
  61. package/scripts/check-entwurf-fact-provider.ts +16 -8
  62. package/scripts/check-entwurf-facts.ts +13 -13
  63. package/scripts/check-entwurf-resume-args.ts +25 -63
  64. package/scripts/check-entwurf-self-address.ts +187 -4
  65. package/scripts/check-entwurf-session-identity.ts +7 -6
  66. package/scripts/check-entwurf-v2-contract.ts +2 -2
  67. package/scripts/check-entwurf-v2-production.ts +9 -7
  68. package/scripts/check-entwurf-v2-spawn-production.ts +8 -4
  69. package/scripts/check-entwurf-v2-surface.ts +327 -14
  70. package/scripts/check-fresh-cut-gate.sh +305 -4
  71. package/scripts/check-gate-qualification.ts +785 -0
  72. package/scripts/check-meta-identity-consumers.ts +501 -1
  73. package/scripts/check-meta-listing.ts +91 -9
  74. package/scripts/check-meta-receiver-marker.ts +54 -0
  75. package/scripts/check-model-lock.ts +1 -1
  76. package/scripts/check-shell-quote.ts +2 -1
  77. package/scripts/lib/mutation-qualify.ts +794 -0
  78. package/scripts/meta-bridge-fresh-cut.ts +164 -28
  79. package/scripts/mutants/acp-augment.json +30 -0
  80. package/scripts/mutants/agy-permission.json +144 -0
  81. package/scripts/mutants/meta-identity.json +17 -0
  82. package/scripts/mutants/self-address.json +59 -0
  83. package/scripts/mutants/v2-surface.json +87 -0
  84. package/scripts/pi_settings_io.py +65 -0
  85. package/scripts/register-pi-package.py +183 -37
  86. package/scripts/register-pi-provider.py +68 -10
  87. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  88. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  89. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  90. package/scripts/smoke-agy-install-state.sh +205 -20
  91. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  92. package/scripts/smoke-meta-install-state.sh +1 -1
  93. package/scripts/smoke-pi-attach.ts +7 -2
  94. package/scripts/smoke-user-scope-citizen.sh +177 -0
  95. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  96. package/scripts/check-entwurf-mailbox-guard.ts +0 -262
@@ -24,7 +24,8 @@
24
24
  * - meta-sessions/ (identity records) → ARCHIVED (`<dir>.archive-<ts>`)
25
25
  * - meta-mailbox/ (messages+receipts) → ARCHIVED (`<dir>.archive-<ts>`)
26
26
  * - meta-senders/, meta-receivers/ markers → CLEARED (disposable process
27
- * state; the quiesce gate has already proven every owner pid is gone)
27
+ * state; the quiesce gate has already proven every owner pid is gone — or,
28
+ * for a refuted marker, that no such owner could ever have existed)
28
29
  * - control sockets (*.sock) → CLEARED if probed dead (the GC
29
30
  * rule: only a demonstrably dead socket may be unlinked)
30
31
  * - native transcripts, andenken/embedding axes, install ownership state
@@ -50,6 +51,28 @@
50
51
  * Only a marker whose named owner demonstrably no longer holds that pid is dead,
51
52
  * and only a dead marker/socket is cleared.
52
53
  *
54
+ * ONE class is neither live, uncertain nor dead: a marker whose `ownerPid` cannot
55
+ * own anything (`<= 1` — init, or a non-pid number). It is REFUTED BY CONSTRUCTION
56
+ * rather than proven dead: after #53 A no writer in this tree can mint one
57
+ * (`isPlausibleOwnerPid` at both writers and at the write boundary), so on a current
58
+ * install it is LEGACY or CORRUPT residue — a pre-fix writer whose parent had been
59
+ * reparented to init (the retired shell-form Claude hook; the agy imprint, which
60
+ * asked only `> 0` until this cut), or a foreign/damaged marker, which is the only
61
+ * way a non-integer pid can appear at all. The one file actually observed was a
62
+ * shell-form Claude hook reparented to init.
63
+ *
64
+ * Reading it as an owner is not merely wrong, it is a trap: init runs for the whole
65
+ * boot and its start-key does not change while it does, so `classifyMarkerOwner`
66
+ * answers `live` and THE ACTION THIS REFUSAL PRESCRIBES CANNOT CHANGE THAT — the
67
+ * operator quiesces every session, exactly as told, and the cut refuses again.
68
+ * (Deleting the marker removes the claim rather than refuting the verdict, and a
69
+ * reboot recomputes the key with no contract either way — neither is a remedy this
70
+ * cut may lean on.) Meanwhile 0.12.8 names this very cut as the one repair for a
71
+ * pre-v3 store, so the host was stuck until the file was deleted by hand (#53 A,
72
+ * measured 2026-07-25). Such a marker is therefore clearable residue, swept with the
73
+ * dead ones but COUNTED AND REPORTED APART from them, because invalidity and death
74
+ * are different findings.
75
+ *
53
76
  * THE MARKER WALK IS NOT THE WHOLE WORLD. A native-push (agy) citizen is registered
54
77
  * with NO marker of any kind and is dispatched straight off its record, so marker
55
78
  * absence is its NORMAL deliverable state — a socket+marker scan would call such a host
@@ -69,6 +92,15 @@
69
92
  * renames reports exactly what already moved (`archived so far:`) and a re-run
70
93
  * finishes the cut with its own stamp.
71
94
  * Running on a clean/empty host just opens a fresh generation and says so.
95
+ *
96
+ * EXIT CONTRACT (#54) — `FRESH_CUT_EXIT`, defined beside the verb's own name in
97
+ * meta-session.ts because a runbook and a gate must read the same numbers:
98
+ * `0` complete · `1` nothing moved · `2` usage · `3` cut transition incomplete ·
99
+ * `4` cut complete but marker/socket cleanup incomplete. The three failure states
100
+ * were all hidden behind one `1` before, which is why the documented `fresh-cut && setup` chain stopped
101
+ * identically on a refusal that changed nothing and on a cut that had already
102
+ * unblocked the install. Only `0` is success — #54 asked for the states to become
103
+ * DISTINGUISHABLE, not for a failed sweep to become a pass.
72
104
  */
73
105
 
74
106
  import * as fs from "node:fs";
@@ -82,7 +114,10 @@ import {
82
114
  defaultMetaReceiversDir,
83
115
  defaultMetaSendersDir,
84
116
  defaultMetaSessionsDir,
117
+ FRESH_CUT_EXIT,
118
+ isPlausibleOwnerPid,
85
119
  type MetaIdentity,
120
+ midCutExit,
86
121
  parseMetaIdentity,
87
122
  probePidExistence,
88
123
  processStartKey,
@@ -104,6 +139,20 @@ function usage(code: number): never {
104
139
  "store = ENTWURF_META_SESSIONS_DIR || <PI_CODING_AGENT_DIR|~/.pi/agent>/meta-sessions",
105
140
  "mailbox = ENTWURF_META_MAILBOX_DIR || <PI_CODING_AGENT_DIR|~/.pi/agent>/meta-mailbox",
106
141
  "sockets = ENTWURF_DIR || ~/.pi/entwurf-control",
142
+ "",
143
+ "EXIT CONTRACT (#54) — what already moved, not how bad it was:",
144
+ ` ${FRESH_CUT_EXIT.COMPLETE} cut complete: generation archived (or none existed), fresh v3 store open,`,
145
+ " residue cleared. `&& setup` is correct here.",
146
+ ` ${FRESH_CUT_EXIT.NO_MOVE} NOTHING MOVED: a live/unprovable surface, an occupied archive destination,`,
147
+ " an unreadable surface, or a first rename that failed. The host is unchanged —",
148
+ " fix the named cause and re-run. Do NOT run setup: the store it refused is still there.",
149
+ ` ${FRESH_CUT_EXIT.USAGE} usage.`,
150
+ ` ${FRESH_CUT_EXIT.HALF_CUT} CUT TRANSITION INCOMPLETE: at least one archive move happened,`,
151
+ " but the fresh generation is not confirmed open. Inspect, or re-run to finish under a new stamp.",
152
+ ` ${FRESH_CUT_EXIT.CLEANUP_INCOMPLETE} CUT COMPLETE, CLEANUP INCOMPLETE: the generation IS archived and the fresh`,
153
+ " generation IS open — install and citizen birth are unblocked, so setup may run —",
154
+ " but marker/socket residue survived. Prefer fixing it and re-running BEFORE setup; after new citizen birth,",
155
+ " remove the named residue manually instead of archiving the new generation with another cut.",
107
156
  ].join("\n"),
108
157
  );
109
158
  process.exit(code);
@@ -216,18 +265,22 @@ function classifySurfaceDir(dir: string): SurfaceDirState {
216
265
  * address out from under it. An unreadable marker at REST is disposable residue; an
217
266
  * unreadable marker as EVIDENCE OF QUIESCENCE is no evidence at all.
218
267
  */
219
- function inspectMarkers(dir: string, label: string): { violations: QuiesceViolation[]; deadFiles: string[] } {
268
+ function inspectMarkers(
269
+ dir: string,
270
+ label: string,
271
+ ): { violations: QuiesceViolation[]; deadFiles: string[]; refutedFiles: string[] } {
220
272
  const violations: QuiesceViolation[] = [];
221
273
  const deadFiles: string[] = [];
274
+ const refutedFiles: string[] = [];
222
275
  const markerFiles: { file: string; shown: string }[] = [];
223
276
  const uncertain = (shown: string, why: string): void => {
224
277
  violations.push({ surface: label, detail: `${shown} — ${why}`, kind: "uncertain" });
225
278
  };
226
279
  const dirState = classifySurfaceDir(dir);
227
- if (dirState.state === "absent") return { violations, deadFiles };
280
+ if (dirState.state === "absent") return { violations, deadFiles, refutedFiles };
228
281
  if (dirState.state !== "directory") {
229
282
  uncertain(dir, `surface could not be inspected (${dirState.detail}): an unreadable surface is not a quiesced one`);
230
- return { violations, deadFiles };
283
+ return { violations, deadFiles, refutedFiles };
231
284
  }
232
285
  // depth 0 = the marker root, depth 1 = a backend subdir. Nothing deeper is part
233
286
  // of any marker layout, so it is inspected by hand rather than swept.
@@ -263,13 +316,28 @@ function inspectMarkers(dir: string, label: string): { violations: QuiesceViolat
263
316
  uncertain(shown, `unreadable marker (${err instanceof Error ? err.message : String(err)}): owner unprovable`);
264
317
  continue;
265
318
  }
266
- const ownerPid =
267
- typeof raw.ownerPid === "number" && Number.isInteger(raw.ownerPid) && raw.ownerPid > 0 ? raw.ownerPid : null;
268
- const ownerStartKey = typeof raw.ownerStartKey === "string" ? raw.ownerStartKey : "";
269
- if (ownerPid === null) {
270
- uncertain(shown, "no positive-integer `ownerPid`: owner unprovable");
319
+ // Two different failures, deliberately kept apart. NO numeric `ownerPid` means the
320
+ // marker never NAMED an owner unprovable, so it refuses the cut like every other
321
+ // unreadable marker. A numeric one that cannot own (`<= 1`, non-integer, unsafe)
322
+ // IS a claim, and it is refuted BY CONSTRUCTION: no writer in this tree can mint it
323
+ // any more, and on the axis this repo certifies "this session is owned by init" is
324
+ // false on its face (a container harness running AS pid 1 is a real but UNSUPPORTED
325
+ // shape — see isPlausibleOwnerPid; it fails closed at the writers). That is a
326
+ // proof of INVALIDITY — strictly stronger than the proof of death this loop already
327
+ // acts on — so it is clearable residue, never `live` and never `uncertain`. Without
328
+ // this row a single reparented-owner marker left quiescence unprovable by the ONE
329
+ // action the refusal prescribes, and the repair 0.12.8 names could not run until
330
+ // the file was deleted by hand (#53 A, measured 2026-07-25).
331
+ if (typeof raw.ownerPid !== "number") {
332
+ uncertain(shown, "no numeric `ownerPid`: owner unprovable");
271
333
  continue;
272
334
  }
335
+ if (!isPlausibleOwnerPid(raw.ownerPid)) {
336
+ refutedFiles.push(file);
337
+ continue;
338
+ }
339
+ const ownerPid = raw.ownerPid;
340
+ const ownerStartKey = typeof raw.ownerStartKey === "string" ? raw.ownerStartKey : "";
273
341
  if (ownerStartKey === "") {
274
342
  uncertain(shown, "no `ownerStartKey`: a bare pid cannot distinguish the owner from a reused pid");
275
343
  continue;
@@ -294,7 +362,7 @@ function inspectMarkers(dir: string, label: string): { violations: QuiesceViolat
294
362
  deadFiles.push(file);
295
363
  }
296
364
  }
297
- return { violations, deadFiles };
365
+ return { violations, deadFiles, refutedFiles };
298
366
  }
299
367
 
300
368
  /**
@@ -319,7 +387,8 @@ function inspectMarkers(dir: string, label: string): { violations: QuiesceViolat
319
387
  * record this walk skips, and the claim is deliberately narrow: NOT "that native session
320
388
  * has exited", only "these bytes are not an address authority in the live runtime, so
321
389
  * they front no current-generation garden surface". Every path that ADDRESSES a citizen
322
- * goes through the live schema — `readMetaIdentityByGardenId`, the v2 `resolveTarget`,
390
+ * goes through the live schema — `readAddressableMetaIdentity` (v2 `resolveTarget` and
391
+ * the pi resume), `readMetaIdentityByGardenId` (the relay reads),
323
392
  * the sender-marker trust — and each THROWS on a record this parser refuses, so nothing
324
393
  * can dispatch to it. (`entwurf_peers` still LISTS it as a diagnostic; a facts surface
325
394
  * reporting what it could not read is not an address.) A record we CAN read is the
@@ -402,10 +471,39 @@ async function inspectNativePushCitizens(storeDir: string): Promise<QuiesceViola
402
471
  return violations;
403
472
  }
404
473
 
474
+ /**
475
+ * Remove the files a completed cut is contracted to clear, and report what it could
476
+ * not. ENOENT is the ONLY tolerated failure — the file raced away, which is the goal
477
+ * state. Every other errno is a real refusal to delete (EACCES, EPERM under a
478
+ * sticky-bit parent, EROFS, an immutable attribute, an LSM denial), and a bare
479
+ * `catch {}` here would launder all of them into "raced away" while the command's own
480
+ * output still said `cleared:`. That is the Crash-Don't-Warn shape this repo removes,
481
+ * not a shortcut it tolerates — the caller decides what to do with the failures,
482
+ * because by this point the archive has already moved and throwing would cost the
483
+ * operator the story of what DID happen.
484
+ */
485
+ function clearFiles(files: string[]): { cleared: number; failures: { file: string; reason: string }[] } {
486
+ const failures: { file: string; reason: string }[] = [];
487
+ let cleared = 0;
488
+ for (const file of files) {
489
+ try {
490
+ fs.unlinkSync(file);
491
+ cleared += 1;
492
+ } catch (err) {
493
+ if ((err as { code?: unknown }).code === "ENOENT") continue; // raced away — already gone is the goal state
494
+ failures.push({ file, reason: err instanceof Error ? err.message : String(err) });
495
+ }
496
+ }
497
+ return { cleared, failures };
498
+ }
499
+
500
+ /** Surfaces this process has already renamed into an archive — see the rename loop. */
501
+ let archivedSoFar = 0;
502
+
405
503
  async function main(): Promise<number> {
406
504
  const args = process.argv.slice(2);
407
- if (args.includes("-h") || args.includes("--help")) usage(0);
408
- if (args.length > 0) usage(2);
505
+ if (args.includes("-h") || args.includes("--help")) usage(FRESH_CUT_EXIT.COMPLETE);
506
+ if (args.length > 0) usage(FRESH_CUT_EXIT.USAGE);
409
507
 
410
508
  const storeDir = defaultMetaSessionsDir();
411
509
  const mailboxDir = defaultMetaMailboxDir();
@@ -487,7 +585,7 @@ async function main(): Promise<number> {
487
585
  "Quiesce those sessions (close them / let them exit), inspect anything listed UNCERTAIN, " +
488
586
  "then re-run the same command. Nothing was moved.",
489
587
  );
490
- return 1;
588
+ return FRESH_CUT_EXIT.NO_MOVE;
491
589
  }
492
590
 
493
591
  // ── the cut: plan the moves, preflight the whole plan, then rename ───────
@@ -536,9 +634,15 @@ async function main(): Promise<number> {
536
634
  "taken (a cut in the same second, or leftovers from an interrupted one). Nothing was moved: " +
537
635
  "wait a second and re-run, or move those directories aside first.",
538
636
  );
539
- return 1;
637
+ return FRESH_CUT_EXIT.NO_MOVE;
540
638
  }
541
639
  const archived: string[] = [];
640
+ // Mirrored to module scope for the ONE reader that cannot see this array: the
641
+ // top-level rejection handler. Everything from here on can still throw — the
642
+ // `mkdirSync` that opens the fresh generation, most obviously — and a handler that
643
+ // answered a flat `1` there would tell a runbook "nothing moved" about a host whose
644
+ // generation is already in an archive. A single-shot CLI may hold one fact in module
645
+ // scope when the alternative is an exit status that lies (#54).
542
646
  for (const { src, dest } of plan) {
543
647
  try {
544
648
  fs.renameSync(src, dest);
@@ -563,19 +667,20 @@ async function main(): Promise<number> {
563
667
  "own stamp — or inspect by hand.",
564
668
  );
565
669
  }
566
- return 1;
670
+ // The prose above already distinguished these two; the STATUS did not, and a
671
+ // runbook reads the status (#54). `midCutExit` is the SSOT so the words and the
672
+ // number cannot drift apart.
673
+ return midCutExit(archived.length);
567
674
  }
568
675
  archived.push(dest);
676
+ archivedSoFar = archived.length;
569
677
  }
570
- let cleared = 0;
571
- for (const file of [...senders.deadFiles, ...receivers.deadFiles, ...deadSockets]) {
572
- try {
573
- fs.unlinkSync(file);
574
- cleared += 1;
575
- } catch {
576
- // raced away — already gone is the goal state
577
- }
578
- }
678
+ // Both sweeps go through ONE remover so they cannot drift to different meanings of
679
+ // "could not remove". Counted APART, because a dead marker is an owner we PROVED
680
+ // left while a refuted one never named an owner that could exist, and folding the
681
+ // two would dress a proof of invalidity up as a proof of death.
682
+ const dead = clearFiles([...senders.deadFiles, ...receivers.deadFiles, ...deadSockets]);
683
+ const refuted = clearFiles([...senders.refutedFiles, ...receivers.refutedFiles]);
579
684
  fs.mkdirSync(storeDir, { recursive: true });
580
685
  fs.mkdirSync(mailboxDir, { recursive: true });
581
686
 
@@ -584,13 +689,40 @@ async function main(): Promise<number> {
584
689
  } else {
585
690
  for (const dir of archived) console.log(`archived: ${dir}`);
586
691
  }
587
- if (cleared > 0) console.log(`cleared: ${cleared} dead marker/socket file(s) (disposable process state)`);
692
+ if (dead.cleared > 0) console.log(`cleared: ${dead.cleared} dead marker/socket file(s) (disposable process state)`);
693
+ if (refuted.cleared > 0) {
694
+ console.log(
695
+ `refuted: ${refuted.cleared} legacy/corrupt marker(s) named an owner pid that cannot own a session ` +
696
+ "(<= 1, or not a pid at all); REFUTED BY CONSTRUCTION, not proven dead, and cleared. The observed " +
697
+ "case was a hook reparented to init before it read its own parent.",
698
+ );
699
+ }
588
700
  console.log(`fresh generation open: ${storeDir} (empty, v3-only)`);
589
701
  console.log(
590
702
  "untouched: native transcripts, andenken/embedding memory axes, install state. " +
591
703
  "The archive is forensic only — no runtime reads it and there is no restore verb.",
592
704
  );
593
- return 0;
705
+
706
+ // The generation IS cut by now, so a cleanup failure must not throw away that
707
+ // story — but it must not be swallowed either: this command's own report claims
708
+ // the residue was "cleared". Say exactly which files survived and why, keep the
709
+ // success lines above (this is not a half-cut), and exit nonzero so no caller
710
+ // reads a partial sweep as a clean one.
711
+ const cleanupFailures = [...dead.failures, ...refuted.failures];
712
+ if (cleanupFailures.length > 0) {
713
+ for (const { file, reason } of cleanupFailures) console.error(`FAIL post-cut cleanup: ${file} — ${reason}`);
714
+ console.error(
715
+ `FAIL post-cut cleanup: ${cleanupFailures.length} marker/socket file(s) above could NOT be removed. The ` +
716
+ "generation was archived and the fresh generation is open — this is not a half-cut, and install/citizen " +
717
+ "birth are no longer blocked by the store. What survived is disposable process state sitting in the new " +
718
+ "generation's surfaces, and it will refuse the NEXT cut. Fix the cause (permissions, a read-only mount, " +
719
+ `an immutable attribute) and re-run the same command BEFORE \`setup\`, or remove those files by hand. EXIT ` +
720
+ `${FRESH_CUT_EXIT.CLEANUP_INCOMPLETE}: the cut is DONE, so \`setup\` may proceed — but once a new citizen is ` +
721
+ "born, another fresh-cut would archive that new generation too; repair the named residue manually instead.",
722
+ );
723
+ return FRESH_CUT_EXIT.CLEANUP_INCOMPLETE;
724
+ }
725
+ return FRESH_CUT_EXIT.COMPLETE;
594
726
  }
595
727
 
596
728
  main().then(
@@ -598,6 +730,10 @@ main().then(
598
730
  (err) => {
599
731
  console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
600
732
  console.error("FAIL: fresh-cut did not complete — the message above names the cause; re-run after fixing it.");
601
- process.exit(1);
733
+ // Not a flat 1: the ONE thing a caller must know from a crash is whether the host
734
+ // still holds its generation. Every refusal that reaches here from before the first
735
+ // rename really is a no-op (its own message says so); anything after it is a
736
+ // an incomplete cut transition and must not be mistaken for a no-op (#54).
737
+ process.exit(midCutExit(archivedSoFar));
602
738
  },
603
739
  );
@@ -0,0 +1,30 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "acp-augment",
4
+ "mutants": [
5
+ {
6
+ "claim": "AUGMENT-BUDGET-FITS",
7
+ "title": "shrinking the augment cap re-opens the tail-cut that hid AGENTS rules + cwd from ACP siblings",
8
+ "subject": "pi-extensions/lib/acp/augment.ts",
9
+ "find": ["const MAX_AUGMENT_BYTES = 50 * 1024;"],
10
+ "replace": ["const MAX_AUGMENT_BYTES = 20 * 1024;"],
11
+ "gate": ["bash", "run.sh", "check-acp-carrier-augment"],
12
+ "timeoutSeconds": 60,
13
+ "signature": "[QK:AUGMENT-BUDGET-FITS]",
14
+ "signatureSource": "scripts/check-acp-carrier-augment.ts"
15
+ },
16
+ {
17
+ "claim": "AUGMENT-TRUNC-MARKER",
18
+ "title": "silent truncation: dropping the honest marker leaves a cut augment with no trace",
19
+ "subject": "pi-extensions/lib/acp/augment.ts",
20
+ "find": [
21
+ "\tconst marker = `\\n\\n[entwurf: context augment truncated to ${MAX_AUGMENT_BYTES} bytes; read AGENTS.md files directly if more detail is needed.]`;"
22
+ ],
23
+ "replace": ["\tconst marker = \"\";"],
24
+ "gate": ["bash", "run.sh", "check-acp-carrier-augment"],
25
+ "timeoutSeconds": 60,
26
+ "signature": "[QK:AUGMENT-TRUNC-MARKER]",
27
+ "signatureSource": "scripts/check-acp-carrier-augment.ts"
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,144 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "agy-permission",
4
+ "mutants": [
5
+ {
6
+ "claim": "AGY-EXACT-RULE-SET",
7
+ "title": "over-grant: adding entwurf_inbox_read to ALLOW_RULES must die at the independent literal oracle",
8
+ "subject": "scripts/agy-bridge-config.py",
9
+ "find": [
10
+ "ALLOW_RULES = tuple(f\"mcp({SERVER_KEY}/{tool})\" for tool in (\"entwurf_v2\", \"entwurf_peers\", \"entwurf_self\"))"
11
+ ],
12
+ "replace": [
13
+ "ALLOW_RULES = tuple(",
14
+ " f\"mcp({SERVER_KEY}/{tool})\" for tool in (\"entwurf_v2\", \"entwurf_peers\", \"entwurf_self\", \"entwurf_inbox_read\")",
15
+ ")"
16
+ ],
17
+ "gate": ["bash", "run.sh", "smoke-agy-install-state"],
18
+ "timeoutSeconds": 180,
19
+ "signature": "[QK:AGY-EXACT-RULE-SET]",
20
+ "signatureSource": "scripts/smoke-agy-install-state.sh"
21
+ },
22
+ {
23
+ "claim": "AGY-INSTALL-MALFORMED-PRIOR",
24
+ "title": "install reverts to a shallow prior-state check (empty provenance map passes all()) and re-captures",
25
+ "subject": "scripts/agy-bridge-config.py",
26
+ "find": [
27
+ " try:",
28
+ " _prior_rules, prior_existed = _parse_permission_state(prior)",
29
+ " except _PermissionStateError as exc:",
30
+ " _die(4, f\"agy-bridge: prior permission install-state is unusable — {exc}. Refusing to \"",
31
+ " \"overwrite provenance this entwurf cannot read; inspect it by hand.\")",
32
+ " for rule in ALLOW_RULES:",
33
+ " if rule in prior_existed:",
34
+ " rules_existed[rule] = prior_existed[rule]"
35
+ ],
36
+ "replace": [
37
+ " if prior.get(\"schemaVersion\") == 1:",
38
+ " legacy_rule = prior.get(\"rule\")",
39
+ " if isinstance(legacy_rule, str) and isinstance(prior.get(\"ruleExistedBefore\"), bool):",
40
+ " if legacy_rule in ALLOW_RULES:",
41
+ " rules_existed[legacy_rule] = prior[\"ruleExistedBefore\"]",
42
+ " else:",
43
+ " prior_existed = prior.get(\"rulesExistedBefore\")",
44
+ " if isinstance(prior_existed, dict) and all(isinstance(v, bool) for v in prior_existed.values()):",
45
+ " for rule in ALLOW_RULES:",
46
+ " if rule in prior_existed:",
47
+ " rules_existed[rule] = prior_existed[rule]"
48
+ ],
49
+ "gate": ["bash", "run.sh", "smoke-agy-install-state"],
50
+ "timeoutSeconds": 180,
51
+ "signature": "[QK:AGY-INSTALL-MALFORMED-PRIOR]",
52
+ "signatureSource": "scripts/smoke-agy-install-state.sh"
53
+ },
54
+ {
55
+ "claim": "AGY-UNINSTALL-VALIDATE-FIRST",
56
+ "title": "uninstall validates the state only inside the settings-exists branch (refusal after mutation)",
57
+ "subject": "scripts/agy-bridge-config.py",
58
+ "find": [
59
+ " ours = _owned_rules(state)",
60
+ "",
61
+ " if os.path.islink(settings_path):",
62
+ " _die(3, f\"agy-bridge: refusing to uninstall — {settings_path} became a symlink since install \"",
63
+ " f\"(someone else's SSOT now). Resolve by hand.\")",
64
+ "",
65
+ " if os.path.exists(settings_path):",
66
+ " data = _load_config(settings_path)"
67
+ ],
68
+ "replace": [
69
+ " if os.path.islink(settings_path):",
70
+ " _die(3, f\"agy-bridge: refusing to uninstall — {settings_path} became a symlink since install \"",
71
+ " f\"(someone else's SSOT now). Resolve by hand.\")",
72
+ "",
73
+ " ours = []",
74
+ " if os.path.exists(settings_path):",
75
+ " data = _load_config(settings_path)",
76
+ " ours = _owned_rules(state)"
77
+ ],
78
+ "gate": ["bash", "run.sh", "smoke-agy-install-state"],
79
+ "timeoutSeconds": 180,
80
+ "signature": "[QK:AGY-UNINSTALL-VALIDATE-FIRST]",
81
+ "signatureSource": "scripts/smoke-agy-install-state.sh"
82
+ },
83
+ {
84
+ "claim": "AGY-DOCTOR-OWNERSHIP-AXIS",
85
+ "title": "the state-doctor rounds a path-correct but unreadable ownership record up to ok",
86
+ "subject": "scripts/agy-bridge-config.py",
87
+ "find": [
88
+ " try:",
89
+ " rules, _existed = _parse_permission_state(state)",
90
+ " except _PermissionStateError as exc:",
91
+ " sys.stdout.write(f\"corrupt {exc}\\n\")",
92
+ " return",
93
+ " sys.stdout.write(f\"ok {len(rules)}\\n\")"
94
+ ],
95
+ "replace": [" sys.stdout.write(\"ok 0\\n\")"],
96
+ "gate": ["bash", "run.sh", "smoke-agy-install-state"],
97
+ "timeoutSeconds": 180,
98
+ "signature": "[QK:AGY-DOCTOR-OWNERSHIP-AXIS]",
99
+ "signatureSource": "scripts/smoke-agy-install-state.sh"
100
+ },
101
+ {
102
+ "claim": "AGY-CROSS-LIST-BROAD-FIRST",
103
+ "title": "broad-most-covering runs only within each list, so a deny-exact shadows an ask-broad host wrongly",
104
+ "subject": "scripts/agy-bridge-config.py",
105
+ "find": [
106
+ " for rule_set, scope in ((BROAD_RULES, \"broad\"), (ALLOW_RULES, \"exact\")):",
107
+ " for list_name in SHADOWING_LISTS:",
108
+ " rules = perms.get(list_name)",
109
+ " if not isinstance(rules, list):",
110
+ " continue",
111
+ " for rule in rules:",
112
+ " if rule in rule_set:",
113
+ " sys.stdout.write(f\"shadowed-by-{list_name} {scope} {rule}\\n\")",
114
+ " return"
115
+ ],
116
+ "replace": [
117
+ " for list_name in SHADOWING_LISTS:",
118
+ " rules = perms.get(list_name)",
119
+ " if not isinstance(rules, list):",
120
+ " continue",
121
+ " for rule_set, scope in ((BROAD_RULES, \"broad\"), (ALLOW_RULES, \"exact\")):",
122
+ " for rule in rules:",
123
+ " if rule in rule_set:",
124
+ " sys.stdout.write(f\"shadowed-by-{list_name} {scope} {rule}\\n\")",
125
+ " return"
126
+ ],
127
+ "gate": ["bash", "run.sh", "check-agy-permission-matrix"],
128
+ "timeoutSeconds": 60,
129
+ "signature": "[QK:AGY-CROSS-LIST-BROAD-FIRST]",
130
+ "signatureSource": "scripts/check-agy-permission-matrix.py"
131
+ },
132
+ {
133
+ "claim": "AGY-SCHEMA-VERSIONS-APART",
134
+ "title": "re-merging the two state schema constants stamps a lying version onto the MCP install-state",
135
+ "subject": "scripts/agy-bridge-config.py",
136
+ "find": ["STATE_SCHEMA_VERSION = 1 # MCP install-state (server key + preimage); shape unchanged"],
137
+ "replace": ["STATE_SCHEMA_VERSION = 2 # re-merged with the permission version (the schema re-merge defect)"],
138
+ "gate": ["bash", "run.sh", "smoke-agy-install-state"],
139
+ "timeoutSeconds": 180,
140
+ "signature": "[QK:AGY-SCHEMA-VERSIONS-APART]",
141
+ "signatureSource": "scripts/smoke-agy-install-state.sh"
142
+ }
143
+ ]
144
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "meta-identity",
4
+ "mutants": [
5
+ {
6
+ "claim": "META-FIFO-NONBLOCK",
7
+ "title": "removing O_NONBLOCK makes a fifo-shaped record block the open — the 7/26 mutation evidence, committed",
8
+ "subject": "pi-extensions/lib/meta-session.ts",
9
+ "find": ["\tconst fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW_FLAG | O_NONBLOCK_FLAG);"],
10
+ "replace": ["\tconst fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW_FLAG);"],
11
+ "gate": ["bash", "run.sh", "check-meta-identity-consumers"],
12
+ "timeoutSeconds": 120,
13
+ "signature": "[QK:META-FIFO-NONBLOCK]",
14
+ "signatureSource": "scripts/check-meta-identity-consumers.ts"
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "self-address",
4
+ "mutants": [
5
+ {
6
+ "claim": "SELFADDR-RAIL-RENDER",
7
+ "title": "entwurf_self reverts to an empty self-fetch guard + unconditional mailbox render (ordering-not-containment)",
8
+ "subject": "mcp/entwurf-bridge/src/index.ts",
9
+ "find": [
10
+ "\t\t\t\tif (rail === \"self-fetch\") {",
11
+ "\t\t\t\t\tconst mailboxPath = path.join(defaultMetaMailboxDir(), sender.sessionId);",
12
+ "\t\t\t\t\textra.mailboxPath = mailboxPath;",
13
+ "\t\t\t\t\tlines.push(`mailboxPath: ${mailboxPath}`);",
14
+ "\t\t\t\t} else if (rail === \"native-push\") {"
15
+ ],
16
+ "replace": [
17
+ "\t\t\t\tif (rail === \"self-fetch\") {",
18
+ "\t\t\t\t}",
19
+ "\t\t\t\tconst mailboxPath = path.join(defaultMetaMailboxDir(), sender.sessionId);",
20
+ "\t\t\t\textra.mailboxPath = mailboxPath;",
21
+ "\t\t\t\tlines.push(`mailboxPath: ${mailboxPath}`);",
22
+ "\t\t\t\tif (rail === \"native-push\") {"
23
+ ],
24
+ "gate": ["bash", "run.sh", "check-entwurf-self-address"],
25
+ "timeoutSeconds": 60,
26
+ "signature": "[QK:SELFADDR-RAIL-RENDER]",
27
+ "signatureSource": "scripts/check-entwurf-self-address.ts"
28
+ },
29
+ {
30
+ "claim": "SELFADDR-NO-HARDCODED-REPLYABLE",
31
+ "title": "buildStrictPiSenderEnvelope hardcodes replyable: true again (env presence read as reachability)",
32
+ "subject": "mcp/entwurf-bridge/src/index.ts",
33
+ "find": ["\t\torigin: \"pi-session\",", "\t\treplyable: self.replyable,", "\t};", "}"],
34
+ "replace": ["\t\torigin: \"pi-session\",", "\t\treplyable: true,", "\t};", "}"],
35
+ "gate": ["bash", "run.sh", "check-entwurf-self-address"],
36
+ "timeoutSeconds": 60,
37
+ "signature": "[QK:SELFADDR-NO-HARDCODED-REPLYABLE]",
38
+ "signatureSource": "scripts/check-entwurf-self-address.ts"
39
+ },
40
+ {
41
+ "claim": "SELFADDR-RAIL-FACT-LEAK",
42
+ "title": "the native-push predicate lets a mailbox fact (watchArmed) buy replyability — behavioral, not text",
43
+ "subject": "pi-extensions/lib/entwurf-self-address.ts",
44
+ "find": [
45
+ "\t\t\t\t\tconst push = nativePushDeliverable({ recordBacked: facts.recordBacked, probeAlive: facts.probeAlive });"
46
+ ],
47
+ "replace": [
48
+ "\t\t\t\t\tconst push = nativePushDeliverable({",
49
+ "\t\t\t\t\t\trecordBacked: facts.recordBacked,",
50
+ "\t\t\t\t\t\tprobeAlive: facts.probeAlive === true || facts.watchArmed === true,",
51
+ "\t\t\t\t\t});"
52
+ ],
53
+ "gate": ["bash", "run.sh", "check-entwurf-self-address"],
54
+ "timeoutSeconds": 60,
55
+ "signature": "[QK:SELFADDR-RAIL-FACT-LEAK]",
56
+ "signatureSource": "scripts/check-entwurf-self-address.ts"
57
+ }
58
+ ]
59
+ }
@@ -0,0 +1,87 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "v2-surface",
4
+ "mutants": [
5
+ {
6
+ "claim": "V2SURF-MCP-LOCK-DOMAIN",
7
+ "title": "MCP long description reverts to the false \"socket paths take a per-target lock\" claim",
8
+ "subject": "mcp/entwurf-bridge/src/index.ts",
9
+ "find": [
10
+ "\t\t\"(delivered / rejected / lock-retained / delivered-but-lock-dirty). LOCK POLICY (do not \" +",
11
+ "\t\t\"over-generalize it): the per-target lock is taken for a control-socket-DOMAIN dispatch, which is \" +",
12
+ "\t\t\"both the live send AND the dormant cell's spawn-bg resume — spawn-bg is a separate relaunch \" +",
13
+ "\t\t\"transport yet it still runs under that domain's lock. The mailbox and native-push rails are \" +",
14
+ "\t\t\"lock-free: the mailbox is guarded instead by active-receiver deliverability, and native-push by \" +",
15
+ "\t\t\"its adapter probe. The decider — not the \" +"
16
+ ],
17
+ "replace": [
18
+ "\t\t\"(delivered / rejected / lock-retained / delivered-but-lock-dirty). LOCK POLICY: socket paths \" +",
19
+ "\t\t\"take a per-target lock; the mailbox path does not. The decider — not the \" +"
20
+ ],
21
+ "gate": ["bash", "run.sh", "check-entwurf-v2-surface"],
22
+ "timeoutSeconds": 60,
23
+ "signature": "[QK:V2SURF-MCP-LOCK-DOMAIN]",
24
+ "signatureSource": "scripts/check-entwurf-v2-surface.ts"
25
+ },
26
+ {
27
+ "claim": "V2SURF-PI-MODE-SCOPE",
28
+ "title": "pi-native mode param reverts to \"Delivery mode for a live send\" (false for native-push)",
29
+ "subject": "pi-extensions/entwurf-control.ts",
30
+ "find": [
31
+ "\t\t\t\tdescription:",
32
+ "\t\t\t\t\t\"Injection style for a CONTROL-SOCKET send only: steer (immediate) or follow_up (after task). \" +",
33
+ "\t\t\t\t\t\"The mailbox, native-push, and spawn-bg plans carry no mode, so it has no effect on those rails.\","
34
+ ],
35
+ "replace": [
36
+ "\t\t\t\tdescription:",
37
+ "\t\t\t\t\t\"Delivery mode for a live send: steer (immediate, interrupt the current turn) or follow_up \" +",
38
+ "\t\t\t\t\t\"(queued after the current task finishes running to completion).\","
39
+ ],
40
+ "gate": ["bash", "run.sh", "check-entwurf-v2-surface"],
41
+ "timeoutSeconds": 60,
42
+ "signature": "[QK:V2SURF-PI-MODE-SCOPE]",
43
+ "signatureSource": "scripts/check-entwurf-v2-surface.ts"
44
+ },
45
+ {
46
+ "claim": "V2SURF-INBOX-SCOPE-HONESTY",
47
+ "title": "entwurf_inbox_read reverts to the false \"drains your own inbox\" access claim (SCOPE paragraph gone)",
48
+ "subject": "mcp/entwurf-bridge/src/index.ts",
49
+ "find": [
50
+ "\t\t\"doorbell is only a wake attempt, not a read. An empty inbox mutates nothing. \" +",
51
+ "\t\t\"SCOPE — read this literally: the garden id is CALLER-SUPPLIED and is NOT verified against \" +",
52
+ "\t\t\"your own identity (a host with no garden record of its own can call this too). So passing \" +",
53
+ "\t\t\"another citizen's garden id drains THEIR mail and stamps THEIR receipt, and they will never \" +",
54
+ "\t\t\"see those messages. Pass only the id from your own doorbell notice or your own meta-record; \" +",
55
+ "\t\t\"use entwurf_self if you need to confirm which id that is. \" +"
56
+ ],
57
+ "replace": [
58
+ "\t\t\"doorbell is only a wake attempt, not a read. An empty inbox mutates nothing. \" +",
59
+ "\t\t\"This tool drains your own meta-bridge inbox and stamps the read receipt on your meta-record. \" +"
60
+ ],
61
+ "gate": ["bash", "run.sh", "check-entwurf-v2-surface"],
62
+ "timeoutSeconds": 60,
63
+ "signature": "[QK:V2SURF-INBOX-SCOPE-HONESTY]",
64
+ "signatureSource": "scripts/check-entwurf-v2-surface.ts"
65
+ },
66
+ {
67
+ "claim": "V2SURF-MERGED-REJECT",
68
+ "title": "MCP long description merges the two owned rejects back under backend-liveness-unsupported",
69
+ "subject": "mcp/entwurf-bridge/src/index.ts",
70
+ "find": [
71
+ "\t\t\"owned-outcome is ONLY for waking a DORMANT socket-domain citizen (spawn-bg resume, currently \" +",
72
+ "\t\t\"backend pi); on a live target it is rejected as owned-live-no-autosend. Neither self-fetch nor \" +",
73
+ "\t\t\"native-push has resume authority, but they reject under DIFFERENT reasons — self-fetch as \" +",
74
+ "\t\t\"backend-liveness-unsupported, native-push as native-push-no-resume-authority. \" +"
75
+ ],
76
+ "replace": [
77
+ "\t\t\"owned-outcome is ONLY for waking a DORMANT socket-domain citizen (spawn-bg resume, currently \" +",
78
+ "\t\t\"backend pi); on a live target it is rejected as owned-live-no-autosend, and on self-fetch and \" +",
79
+ "\t\t\"native-push alike it is rejected as backend-liveness-unsupported. \" +"
80
+ ],
81
+ "gate": ["bash", "run.sh", "check-entwurf-v2-surface"],
82
+ "timeoutSeconds": 60,
83
+ "signature": "[QK:V2SURF-MERGED-REJECT]",
84
+ "signatureSource": "scripts/check-entwurf-v2-surface.ts"
85
+ }
86
+ ]
87
+ }