@junghanacs/entwurf 0.12.8 → 0.12.9

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 (52) hide show
  1. package/AGENTS.md +4 -4
  2. package/CHANGELOG.md +20 -1
  3. package/DELIVERY.md +1 -1
  4. package/README.md +122 -10
  5. package/demo/README.md +2 -2
  6. package/docs/setup-clean-host.md +14 -3
  7. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +9 -15
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +8 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +483 -34
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
  15. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
  16. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
  17. package/mcp/entwurf-bridge/src/index.ts +10 -12
  18. package/package.json +7 -7
  19. package/pi-extensions/entwurf-control.ts +12 -12
  20. package/pi-extensions/lib/acp/models.ts +12 -12
  21. package/pi-extensions/lib/entwurf-fact-provider.ts +9 -2
  22. package/pi-extensions/lib/entwurf-v2-production.ts +8 -2
  23. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +8 -2
  24. package/pi-extensions/lib/meta-sender-identity.ts +15 -5
  25. package/pi-extensions/lib/meta-session.ts +526 -38
  26. package/pi-extensions/meta-bridge-hook.ts +8 -2
  27. package/run.sh +30 -26
  28. package/scripts/agy-imprint.ts +15 -1
  29. package/scripts/check-acp-session-reuse.ts +1 -1
  30. package/scripts/check-acp-session-store.ts +3 -3
  31. package/scripts/check-agy-sender-identity.ts +83 -0
  32. package/scripts/check-entwurf-control-rpc.ts +2 -2
  33. package/scripts/check-entwurf-fact-provider.ts +9 -1
  34. package/scripts/check-entwurf-facts.ts +1 -1
  35. package/scripts/check-entwurf-mailbox-guard.ts +6 -2
  36. package/scripts/check-entwurf-resume-args.ts +6 -6
  37. package/scripts/check-entwurf-session-identity.ts +7 -6
  38. package/scripts/check-entwurf-v2-production.ts +4 -2
  39. package/scripts/check-entwurf-v2-spawn-production.ts +2 -2
  40. package/scripts/check-fresh-cut-gate.sh +305 -4
  41. package/scripts/check-meta-identity-consumers.ts +501 -1
  42. package/scripts/check-meta-listing.ts +91 -9
  43. package/scripts/check-meta-receiver-marker.ts +54 -0
  44. package/scripts/check-model-lock.ts +1 -1
  45. package/scripts/meta-bridge-fresh-cut.ts +164 -28
  46. package/scripts/pi_settings_io.py +65 -0
  47. package/scripts/register-pi-package.py +183 -37
  48. package/scripts/register-pi-provider.py +68 -10
  49. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  50. package/scripts/smoke-meta-install-state.sh +1 -1
  51. package/scripts/smoke-pi-attach.ts +7 -2
  52. package/scripts/smoke-user-scope-citizen.sh +177 -0
@@ -16,11 +16,19 @@
16
16
  * - mode "strict" throws on any error; "collect" (default) returns partial,
17
17
  * - errors sorted by filename.
18
18
  *
19
+ * #52 adds the store-WIDE half this listing can afford (it already holds every
20
+ * record): two records claiming one `nativeSessionId` are not two citizens. NEITHER
21
+ * is listed — a facts surface may not mint the ownership the certification refuses
22
+ * to mint — both become errors naming each other, and every unrelated citizen keeps
23
+ * listing. Before this, `entwurf_peers` reported a store the doctor calls
24
+ * uncertifiable as clean, which is a wrong FACT, not merely a missing guard.
25
+ *
19
26
  * Pure; entries/readRecord injected, no IO.
20
27
  */
21
28
 
22
29
  import assert from "node:assert/strict";
23
30
  import {
31
+ type ActiveStoreEntry,
24
32
  listAllMetaIdentities,
25
33
  type MetaCitizenBackend,
26
34
  type MetaIdentity,
@@ -61,10 +69,17 @@ function reader(map: Record<string, string>): (filename: string) => string {
61
69
  };
62
70
  }
63
71
 
72
+ /** Store entries from a fixture map — every name a REGULAR file unless `irregular` says
73
+ * otherwise. The listing takes kind, not bare names, so rule 1 (a symlinked record is
74
+ * refused and never followed) is enforceable on this surface too. */
75
+ function entries(map: Record<string, string>, irregular: readonly string[] = []): ActiveStoreEntry[] {
76
+ return Object.keys(map).map((filename) => ({ filename, regularFile: !irregular.includes(filename) }));
77
+ }
78
+
64
79
  // ── valid records → identities, sorted ──────────────────────────────────────
65
80
  {
66
81
  const map = { [`${GID_B}.meta.json`]: rec(GID_B), [`${GID_A}.meta.json`]: rec(GID_A) };
67
- const { identities, errors } = listAllMetaIdentities(Object.keys(map), reader(map));
82
+ const { identities, errors } = listAllMetaIdentities(entries(map), reader(map));
68
83
  ok("2 valid → 2 identities, 0 errors", identities.length === 2 && errors.length === 0);
69
84
  ok("identities sorted by gardenId", identities[0]?.gardenId === GID_A);
70
85
  }
@@ -72,7 +87,7 @@ function reader(map: Record<string, string>): (filename: string) => string {
72
87
  // ── corrupt record does NOT blind the listing ───────────────────────────────
73
88
  {
74
89
  const map = { [`${GID_A}.meta.json`]: rec(GID_A), [`${GID_B}.meta.json`]: "{ broken json" };
75
- const { identities, errors } = listAllMetaIdentities(Object.keys(map), reader(map));
90
+ const { identities, errors } = listAllMetaIdentities(entries(map), reader(map));
76
91
  ok("corrupt 1 → valid still listed (not blinded)", identities.length === 1 && identities[0]?.gardenId === GID_A);
77
92
  ok("corrupt → exactly 1 error", errors.length === 1);
78
93
  ok("error names the corrupt file", errors[0]?.filename === `${GID_B}.meta.json`);
@@ -81,7 +96,7 @@ function reader(map: Record<string, string>): (filename: string) => string {
81
96
  // ── body/filename drift → error, excluded ───────────────────────────────────
82
97
  {
83
98
  const map = { [`${GID_A}.meta.json`]: rec(GID_B) }; // filename A, body claims B
84
- const { identities, errors } = listAllMetaIdentities(Object.keys(map), reader(map));
99
+ const { identities, errors } = listAllMetaIdentities(entries(map), reader(map));
85
100
  ok("drift → excluded from identities", identities.length === 0);
86
101
  ok("drift → 1 error mentioning drift", errors.length === 1 && /drift/.test(errors[0]?.message ?? ""));
87
102
  }
@@ -92,21 +107,27 @@ function reader(map: Record<string, string>): (filename: string) => string {
92
107
  if (f === `${GID_B}.meta.json`) throw new Error("ENOENT mid-scan");
93
108
  return rec(GID_A);
94
109
  };
95
- const { identities, errors } = listAllMetaIdentities([`${GID_A}.meta.json`, `${GID_B}.meta.json`], read);
110
+ const { identities, errors } = listAllMetaIdentities(
111
+ [
112
+ { filename: `${GID_A}.meta.json`, regularFile: true },
113
+ { filename: `${GID_B}.meta.json`, regularFile: true },
114
+ ],
115
+ read,
116
+ );
96
117
  ok("reader throw → error, valid still listed", identities.length === 1 && errors.length === 1);
97
118
  }
98
119
 
99
120
  // ── non-.meta.json ignored ──────────────────────────────────────────────────
100
121
  {
101
122
  const map = { [`${GID_A}.meta.json`]: rec(GID_A), "README.txt": "x", "inbox.signal": "y" };
102
- const { identities, errors } = listAllMetaIdentities(Object.keys(map), reader(map));
123
+ const { identities, errors } = listAllMetaIdentities(entries(map), reader(map));
103
124
  ok("non-.meta.json entries ignored", identities.length === 1 && errors.length === 0);
104
125
  }
105
126
 
106
127
  // ── verbatim-or-nothing: error keyset = {filename, message} ─────────────────
107
128
  {
108
129
  const map = { [`${GID_A}.meta.json`]: "{ broken" };
109
- const { errors } = listAllMetaIdentities(Object.keys(map), reader(map));
130
+ const { errors } = listAllMetaIdentities(entries(map), reader(map));
110
131
  const keys = Object.keys(errors[0] ?? {}).sort();
111
132
  assert.deepStrictEqual(keys, ["filename", "message"], `error keyset drift: ${keys.join(",")}`);
112
133
  ok("error verbatim-or-nothing (filename + message only, no identity field)", true);
@@ -117,20 +138,81 @@ function reader(map: Record<string, string>): (filename: string) => string {
117
138
  const map = { [`${GID_A}.meta.json`]: rec(GID_A), [`${GID_B}.meta.json`]: "{ broken" };
118
139
  let threw = false;
119
140
  try {
120
- listAllMetaIdentities(Object.keys(map), reader(map), { mode: "strict" });
141
+ listAllMetaIdentities(entries(map), reader(map), { mode: "strict" });
121
142
  } catch {
122
143
  threw = true;
123
144
  }
124
145
  ok("mode strict → throw if any record unreadable", threw);
125
- const { identities } = listAllMetaIdentities(Object.keys(map), reader(map), { mode: "collect" });
146
+ const { identities } = listAllMetaIdentities(entries(map), reader(map), { mode: "collect" });
126
147
  ok("mode collect (default) → partial, no throw", identities.length === 1);
127
148
  }
128
149
 
129
150
  // ── errors sorted by filename ───────────────────────────────────────────────
130
151
  {
131
152
  const map = { [`${GID_B}.meta.json`]: "{ x", [`${GID_A}.meta.json`]: "{ y" };
132
- const { errors } = listAllMetaIdentities(Object.keys(map), reader(map));
153
+ const { errors } = listAllMetaIdentities(entries(map), reader(map));
133
154
  ok("errors sorted by filename", errors[0]?.filename === `${GID_A}.meta.json`);
134
155
  }
135
156
 
157
+ // ── #52: duplicate nativeSessionId is not two citizens ──────────────────────
158
+ {
159
+ const shared = "n-shared-conversation";
160
+ const map = {
161
+ [`${GID_A}.meta.json`]: rec(GID_A, { nativeSessionId: shared }),
162
+ [`${GID_B}.meta.json`]: rec(GID_B, { nativeSessionId: shared }),
163
+ };
164
+ const { identities, errors } = listAllMetaIdentities(entries(map), reader(map));
165
+ ok("duplicate nativeSessionId → NEITHER record is listed as a citizen", identities.length === 0);
166
+ ok("duplicate → one error per rival (both are told)", errors.length === 2);
167
+ ok(
168
+ "each error names the OTHER holder, so the pair is debuggable from either side",
169
+ (errors[0]?.message ?? "").includes(`${GID_B}.meta.json`) &&
170
+ (errors[1]?.message ?? "").includes(`${GID_A}.meta.json`),
171
+ );
172
+ ok(
173
+ "the duplicate error names the fresh-cut verb (one prescription, as everywhere else)",
174
+ errors.every((e) => e.message.includes("meta-bridge-fresh-cut")),
175
+ );
176
+ }
177
+
178
+ // ── #52: a duplicate must not blind the citizens around it ──────────────────
179
+ {
180
+ const shared = "n-shared-conversation";
181
+ const GID_C = "20260611T333333-cccccc";
182
+ const map = {
183
+ [`${GID_A}.meta.json`]: rec(GID_A, { nativeSessionId: shared }),
184
+ [`${GID_B}.meta.json`]: rec(GID_B, { nativeSessionId: shared }),
185
+ [`${GID_C}.meta.json`]: rec(GID_C),
186
+ };
187
+ const { identities, errors } = listAllMetaIdentities(entries(map), reader(map));
188
+ ok(
189
+ "an unrelated citizen still lists beside a duplicate pair (0.10 lesson holds)",
190
+ identities.length === 1 && identities[0]?.gardenId === GID_C,
191
+ );
192
+ ok("only the two rivals become errors", errors.length === 2);
193
+ }
194
+
195
+ // ── #52: strict mode refuses a store with a duplicate ───────────────────────
196
+ {
197
+ const shared = "n-shared-conversation";
198
+ const map = {
199
+ [`${GID_A}.meta.json`]: rec(GID_A, { nativeSessionId: shared }),
200
+ [`${GID_B}.meta.json`]: rec(GID_B, { nativeSessionId: shared }),
201
+ };
202
+ let threw = false;
203
+ try {
204
+ listAllMetaIdentities(entries(map), reader(map), { mode: "strict" });
205
+ } catch {
206
+ threw = true;
207
+ }
208
+ ok("mode strict → a duplicate throws like any other unreadable store", threw);
209
+ }
210
+
211
+ // ── #52: one holder is not a duplicate (no false positive) ──────────────────
212
+ {
213
+ const map = { [`${GID_A}.meta.json`]: rec(GID_A), [`${GID_B}.meta.json`]: rec(GID_B) };
214
+ const { identities, errors } = listAllMetaIdentities(entries(map), reader(map));
215
+ ok("distinct nativeSessionIds are untouched by the uniqueness pass", identities.length === 2 && errors.length === 0);
216
+ }
217
+
136
218
  console.log(`\n[check-meta-listing] ${passed} assertions ok`);
@@ -107,6 +107,60 @@ ok(
107
107
  readMetaReceiverMarker({ gardenId: ghostGarden, receiversDir: DIR, verifyOwner: false }) !== null,
108
108
  );
109
109
 
110
+ // ── the owner CLAIM, one layer above liveness (#53 A) ───────────────────────
111
+ // A marker naming pid 1 passes the pid-reuse guard on a running Linux host: init is
112
+ // still the very process the marker named, so its start-key matches. The claim is
113
+ // what is false — init owns no Claude session. Such a marker is legacy or corrupt
114
+ // residue (any pre-fix writer that was reparented to init; a foreign/damaged file);
115
+ // the one observed was a shell-form hook. Honoring it made a dead garden id read as
116
+ // an ACTIVE RECEIVER and, one surface over, left meta-bridge-fresh-cut unable to be
117
+ // unblocked by the action it prescribes (#53 A).
118
+ const initGarden = "20260614T130000-cccccc";
119
+ const initKey = processStartKey(1);
120
+ ok("this host CAN read a start key for pid 1 (the residue cells are not vacuous)", initKey !== "");
121
+ writeFileSync(
122
+ metaReceiverMarkerPath(initGarden, DIR),
123
+ `${JSON.stringify({
124
+ gardenId: initGarden,
125
+ backend: "claude-code",
126
+ nativeSessionId: "n-init",
127
+ ownerPid: 1,
128
+ ownerStartKey: initKey, // the REAL key: the pid-reuse guard would pass
129
+ ownerKind: "claude-code-cli",
130
+ armProvenance: "session-start",
131
+ updatedAt: "2026-06-10T16:03:10.000Z",
132
+ })}\n`,
133
+ );
134
+ ok(
135
+ "an init-owned marker reads as null — pid 1 cannot own a session, however live init is",
136
+ readMetaReceiverMarker({ gardenId: initGarden, receiversDir: DIR }) === null,
137
+ );
138
+ ok(
139
+ "the classifier itself still says LIVE for it — the refusal is a CLAIM rule, not a liveness one",
140
+ classifyMarkerOwner(initKey, { currentStartKey: processStartKey(1), pidExists: probePidExistence(1) }) === "live",
141
+ );
142
+ ok(
143
+ "verifyOwner:false does NOT reach past plausibility (it opts out of liveness, not of validity)",
144
+ readMetaReceiverMarker({ gardenId: initGarden, receiversDir: DIR, verifyOwner: false }) === null,
145
+ );
146
+ let initWriteRejected = false;
147
+ try {
148
+ writeMetaReceiverMarker({
149
+ gardenId: initGarden,
150
+ backend: "claude-code",
151
+ nativeSessionId: "n-init",
152
+ ownerPid: 1,
153
+ armProvenance: "session-start",
154
+ receiversDir: DIR,
155
+ });
156
+ } catch {
157
+ initWriteRejected = true;
158
+ }
159
+ ok(
160
+ "minting an init-owned receiver marker THROWS at the write boundary (no writer can reintroduce it)",
161
+ initWriteRejected,
162
+ );
163
+
110
164
  // ── armProvenance constraint ────────────────────────────────────────────────
111
165
  ok(
112
166
  "arm provenances are exactly the arm-capable events",
@@ -211,7 +211,7 @@ async function makeHarness(opts: MakeHarnessOpts = {}): Promise<Harness> {
211
211
  const NATIVE_A: ModelLike = { provider: "openai-codex", id: "gpt-5.4" };
212
212
  const NATIVE_B: ModelLike = { provider: "openai-codex", id: "gpt-5.5" };
213
213
  const PSA_SONNET: ModelLike = { provider: "entwurf", id: "claude-sonnet-5" };
214
- const PSA_OPUS: ModelLike = { provider: "entwurf", id: "claude-opus-4-8" };
214
+ const PSA_OPUS: ModelLike = { provider: "entwurf", id: "claude-opus-5" };
215
215
 
216
216
  let passed = 0;
217
217
  let failed = 0;
@@ -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,65 @@
1
+ """pi_settings_io — the shared serializer for the pi settings.json files this repo WRITES.
2
+
3
+ Both Python writers touch the SAME file (`<repo>/.pi/settings.json` at project scope,
4
+ `~/.pi/agent/settings.json` at user scope):
5
+
6
+ - register-pi-package.py → packages[]
7
+ - register-pi-provider.py → entwurfProvider.mcpServers.entwurf-bridge
8
+
9
+ A settings file can be tracked and owned by a formatter (this repo's own is: tab
10
+ indented, biome-governed, pinned byte-for-byte by check-install-surface S7b–S7d), so a
11
+ writer that re-serializes it in its own house style turns `install` into a source edit.
12
+ #53 B closed that for the packages writer and left the provider writer open — the same
13
+ file, the same defect, one function call away. The rules therefore live HERE rather than
14
+ in either script, because a rule that has to be remembered at each call site is exactly
15
+ what shipped the bug twice:
16
+
17
+ 1. NO WRITE WHEN NOTHING CHANGES. The strongest idempotence is an untouched file —
18
+ same bytes, same mtime. Compare the mutated document to the one that was loaded and
19
+ skip the write when they are equal; only the caller knows what "changed" means, so
20
+ each one asks `unchanged` and decides.
21
+ 2. A GENUINE REWRITE KEEPS THE FILE'S OWN INDENT UNIT. Narrower than it sounds: this
22
+ preserves the indent UNIT, not a formatter's line-collapsing decisions, so a rewrite
23
+ is NOT guaranteed to come back formatter-clean. Byte identity comes from rule 1.
24
+
25
+ Ships inside `scripts/` (package.json `files` carries the directory whole, with
26
+ `__pycache__`/`*.pyc` excluded), and both importers add their own directory to sys.path
27
+ so the import holds however they are invoked.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+
35
+ # The first indented line's leading whitespace is the file's unit. `\S` so a blank or
36
+ # whitespace-only line never answers for the document.
37
+ _INDENT = re.compile(r"\n([ \t]+)\S")
38
+
39
+
40
+ def detect_indent(text: str | None) -> str | int:
41
+ """The file's own indent unit — a tab, or N spaces. Defaults to 2 for a new/one-line
42
+ file, which is what both writers produced before they knew to ask."""
43
+ if not text:
44
+ return 2
45
+ m = _INDENT.search(text)
46
+ if not m:
47
+ return 2
48
+ unit = m.group(1)
49
+ return "\t" if unit[0] == "\t" else len(unit)
50
+
51
+
52
+ def dumps(data: dict, indent: str | int) -> str:
53
+ """Serialize with the given unit and exactly one trailing newline."""
54
+ return json.dumps(data, indent=indent) + "\n"
55
+
56
+
57
+ def unchanged(before: dict, after: dict) -> bool:
58
+ """Would writing `after` change what the file MEANS?
59
+
60
+ Value equality, not byte equality, and deliberately so: dict `==` ignores key ORDER,
61
+ so a document whose keys merely moved is reported unchanged and the operator's own
62
+ ordering survives. The caller must pass a `before` taken BEFORE any mutation — both
63
+ writers mutate in place, so they parse the raw text twice rather than aliasing it.
64
+ """
65
+ return before == after