@junghanacs/entwurf 0.16.1 → 0.17.2
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 -2
- package/CHANGELOG.md +333 -0
- package/VERIFY.md +8 -1
- package/docs/acp-backend-rail.md +25 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +15 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +18 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +23 -7
- package/package.json +4 -4
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
- package/pi-extensions/lib/acp/acp-client.ts +57 -4
- package/pi-extensions/lib/acp/backend-adapter.ts +59 -0
- package/pi-extensions/lib/acp/backend.ts +453 -11
- package/pi-extensions/lib/acp/event-mapper.ts +43 -6
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
- package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
- package/pi-extensions/lib/entwurf-v2-surface.ts +18 -3
- package/pi-extensions/lib/meta-session.ts +32 -7
- package/run.sh +21 -0
- package/scripts/check-acp-stop-reason.ts +8 -2
- package/scripts/check-acp-usage-accounting.ts +1074 -0
- package/scripts/check-entwurf-v2-mailbox.ts +33 -0
- package/scripts/check-entwurf-v2-runner.ts +18 -0
- package/scripts/check-entwurf-v2-send.ts +13 -1
- package/scripts/check-entwurf-v2-surface.ts +75 -3
- package/scripts/check-gate-qualification.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +70 -1
- package/scripts/check-mailbox-receipt-state.ts +6 -0
- package/scripts/check-meta-doctor-oracle.sh +73 -0
- package/scripts/check-meta-mailbox-state-write.ts +9 -2
- package/scripts/check-meta-manifest-schema.py +19 -1
- package/scripts/check-meta-session.ts +10 -2
- package/scripts/lib/pi-record-discovery.ts +47 -0
- package/scripts/meta-bridge-doctor.sh +18 -3
- package/scripts/meta-bridge-state.py +23 -5
- package/scripts/meta-bridge-statusline.sh +65 -2
- package/scripts/mutants/acp-stream-hooks.json +4 -2
- package/scripts/mutants/acp-usage-accounting.json +181 -0
- package/scripts/raw-async-delivery/README.md +280 -9
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
- package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
- package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
- package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +2 -2
- package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
- package/scripts/smoke-meta-async-drift.sh +31 -8
- package/scripts/smoke-meta-install-state.sh +170 -11
- package/scripts/smoke-meta-keyset-guard.sh +4 -1
- package/scripts/smoke-mux-lifecycle-live.ts +46 -2
|
@@ -158,6 +158,32 @@ function recordingEnqueue(): {
|
|
|
158
158
|
ok("6: successful enqueue → {success:true}", res.success === true && res.error === undefined);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// ── 6b. #98 R: the enqueue result's messagePath is the SEND receipt ──────────
|
|
162
|
+
// Reported VERBATIM from the enqueue result, never re-derived from the plan — a
|
|
163
|
+
// re-derived path could disagree with the file that was actually written.
|
|
164
|
+
{
|
|
165
|
+
const enq = recordingEnqueue();
|
|
166
|
+
const res = executeMetaMailboxSend(mailboxPlan(), SENDER, { enqueue: enq.fn });
|
|
167
|
+
ok("6b: result carries the enqueue's messagePath verbatim", res.messagePath === `/fake/mailbox/${GID}/m.msg`);
|
|
168
|
+
}
|
|
169
|
+
{
|
|
170
|
+
// The path is taken from the RESULT, not from `plan.mailboxDir` + gardenId: an enqueue
|
|
171
|
+
// that lands somewhere else (env override, tilde expansion, a resolved symlink) must be
|
|
172
|
+
// reported where it actually landed.
|
|
173
|
+
const res = executeMetaMailboxSend(mailboxPlan(), SENDER, {
|
|
174
|
+
enqueue: (opts) => ({
|
|
175
|
+
gardenId: opts.gardenId,
|
|
176
|
+
recordPath: "/elsewhere/rec.json",
|
|
177
|
+
messagePath: "/elsewhere/queued-here.msg",
|
|
178
|
+
signalPath: "/elsewhere/inbox.signal",
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
ok(
|
|
182
|
+
"6b: a messagePath outside the plan's mailboxDir is still reported as-is",
|
|
183
|
+
res.messagePath === "/elsewhere/queued-here.msg",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
161
187
|
// ── 7. production adapter: ignores lock entirely (poison LockClaim) ───────────
|
|
162
188
|
{
|
|
163
189
|
const enq = recordingEnqueue();
|
|
@@ -223,6 +249,13 @@ function recordingEnqueue(): {
|
|
|
223
249
|
for (const forbidden of ["releaseLock", "inspectSocket", "probeSocket", "resolveDispatch", "resolveTarget"]) {
|
|
224
250
|
ok(`9: lib code has no '${forbidden}' (no release / no routing seam)`, !code.includes(forbidden));
|
|
225
251
|
}
|
|
252
|
+
// #98 R, the negative half stated as an assertion: a SEND receipt may never carry a
|
|
253
|
+
// read/delivery stamp. At enqueue time `lastReadAt` holds the PREVIOUS message's read,
|
|
254
|
+
// so carrying it here would let a sender read "my message was read" off a stamp that
|
|
255
|
+
// says nothing of the kind. The per-message read receipt is the `.read` suffix.
|
|
256
|
+
for (const forbidden of ["lastReadAt", "lastDeliveredAt", "readAt"]) {
|
|
257
|
+
ok(`9: lib code never carries '${forbidden}' into the send receipt`, !code.includes(forbidden));
|
|
258
|
+
}
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
console.log(`\ncheck-entwurf-v2-mailbox: ${passed} checks passed`);
|
|
@@ -212,6 +212,24 @@ async function main(): Promise<void> {
|
|
|
212
212
|
"4: executed{meta-mailbox, success}",
|
|
213
213
|
res.kind === "executed" && res.outcome.transport === "meta-mailbox" && res.outcome.success === true,
|
|
214
214
|
);
|
|
215
|
+
// #98 R: a dep that reports no path leaves the outcome's receipt undefined. The
|
|
216
|
+
// runner must not invent one — a guessed path is worse than no path.
|
|
217
|
+
ok(
|
|
218
|
+
"4: no enqueue receipt from the dep → messagePath stays undefined (never invented)",
|
|
219
|
+
res.kind === "executed" && res.outcome.transport === "meta-mailbox" && res.outcome.messagePath === undefined,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── 4a: #98 R — the mailbox enqueue receipt is carried through verbatim ───
|
|
224
|
+
{
|
|
225
|
+
const { deps } = makeDeps({ mailbox: { result: { success: true, messagePath: "/fake/mailbox/g/2026.msg" } } });
|
|
226
|
+
const res = await executeDispatch(executeDecision(MAILBOX_PLAN, null), deps);
|
|
227
|
+
ok(
|
|
228
|
+
"4a: executed{meta-mailbox} carries the dep's messagePath verbatim",
|
|
229
|
+
res.kind === "executed" &&
|
|
230
|
+
res.outcome.transport === "meta-mailbox" &&
|
|
231
|
+
res.outcome.messagePath === "/fake/mailbox/g/2026.msg",
|
|
232
|
+
);
|
|
215
233
|
}
|
|
216
234
|
|
|
217
235
|
// ── 4b: native-push execute → sendNativePush(plan, null), lock-free (봉인 4) ──
|
|
@@ -213,6 +213,9 @@ async function main(): Promise<void> {
|
|
|
213
213
|
fallbackSend: { result: { success: true } },
|
|
214
214
|
});
|
|
215
215
|
ok("dead → re-resolve(control) success → fallback-sent", result.outcome === "fallback-sent");
|
|
216
|
+
// A socket retry hands the body to a live receiver and writes no file: no receipt to
|
|
217
|
+
// invent (#98 R is a per-FILE identifier, not a per-delivery one).
|
|
218
|
+
ok("dead → socket retry carries NO messagePath (no file exists)", result.messagePath === undefined);
|
|
216
219
|
ok("dead → deadFallback called exactly once", trace.deadFallbackCalls === 1);
|
|
217
220
|
ok("dead → re-resolve used the alt socket", trace.socketSends[1]?.socketPath === "/fake/ctl/alt.sock");
|
|
218
221
|
ok("dead → release ×1", trace.releases.length === 1);
|
|
@@ -277,9 +280,16 @@ async function main(): Promise<void> {
|
|
|
277
280
|
const sent = await run({
|
|
278
281
|
firstSend: { throwCode: "ECONNREFUSED" },
|
|
279
282
|
deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
|
|
280
|
-
fallbackSend: { result: { success: true } },
|
|
283
|
+
fallbackSend: { result: { success: true, messagePath: "/fake/mb/gid/2026-fallback.msg" } },
|
|
281
284
|
});
|
|
282
285
|
ok("dead → re-resolve(mailbox) enqueue → fallback-sent", sent.result.outcome === "fallback-sent");
|
|
286
|
+
// #98 R, fallback leg: this enqueue wrote a file, so the sender gets its name — the
|
|
287
|
+
// same per-message receipt the primary mailbox rail hands back. Dropping it here was
|
|
288
|
+
// the one mailbox delivery with no identifier.
|
|
289
|
+
ok(
|
|
290
|
+
"dead → mailbox fallback carries the #98 R messagePath",
|
|
291
|
+
sent.result.messagePath === "/fake/mb/gid/2026-fallback.msg",
|
|
292
|
+
);
|
|
283
293
|
ok("dead → mailbox helper called once", sent.trace.mailboxSends.length === 1);
|
|
284
294
|
ok("dead → mailbox reached only via resolver, release ×1", sent.trace.releases.length === 1);
|
|
285
295
|
|
|
@@ -289,6 +299,8 @@ async function main(): Promise<void> {
|
|
|
289
299
|
fallbackSend: { result: { success: false } },
|
|
290
300
|
});
|
|
291
301
|
ok("dead → mailbox enqueue success:false → rejected", refused.result.outcome === "rejected");
|
|
302
|
+
// No file was written, so there is nothing to name — never echo a dep's stray path.
|
|
303
|
+
ok("dead → rejected enqueue carries NO messagePath", refused.result.messagePath === undefined);
|
|
292
304
|
}
|
|
293
305
|
|
|
294
306
|
// ── 9: single-release across every outcome (release at most once) ─────────
|
|
@@ -361,20 +361,92 @@ async function main(): Promise<void> {
|
|
|
361
361
|
cr.isError && cr.text.includes("dormant-fire-forget-unsupported"),
|
|
362
362
|
);
|
|
363
363
|
|
|
364
|
+
// control-socket fallback-sent that re-resolved to the MAILBOX: same file, same
|
|
365
|
+
// receipt. Before this the fallback leg was the one mailbox delivery whose sender
|
|
366
|
+
// line carried no per-message identifier.
|
|
367
|
+
const fallback: EntwurfV2RunResult = {
|
|
368
|
+
kind: "executed",
|
|
369
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "control-socket" },
|
|
370
|
+
transport: "control-socket",
|
|
371
|
+
outcome: {
|
|
372
|
+
transport: "control-socket",
|
|
373
|
+
outcome: "fallback-sent",
|
|
374
|
+
messagePath: "/home/x/.pi/agent/meta-mailbox/20260903T134455-e55e87/2026-09-03T09-58-02-114Z-ab12cd.msg",
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
const fb = renderEntwurfV2Result(fallback);
|
|
378
|
+
ok("2: fallback-sent is a delivery (not an error)", !fb.isError && fb.text.includes("fallback-sent"));
|
|
379
|
+
ok(
|
|
380
|
+
"2: fallback-sent names the enqueued FILE (#98 R, fallback leg)",
|
|
381
|
+
fb.text.includes("2026-09-03T09-58-02-114Z-ab12cd.msg"),
|
|
382
|
+
);
|
|
383
|
+
ok(
|
|
384
|
+
"2: fallback-sent prints the basename, not the whole path",
|
|
385
|
+
!fb.text.includes("/home/x/") && !fb.text.includes("meta-mailbox/20260903T134455-e55e87"),
|
|
386
|
+
);
|
|
387
|
+
ok(
|
|
388
|
+
"2: fallback-sent carries NO read stamp (same ban as the primary rail)",
|
|
389
|
+
!/lastReadAt|lastDeliveredAt/i.test(fb.text),
|
|
390
|
+
);
|
|
391
|
+
// A socket-to-socket retry writes no file. No receipt, and no "(enqueued undefined)".
|
|
392
|
+
const fallbackSocket: EntwurfV2RunResult = {
|
|
393
|
+
kind: "executed",
|
|
394
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "control-socket" },
|
|
395
|
+
transport: "control-socket",
|
|
396
|
+
outcome: { transport: "control-socket", outcome: "fallback-sent" },
|
|
397
|
+
};
|
|
398
|
+
const fbs = renderEntwurfV2Result(fallbackSocket);
|
|
399
|
+
ok(
|
|
400
|
+
"2: socket-retry fallback-sent degrades to the bare outcome (no file to name)",
|
|
401
|
+
fbs.text === "entwurf_v2 control-socket → fallback-sent" && !fbs.isError,
|
|
402
|
+
);
|
|
403
|
+
|
|
364
404
|
// The two spawn-bg render cells (lock-retained / socket-alive) were deleted with the
|
|
365
405
|
// transport itself. Nothing renders them anymore, and `EntwurfV2Transport` no longer
|
|
366
406
|
// admits the literal — a cell kept here would only prove the type is still wrong.
|
|
367
407
|
|
|
368
|
-
// meta-mailbox → enqueued
|
|
408
|
+
// meta-mailbox → enqueued, carrying the #98 R send receipt (the enqueued FILE).
|
|
369
409
|
const mailbox: EntwurfV2RunResult = {
|
|
410
|
+
kind: "executed",
|
|
411
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "meta-mailbox" },
|
|
412
|
+
transport: "meta-mailbox",
|
|
413
|
+
outcome: {
|
|
414
|
+
transport: "meta-mailbox",
|
|
415
|
+
success: true,
|
|
416
|
+
messagePath: "/home/x/.pi/agent/meta-mailbox/20260903T134455-e55e87/2026-09-03T07-19-31-803Z-113443.msg",
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
const mb = renderEntwurfV2Result(mailbox);
|
|
420
|
+
ok("2: meta-mailbox → not error + enqueued", !mb.isError && mb.text.includes("enqueued"));
|
|
421
|
+
ok(
|
|
422
|
+
"2: meta-mailbox names the enqueued message FILE (#98 R send receipt)",
|
|
423
|
+
mb.text.includes("2026-09-03T07-19-31-803Z-113443.msg"),
|
|
424
|
+
);
|
|
425
|
+
// The basename alone — printing the whole path would put the operator's home and the
|
|
426
|
+
// target garden id (which the caller just typed) into every send line.
|
|
427
|
+
ok(
|
|
428
|
+
"2: meta-mailbox prints the basename, not the whole path",
|
|
429
|
+
!mb.text.includes("/home/x/") && !mb.text.includes("meta-mailbox/20260903T134455-e55e87"),
|
|
430
|
+
);
|
|
431
|
+
// The assertion #98 asks for BY NAME: an enqueue receipt must never carry a read
|
|
432
|
+
// stamp. At enqueue time `lastReadAt` is the PREVIOUS message's read, so surfacing it
|
|
433
|
+
// would read as "my message was read" — the misreading that opened the issue.
|
|
434
|
+
ok(
|
|
435
|
+
"2: meta-mailbox carries NO read stamp (lastReadAt is forbidden on a send receipt)",
|
|
436
|
+
!/lastReadAt|lastDeliveredAt|\bread\b/i.test(mb.text),
|
|
437
|
+
);
|
|
438
|
+
// A dep that hands back no receipt must degrade to the old literal — never
|
|
439
|
+
// "enqueued (undefined)". The delivery happened either way.
|
|
440
|
+
const mailboxNoReceipt: EntwurfV2RunResult = {
|
|
370
441
|
kind: "executed",
|
|
371
442
|
receipt: { ...SUCCESS_RECEIPT, transport: "meta-mailbox" },
|
|
372
443
|
transport: "meta-mailbox",
|
|
373
444
|
outcome: { transport: "meta-mailbox", success: true },
|
|
374
445
|
};
|
|
446
|
+
const mbn = renderEntwurfV2Result(mailboxNoReceipt);
|
|
375
447
|
ok(
|
|
376
|
-
"2: meta-mailbox
|
|
377
|
-
!
|
|
448
|
+
"2: meta-mailbox with no receipt degrades to the bare literal (never 'undefined')",
|
|
449
|
+
!mbn.isError && mbn.text === "entwurf_v2 meta-mailbox → enqueued",
|
|
378
450
|
);
|
|
379
451
|
|
|
380
452
|
// native-push → delivered (no retry)
|
|
@@ -804,6 +804,7 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
|
|
|
804
804
|
"acp-prompt-lifecycle": 15,
|
|
805
805
|
"acp-stop-reason": 6,
|
|
806
806
|
"acp-stream-hooks": 10,
|
|
807
|
+
"acp-usage-accounting": 12,
|
|
807
808
|
"agy-permission": 6,
|
|
808
809
|
"bridge-boot-resume": 3,
|
|
809
810
|
"bridge-command-boot": 9,
|
|
@@ -58,7 +58,20 @@ const PLUGIN_SRC = path.join(REPO_DIR, "pi", "meta-bridge", "entwurf-meta-receiv
|
|
|
58
58
|
const LAUNCHER = path.join(PLUGIN_SRC, "scripts", "hook-launch.sh");
|
|
59
59
|
const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
|
|
60
60
|
|
|
61
|
-
type Leaf = {
|
|
61
|
+
type Leaf = {
|
|
62
|
+
type?: string;
|
|
63
|
+
command?: string;
|
|
64
|
+
args?: string[];
|
|
65
|
+
asyncRewake?: boolean;
|
|
66
|
+
timeout?: number;
|
|
67
|
+
rewakeSummary?: string;
|
|
68
|
+
rewakeMessage?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** #98 A/A″ length ceiling for the two rewake strings. The operator row is ONE line in a
|
|
72
|
+
* terminal status area; the model prefix is glued in front of the doorbell's stderr. There
|
|
73
|
+
* is no engine cap on the config path (see the lint block below), so this is ours. */
|
|
74
|
+
const REWAKE_MAX_LEN = 80;
|
|
62
75
|
const manifest = JSON.parse(readFileSync(path.join(PLUGIN_SRC, "hooks", "hooks.json"), "utf8")) as {
|
|
63
76
|
hooks: Record<string, Array<{ matcher?: string; hooks: Leaf[] }>>;
|
|
64
77
|
};
|
|
@@ -99,6 +112,62 @@ for (const event of OWNER_EVENTS) {
|
|
|
99
112
|
ok("FileChanged: timeout stays declared", typeof bell.timeout === "number" && bell.timeout > 0);
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
// ── 1b. #98 A/A″: the two rewake strings, and the lint the engine does NOT do ─
|
|
116
|
+
// WHY A LINT AT ALL. Measured on Claude 2.1.236/2.1.258/2.1.259 (#98 Phase 1): the
|
|
117
|
+
// stdout-JSON form of `rewakeSummary` is sanitised by the engine
|
|
118
|
+
// (`.trim().replace(/\s+/g," ").slice(0,cap)`), but the hooks.json CONFIG form is not —
|
|
119
|
+
// it reaches the operator's row verbatim, guarded only by the schema's `min(1)`. Two
|
|
120
|
+
// concrete failures that guard cannot see:
|
|
121
|
+
// - a value containing a newline breaks the one-row status area;
|
|
122
|
+
// - a whitespace-only value passes `min(1)` and draws a BLANK row, which is worse than
|
|
123
|
+
// the default `Stop hook feedback` because it looks like nothing arrived.
|
|
124
|
+
// (A third, `<summary>` missing entirely → the whole notification is hidden, is covered
|
|
125
|
+
// by asserting the field is present and non-empty at all.)
|
|
126
|
+
{
|
|
127
|
+
const bell = leafOf("FileChanged");
|
|
128
|
+
// A″ — the model-facing prefix. It REPLACES `Stop hook blocking error from command "…":`,
|
|
129
|
+
// so without it every arriving letter is named an error in the transcript.
|
|
130
|
+
ok(
|
|
131
|
+
"FileChanged: rewakeMessage is declared (model wakes to mail, not to a 'blocking error')",
|
|
132
|
+
typeof bell.rewakeMessage === "string",
|
|
133
|
+
);
|
|
134
|
+
// A — the operator-facing row. Static by nature: the renderer draws this ONE string, so
|
|
135
|
+
// it cannot carry the unread count (the statusline badge does that half).
|
|
136
|
+
ok(
|
|
137
|
+
"FileChanged: rewakeSummary is declared (operator row is not 'Stop hook feedback')",
|
|
138
|
+
typeof bell.rewakeSummary === "string",
|
|
139
|
+
);
|
|
140
|
+
for (const field of ["rewakeSummary", "rewakeMessage"] as const) {
|
|
141
|
+
const value = bell[field];
|
|
142
|
+
ok(
|
|
143
|
+
`FileChanged: ${field} is a single line (a newline would break the one-row status area)`,
|
|
144
|
+
typeof value === "string" && !/[\r\n]/.test(value),
|
|
145
|
+
);
|
|
146
|
+
ok(
|
|
147
|
+
`FileChanged: ${field} is not whitespace-only (min(1) passes a blank row; we must not)`,
|
|
148
|
+
typeof value === "string" && value.trim().length > 0,
|
|
149
|
+
);
|
|
150
|
+
ok(
|
|
151
|
+
`FileChanged: ${field} is <= ${REWAKE_MAX_LEN} chars (unsanitised config path, no engine cap)`,
|
|
152
|
+
typeof value === "string" && value.length <= REWAKE_MAX_LEN,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
// Prefix hygiene (#98 (b)): the model body is `rewakeMessage + " " + stderr`, and the
|
|
156
|
+
// doorbell's stderr ALREADY opens with `[entwurf inbox]`. A bracketed prefix here would
|
|
157
|
+
// render `[entwurf …] [entwurf inbox] …` — one tag per line, and the stderr owns it.
|
|
158
|
+
ok(
|
|
159
|
+
"FileChanged: rewakeMessage carries no bracket tag (doorbell stderr already opens with [entwurf inbox])",
|
|
160
|
+
typeof bell.rewakeMessage === "string" && !bell.rewakeMessage.includes("["),
|
|
161
|
+
);
|
|
162
|
+
// The doorbell's own opening tag, read from the shipped script rather than assumed —
|
|
163
|
+
// if someone changes the stderr tag, the no-duplicate-prefix rule above must be re-read.
|
|
164
|
+
const doorbellSrc = readFileSync(path.join(PLUGIN_SRC, "scripts", "doorbell.sh"), "utf8");
|
|
165
|
+
ok(
|
|
166
|
+
"doorbell stderr still opens with the [entwurf inbox] tag the prefix rule assumes",
|
|
167
|
+
doorbellSrc.includes('echo "[entwurf inbox]'),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
102
171
|
// ── 2. no shell-form regression anywhere in the shipped manifest ─────────────
|
|
103
172
|
// The retired form is recognizable by its carrier; assert the whole file is free of
|
|
104
173
|
// it rather than only the leaves we happen to read above.
|
|
@@ -120,6 +120,12 @@ try {
|
|
|
120
120
|
now: new Date("2026-03-01T12:05:00.000Z"),
|
|
121
121
|
});
|
|
122
122
|
ok("stamp lastReadAt: state.json created on first stamp", fs.existsSync(mailboxReceiptStatePath(mailboxDir, GID)));
|
|
123
|
+
// The two `lastDeliveredAt === null` pins below are FIELD-ISOLATION pins, not a
|
|
124
|
+
// "waiting for the doorbell" state. #98 5a: no writer stamps lastDeliveredAt at all —
|
|
125
|
+
// the shipped doorbell.sh does not touch state.json — so this null is the field's
|
|
126
|
+
// permanent value. state.json holds garden-wide last-activity; the per-message receipts
|
|
127
|
+
// are the file suffixes (`.msg` → `.msg.delivered` → `.msg.delivered.read`). Removing
|
|
128
|
+
// the field is a migration over existing on-disk v1 states (#98 5b), not a comment fix.
|
|
123
129
|
ok(
|
|
124
130
|
"stamp lastReadAt: only lastReadAt set, others null (읽음이 남는다)",
|
|
125
131
|
afterRead.lastReadAt === "2026-03-01T12:05:00.000Z" &&
|
|
@@ -631,6 +631,79 @@ else
|
|
|
631
631
|
fi
|
|
632
632
|
unset FAKE_MCP_TAIL
|
|
633
633
|
|
|
634
|
+
# M14 — a MANAGED SETTINGS SCALAR drifted. Added with #94, which retired the two
|
|
635
|
+
# compaction keys and so deleted two of this doctor's drift cells. Until now the
|
|
636
|
+
# mutation set planted only hook/cache/receiver/MCP/delivery defects: not one cell
|
|
637
|
+
# asked whether the managed-scalar sweep still detects anything at all. That made
|
|
638
|
+
# "retiring these two costs no detection power" an unprovable claim — the gate
|
|
639
|
+
# could not have noticed if the whole sweep had gone silent. `verbose` stands in
|
|
640
|
+
# for the ten scalars that remain owned; it is the cheapest of them and carries no
|
|
641
|
+
# policy weight of its own.
|
|
642
|
+
python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
|
|
643
|
+
import json, sys
|
|
644
|
+
p = sys.argv[1]
|
|
645
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
646
|
+
d["verbose"] = True
|
|
647
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
648
|
+
PY
|
|
649
|
+
expect_red "a managed settings scalar (verbose) drifted" "settings verbose missing/drifted at verbose"
|
|
650
|
+
python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
|
|
651
|
+
import json, sys
|
|
652
|
+
p = sys.argv[1]
|
|
653
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
654
|
+
d["verbose"] = False
|
|
655
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
656
|
+
PY
|
|
657
|
+
|
|
658
|
+
# M15 (negative) — the same sweep must NOT claim a RETIRED key. Planted here is the
|
|
659
|
+
# EXACT state oracle sat in on 2026-09-02, the one that opened #94: Claude Code had
|
|
660
|
+
# put `autoCompactEnabled` back to true while entwurf's `env.DISABLE_AUTOCOMPACT`
|
|
661
|
+
# stayed "1". Under the old code that drift is what turned the doctor red; under #94
|
|
662
|
+
# both keys are the operator's and it must be green.
|
|
663
|
+
#
|
|
664
|
+
# Two things this cell will not do. It does not plant entwurf's own former desired
|
|
665
|
+
# value (`false`), which is green under BOTH codes and so could not tell "retired"
|
|
666
|
+
# apart from "still checked, message reworded". And it does not REMOVE the env key
|
|
667
|
+
# to make the state tidier: leaving "1" in place is the stronger claim, because a
|
|
668
|
+
# retired key must be ignored while still carrying the value we used to force, not
|
|
669
|
+
# merely once the operator has finished cleaning up after us.
|
|
670
|
+
cp "$CLAUDE_CONFIG_DIR/settings.json" "$TMP/settings-m15.bak"
|
|
671
|
+
python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
|
|
672
|
+
import json, sys
|
|
673
|
+
p = sys.argv[1]
|
|
674
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
675
|
+
d["autoCompactEnabled"] = True
|
|
676
|
+
d.setdefault("env", {})["DISABLE_AUTOCOMPACT"] = "1"
|
|
677
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
678
|
+
PY
|
|
679
|
+
run_doctor
|
|
680
|
+
if [ "$DOC_RC" -eq 0 ]; then
|
|
681
|
+
ok "an operator who turned compaction back ON is not drift (retired keys leave the doctor green)"
|
|
682
|
+
else
|
|
683
|
+
bad "compaction re-enabled by the operator still reddens the doctor (the keys are not really retired):"$'\n'"$(printf '%s\n' "$DOC_OUT" | grep -E '^ FAIL' | sed 's/^/ /')"
|
|
684
|
+
fi
|
|
685
|
+
cp "$TMP/settings-m15.bak" "$CLAUDE_CONFIG_DIR/settings.json"
|
|
686
|
+
|
|
687
|
+
# M16 — the ONE nudge a retired key still owes. `check()` refuses while the ledger
|
|
688
|
+
# still carries ownership entwurf has not yet relinquished, which is the state an
|
|
689
|
+
# upgraded host is in between `git pull` and `install-meta-bridge`. No other
|
|
690
|
+
# mutation reaches that branch, so without this cell the whole retirement path
|
|
691
|
+
# could stop refusing and every gate would stay green.
|
|
692
|
+
cp "$CLAUDE_CONFIG_DIR/entwurf.install-state.json" "$TMP/state-m16.bak"
|
|
693
|
+
python3 - "$CLAUDE_CONFIG_DIR/entwurf.install-state.json" <<'PY'
|
|
694
|
+
import json, sys
|
|
695
|
+
p = sys.argv[1]
|
|
696
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
697
|
+
d["files"]["settings"]["keys"]["autoCompactEnabled"] = {
|
|
698
|
+
"kind": "scalar",
|
|
699
|
+
"path": ["autoCompactEnabled"],
|
|
700
|
+
"original": {"existed": False, "value": None},
|
|
701
|
+
}
|
|
702
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
703
|
+
PY
|
|
704
|
+
expect_red "install-state still owns a retired scalar (upgrade not yet relinquished)" "install-state still owns retired scalar autoCompactEnabled"
|
|
705
|
+
cp "$TMP/state-m16.bak" "$CLAUDE_CONFIG_DIR/entwurf.install-state.json"
|
|
706
|
+
|
|
634
707
|
# --- 5. the control must still hold after every restore ----------------------
|
|
635
708
|
echo "[control] re-run after all mutations were reverted"
|
|
636
709
|
run_doctor
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
* (enqueue/read no longer touch the record — invariant ⑤);
|
|
11
11
|
* - enqueue stamps state.lastEnqueuedAt, read stamps state.lastReadAt, and the
|
|
12
12
|
* enqueue receipt survives the read (field isolation on the state store);
|
|
13
|
-
* - lastDeliveredAt is never invented by read
|
|
13
|
+
* - lastDeliveredAt is never invented by read. #98 5a corrects why: NOBODY stamps it —
|
|
14
|
+
* the shipped doorbell.sh writes nothing to state.json — so it is a reserved slot that
|
|
15
|
+
* is null on every citizen. The per-message receipts are the file SUFFIXES
|
|
16
|
+
* (`.msg` → `.msg.delivered` → `.msg.delivered.read`); state.json carries only the
|
|
17
|
+
* garden-wide last enqueue/read. Removing the field is a migration, not this gate (#98 5b);
|
|
14
18
|
* - an empty inbox is a no-op on BOTH the record (untouched) and the state
|
|
15
19
|
* (state.json never created) — invariant ⑥;
|
|
16
20
|
* - a state-store drift makes the read throw fail-loud (partial failure surfaces).
|
|
@@ -95,7 +99,10 @@ try {
|
|
|
95
99
|
const stateAfterRead = readMailboxReceiptState({ gardenId: gidA, mailboxDir });
|
|
96
100
|
ok("read: state.lastReadAt stamped", stateAfterRead.lastReadAt === RD_ISO);
|
|
97
101
|
ok("read: enqueue receipt SURVIVES the read (field isolation)", stateAfterRead.lastEnqueuedAt === ENQ_ISO);
|
|
98
|
-
ok(
|
|
102
|
+
ok(
|
|
103
|
+
"read: lastDeliveredAt never invented by read (reserved slot, no writer — see header)",
|
|
104
|
+
stateAfterRead.lastDeliveredAt === null,
|
|
105
|
+
);
|
|
99
106
|
|
|
100
107
|
// --- empty inbox: no-op on BOTH record and state --------------------------
|
|
101
108
|
const gidB = seed("n-state-b");
|
|
@@ -114,7 +114,25 @@ if hooks is not None:
|
|
|
114
114
|
# argv. The marketplace/plugin manifests below stay minimal: the closed-
|
|
115
115
|
# schema lesson that produced this gate was about a DECORATIVE key
|
|
116
116
|
# (`description`), and that lesson is untouched.
|
|
117
|
-
|
|
117
|
+
#
|
|
118
|
+
# rewakeSummary/rewakeMessage (#98, 2026-09-03) join the same category:
|
|
119
|
+
# load-bearing, not decorative. They are the ONLY way the doorbell names
|
|
120
|
+
# itself — without `rewakeSummary` the operator's row is the engine default
|
|
121
|
+
# `Stop hook feedback` (no sender, no count, no garden id), and without
|
|
122
|
+
# `rewakeMessage` the model wakes to `Stop hook blocking error from command
|
|
123
|
+
# "FileChanged":`, i.e. an arriving letter announced as an error. Both were
|
|
124
|
+
# measured to load from a local plugin's hooks.json with no first-party gate
|
|
125
|
+
# on Claude 2.1.236/2.1.258/2.1.259, and `claude plugin validate` accepts
|
|
126
|
+
# them (install-meta-bridge runs it). On a host old enough not to know them,
|
|
127
|
+
# the #51 measurement above applies unchanged: the key is accepted and the
|
|
128
|
+
# value silently dropped, so the surface degrades to today's default rather
|
|
129
|
+
# than failing the install. Their VALUES are linted (single line, non-blank,
|
|
130
|
+
# length) by check-hook-launch-topology; this gate pins only the keyset.
|
|
131
|
+
subset(
|
|
132
|
+
f"hooks.{event}[{j}].hooks[{k}]",
|
|
133
|
+
h,
|
|
134
|
+
{"type", "command", "args", "asyncRewake", "timeout", "rewakeSummary", "rewakeMessage"},
|
|
135
|
+
)
|
|
118
136
|
|
|
119
137
|
# Every hook launches through the shipped launcher in EXEC form: `command` is
|
|
120
138
|
# hook-launch.sh and the baked argv travels in `args`. No shell is on the path, so
|
|
@@ -455,8 +455,16 @@ check("readMetaInbox: drains a fresh .msg, returns the body, stamps lastReadAt (
|
|
|
455
455
|
assert.equal(read.readAt, T1.toISOString(), "readAt returned");
|
|
456
456
|
const st = readMailboxReceiptState({ gardenId: fx.gardenId, mailboxDir: fx.mailboxDir });
|
|
457
457
|
assert.equal(st.lastReadAt, T1.toISOString(), "lastReadAt stamped in mailbox state = the honest read receipt");
|
|
458
|
-
// #5 honesty:
|
|
459
|
-
|
|
458
|
+
// #5 honesty: readMetaInbox must NOT invent a delivery time. #98 5a corrects the old
|
|
459
|
+
// reason next to this pin ("the doorbell's to stamp"): the shipped doorbell.sh writes
|
|
460
|
+
// nothing to state.json, so lastDeliveredAt is a RESERVED SLOT no writer fills — this
|
|
461
|
+
// null is permanent, not "not yet". The per-message delivery fact is the `.delivered`
|
|
462
|
+
// file suffix; state.json only ever holds garden-wide last-activity.
|
|
463
|
+
assert.equal(
|
|
464
|
+
st.lastDeliveredAt,
|
|
465
|
+
null,
|
|
466
|
+
"lastDeliveredAt left null (nobody stamps it; the .delivered suffix is the per-message receipt)",
|
|
467
|
+
);
|
|
460
468
|
} finally {
|
|
461
469
|
fx.cleanup();
|
|
462
470
|
}
|
|
@@ -16,13 +16,60 @@
|
|
|
16
16
|
* runs of the acp socket smokes).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
19
20
|
import * as fsp from "node:fs/promises";
|
|
21
|
+
import * as os from "node:os";
|
|
20
22
|
import * as path from "node:path";
|
|
21
23
|
|
|
22
24
|
function sleep(ms: number): Promise<void> {
|
|
23
25
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* pi's own cross-process locks. `FileAuthStorageBackend` (pi `dist/core/auth-storage.js`) guards
|
|
30
|
+
* `auth.json` AND `models-store.json` with `proper-lockfile`, and every boot reads through it, so
|
|
31
|
+
* these two directories are the ones a killed resident can leave behind.
|
|
32
|
+
*/
|
|
33
|
+
const PI_LOCK_PATHS = ["auth.json.lock", "models-store.json.lock"].map((name) =>
|
|
34
|
+
path.join(os.homedir(), ".pi", "agent", name),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The bound a LIVE smoke must give a `pi` boot — and the reason it is NOT "how long a boot takes".
|
|
39
|
+
*
|
|
40
|
+
* MEASURED 2026-09-03 on the release host, in the smokes' own spawn shape:
|
|
41
|
+
* - an undisturbed boot → V3 record is **1008–1212ms across 80 consecutive boots** (5.1–5.4s under
|
|
42
|
+
* 4× CPU oversubscription), so a bound in the tens of seconds is not about boot cost;
|
|
43
|
+
* - pi reads its auth/models store under a `proper-lockfile` lock whose stale window is
|
|
44
|
+
* `staleMs = 30_000` (pi `dist/core/auth-storage.js`), and a contender that finds the lock held
|
|
45
|
+
* retries for exactly that long before taking it over;
|
|
46
|
+
* - SIGTERM lands inside that window often enough to matter — sweeping 24 kill offsets across a
|
|
47
|
+
* boot left `~/.pi/agent/models-store.json.lock` orphaned once (at +375ms), because the signal
|
|
48
|
+
* ends the process before `proper-lockfile`'s release ever runs;
|
|
49
|
+
* - with such an orphan present, the very next boot measured **30_148ms** (and 1_114ms immediately
|
|
50
|
+
* after, once the stale takeover had cleared it).
|
|
51
|
+
*
|
|
52
|
+
* So a 30_000 bound is the single worst value available: it expires 148ms INSIDE the takeover, and
|
|
53
|
+
* the record lands just after the smoke has stopped looking — an empty stderr, a live child, and no
|
|
54
|
+
* record in any store. That is what blocked two of the three 0.17.0 `--cut` runs on C1b, while
|
|
55
|
+
* `smoke-entwurf-chain-live` — same two-resident dance, 45_000 — passed all three. This constant is
|
|
56
|
+
* that value, shared so no smoke sits on the cliff again. Raise it if pi's `staleMs` ever grows;
|
|
57
|
+
* it must stay strictly greater than that window plus a boot.
|
|
58
|
+
*/
|
|
59
|
+
export const PI_BOOT_TIMEOUT_MS = 45_000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which pi locks are on disk right now, for a failure diagnostic — a boot that overran its bound
|
|
63
|
+
* while one of these exists overran it for a NAMED reason, not a mysterious one. Read-only: a lock
|
|
64
|
+
* is arbitrated by pi's own stale protocol and must never be deleted by a smoke, because a live
|
|
65
|
+
* holder and an orphan look identical from here.
|
|
66
|
+
*/
|
|
67
|
+
export function describePiLockResidue(): string {
|
|
68
|
+
const present = PI_LOCK_PATHS.filter((lock) => existsSync(lock));
|
|
69
|
+
if (present.length === 0) return "none held (so a boot overrun here is not the pi lock-stale window)";
|
|
70
|
+
return `${present.join(", ")} — a boot contending with this waits out pi's ${30_000}ms stale window before taking over`;
|
|
71
|
+
}
|
|
72
|
+
|
|
26
73
|
export async function waitForPiRecord(storeDir: string, timeoutMs: number, pollMs = 100): Promise<string | null> {
|
|
27
74
|
const deadline = Date.now() + timeoutMs;
|
|
28
75
|
while (Date.now() < deadline) {
|
|
@@ -468,11 +468,18 @@ if diff:
|
|
|
468
468
|
# template equality above, not from a loose per-field test. It says the wake wiring is
|
|
469
469
|
# DECLARED as shipped; it does NOT say exit-2 → wake holds at runtime (#51 B2).
|
|
470
470
|
bell = leaf("FileChanged")
|
|
471
|
+
# The two rewake strings are REPORTED, not optional: this line's whole job is to say what
|
|
472
|
+
# the wake wiring DECLARES, and since #98 the declaration includes what the operator and the
|
|
473
|
+
# model actually see when a letter lands. Omitting them would report a contract the manifest
|
|
474
|
+
# no longer fully states — the exact class of half-truth #98 exists to remove. They are safe
|
|
475
|
+
# to index: the exact-template equality above already ran, so a manifest missing either field
|
|
476
|
+
# never reaches here.
|
|
471
477
|
doorbell = (
|
|
472
478
|
f"ok FileChanged doorbell matches the shipped static contract exactly "
|
|
473
479
|
f"(command={bell['command']}, args={bell['args']}, asyncRewake={bell['asyncRewake']}, "
|
|
474
|
-
f"timeout={bell['timeout']}
|
|
475
|
-
"
|
|
480
|
+
f"timeout={bell['timeout']}, rewakeSummary={bell['rewakeSummary']!r}, "
|
|
481
|
+
f"rewakeMessage={bell['rewakeMessage']!r}); runtime exit-2 wake was observed at Claude "
|
|
482
|
+
"2.1.217 (#51 B2) but is not re-proven by this static read"
|
|
476
483
|
)
|
|
477
484
|
|
|
478
485
|
# Resolve the argv EXACTLY the way Claude does for an exec form: substitute the
|
|
@@ -563,7 +570,15 @@ PY
|
|
|
563
570
|
bad "statusline bin not on PATH: $EXPECTED_STATUSLINE"
|
|
564
571
|
fi
|
|
565
572
|
SAMPLE_STATUSLINE_OUT="$(printf '%s' '{"session_id":"doctor-no-record","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-sonnet-5"},"context_window":{"context_window_size":200000,"used_percentage":1,"current_usage":{"input_tokens":1}}}' | "$EXPECTED_STATUSLINE" 2>/dev/null || true)"
|
|
566
|
-
|
|
573
|
+
# The identity row may carry the #98 B unread badge between `cc` and the model letter:
|
|
574
|
+
# ` ✉N` when mail is waiting, nothing when the mailbox is empty, ` ✉?` when the count
|
|
575
|
+
# could not be taken. All three are healthy renders, so the shape test admits any of them.
|
|
576
|
+
if [ "$(printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | wc -l | tr -d ' ')" = "2" ] && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '1p' | grep -q 'tmp' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q '🪛' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -qE ' cc( ✉([0-9]+|\?))? \| s'; then ok "statusline synthetic execution emits two rows (work context + identity)"; else bad "statusline synthetic execution failed or omitted two-row work/identity marker"; fi
|
|
577
|
+
# The synthetic feeds a session_id with no meta-record, so the garden id does not
|
|
578
|
+
# resolve and there is no mailbox to count. `✉?` is the required answer: a statusline
|
|
579
|
+
# that drew nothing there would be claiming "no mail" about a mailbox it never opened —
|
|
580
|
+
# the exact silence #98 was opened to remove.
|
|
581
|
+
if printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q '✉?'; then ok "statusline badge distinguishes 'could not count' (✉?) from zero on the no-record synthetic"; else bad "statusline badge omitted ✉? for the no-record synthetic (a false zero would be indistinguishable from an empty mailbox)"; fi
|
|
567
582
|
else
|
|
568
583
|
bad "cannot validate statusline without python3"
|
|
569
584
|
fi
|
|
@@ -66,16 +66,24 @@ LEGACY_PERMISSION_ALLOW = ["mcp__pi-tools-bridge__*"]
|
|
|
66
66
|
|
|
67
67
|
# Claude Code single-driver policy scalars owned by entwurf for the native
|
|
68
68
|
# meta-bridge install. These are not theming/personal hooks; they close background
|
|
69
|
-
# autonomy/suggestion
|
|
70
|
-
#
|
|
69
|
+
# autonomy/suggestion surfaces so Claude Code behaves like the same single forged
|
|
70
|
+
# screwdriver that entwurf already enforces for ACP backends.
|
|
71
|
+
#
|
|
72
|
+
# COMPACTION IS NOT ONE OF THEM (#94). entwurf neither enables nor disables it on
|
|
73
|
+
# any rail, and the ACP side reached that state first: 0.5.0 really did carry a
|
|
74
|
+
# pi-side compaction surface in CODE (`session_before_compact` cancelling by
|
|
75
|
+
# default, `assertLegacyCompactionKnobUnset`, `smoke-compaction-policy`), the v2
|
|
76
|
+
# subtraction removed it (`378c682`, 2026-06-16), and `623a4ea` (06-23) then
|
|
77
|
+
# removed the documentation that had outlived the code by a week. As of #94 the
|
|
78
|
+
# native rail stops owning `autoCompactEnabled` / `env.DISABLE_AUTOCOMPACT` too.
|
|
79
|
+
# Backend-native context management is allowed; we add no guard and only measure.
|
|
80
|
+
# Both keys live in RETIRED_SETTINGS_SCALARS below.
|
|
71
81
|
MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
72
82
|
("cleanupPeriodDays", ["cleanupPeriodDays"], 365),
|
|
73
|
-
("env.DISABLE_AUTOCOMPACT", ["env", "DISABLE_AUTOCOMPACT"], "1"),
|
|
74
83
|
("promptSuggestionEnabled", ["promptSuggestionEnabled"], False),
|
|
75
84
|
("awaySummaryEnabled", ["awaySummaryEnabled"], False),
|
|
76
85
|
("autoMemoryEnabled", ["autoMemoryEnabled"], False),
|
|
77
86
|
("verbose", ["verbose"], False),
|
|
78
|
-
("autoCompactEnabled", ["autoCompactEnabled"], False),
|
|
79
87
|
("showTurnDuration", ["showTurnDuration"], False),
|
|
80
88
|
("terminalProgressBarEnabled", ["terminalProgressBarEnabled"], False),
|
|
81
89
|
("useAutoModeDuringPlan", ["useAutoModeDuringPlan"], False),
|
|
@@ -89,6 +97,14 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
|
89
97
|
# exactly once, then uninstall can no longer restore over the operator's choice.
|
|
90
98
|
RETIRED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
91
99
|
("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
|
|
100
|
+
# #94 — compaction returns to the operator. RETIRED, never deleted outright:
|
|
101
|
+
# a deleted entry would be ORPHANED in an existing install-state, and
|
|
102
|
+
# uninstall() walks every ledger entry through restore_entry(), so it would
|
|
103
|
+
# put the suppression back over an operator who had since enabled compaction.
|
|
104
|
+
# Only this list's relinquish path pops the entry so uninstall can no longer
|
|
105
|
+
# reach it.
|
|
106
|
+
("env.DISABLE_AUTOCOMPACT", ["env", "DISABLE_AUTOCOMPACT"], "1"),
|
|
107
|
+
("autoCompactEnabled", ["autoCompactEnabled"], False),
|
|
92
108
|
]
|
|
93
109
|
|
|
94
110
|
_managed_scalar_names = {name for name, _path, _desired in MANAGED_SETTINGS_SCALARS}
|
|
@@ -607,7 +623,9 @@ def check(repo: Path, asm: Path) -> None:
|
|
|
607
623
|
if existed and type(value) is type(last_managed_value) and value == last_managed_value:
|
|
608
624
|
print(
|
|
609
625
|
f"NOTE: settings {name}={json.dumps(last_managed_value)} is operator-owned; "
|
|
610
|
-
f"entwurf no longer
|
|
626
|
+
f"entwurf no longer sets or restores this value. It still carries the value "
|
|
627
|
+
f"entwurf last managed, which is not drift and is not a defect — a retired "
|
|
628
|
+
f"key has no desired value to drift from."
|
|
611
629
|
)
|
|
612
630
|
|
|
613
631
|
checks = [
|