@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.
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +20 -1
- package/DELIVERY.md +1 -1
- package/README.md +122 -10
- package/demo/README.md +2 -2
- package/docs/setup-clean-host.md +14 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +9 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +8 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +483 -34
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
- package/mcp/entwurf-bridge/src/index.ts +10 -12
- package/package.json +7 -7
- package/pi-extensions/entwurf-control.ts +12 -12
- package/pi-extensions/lib/acp/models.ts +12 -12
- package/pi-extensions/lib/entwurf-fact-provider.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +8 -2
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +8 -2
- package/pi-extensions/lib/meta-sender-identity.ts +15 -5
- package/pi-extensions/lib/meta-session.ts +526 -38
- package/pi-extensions/meta-bridge-hook.ts +8 -2
- package/run.sh +30 -26
- package/scripts/agy-imprint.ts +15 -1
- package/scripts/check-acp-session-reuse.ts +1 -1
- package/scripts/check-acp-session-store.ts +3 -3
- package/scripts/check-agy-sender-identity.ts +83 -0
- package/scripts/check-entwurf-control-rpc.ts +2 -2
- package/scripts/check-entwurf-fact-provider.ts +9 -1
- package/scripts/check-entwurf-facts.ts +1 -1
- package/scripts/check-entwurf-mailbox-guard.ts +6 -2
- package/scripts/check-entwurf-resume-args.ts +6 -6
- package/scripts/check-entwurf-session-identity.ts +7 -6
- package/scripts/check-entwurf-v2-production.ts +4 -2
- package/scripts/check-entwurf-v2-spawn-production.ts +2 -2
- package/scripts/check-fresh-cut-gate.sh +305 -4
- package/scripts/check-meta-identity-consumers.ts +501 -1
- package/scripts/check-meta-listing.ts +91 -9
- package/scripts/check-meta-receiver-marker.ts +54 -0
- package/scripts/check-model-lock.ts +1 -1
- package/scripts/meta-bridge-fresh-cut.ts +164 -28
- package/scripts/pi_settings_io.py +65 -0
- package/scripts/register-pi-package.py +183 -37
- package/scripts/register-pi-provider.py +68 -10
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +1 -1
- package/scripts/smoke-pi-attach.ts +7 -2
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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-
|
|
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(
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 — `
|
|
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(
|
|
408
|
-
if (args.length > 0) usage(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|