@junghanacs/entwurf 0.14.0 → 0.14.1

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * classify-tmux-cwd — the ONE classification of a start directory that is about to be handed
3
+ * to tmux as a `-c` value. Shared leaf of the resume and fresh launch compositions; it owns
4
+ * the classification and NOTHING else — no hints (each consumer phrases its own: resume says
5
+ * "recorded cwd", fresh says "requested cwd"), no argv, no tmux, no fallback directory.
6
+ *
7
+ * Every rule below is a MEASURED tmux 3.6a behaviour (2026-08-06, private server), and each
8
+ * one is a way a launch would look successful while being wrong:
9
+ *
10
+ * 1. a NONEXISTENT `-c` is silent. tmux exits 0, opens the window, and the child falls back
11
+ * to `$HOME`. A launch whose directory has been deleted would therefore open a visible
12
+ * window in the wrong project and look successful. Nothing downstream can catch that:
13
+ * the launch receipt would be perfectly well-formed.
14
+ * 2. `-c` is FORMAT-EXPANDED. `#{pane_id}` inside the value silently rewrote the path
15
+ * (`<dir>/#{pane_id}` → `<dir>/%0`), and a `#(…)` value was observed running its
16
+ * command. A path is data; tmux reads it as a format. So `#` is refused outright.
17
+ * 3. whitespace is SAFE — argv is an array and nothing re-splits. A dir named `with space`
18
+ * arrived intact. So there is no quoting grammar here, and none is owed.
19
+ *
20
+ * That is the entire defence: one existence check and one character. No escaping layer, no
21
+ * sanitiser, no trim, no realpath/symlink policy — a symlinked project dir is a normal thing
22
+ * to work in, and a value is classified exactly as given.
23
+ */
24
+
25
+ import { statSync } from "node:fs";
26
+ import path from "node:path";
27
+
28
+ /** Why a candidate `-c` value was refused. Four stable literals — both consuming
29
+ * compositions widen their own reject unions with this type, so the strings are contract. */
30
+ export type TmuxCwdRejectReason = "cwd-not-absolute" | "cwd-format-token" | "cwd-missing" | "cwd-not-directory";
31
+
32
+ /**
33
+ * Classify a candidate start directory. Split into separate reasons rather than one because
34
+ * the operator's next move differs: an absolute-path bug is a caller defect, a missing
35
+ * directory is a moved/deleted project, and a `#` is a path tmux would rewrite under us.
36
+ */
37
+ export function classifyTmuxCwd(cwd: string): TmuxCwdRejectReason | null {
38
+ if (!path.isAbsolute(cwd)) return "cwd-not-absolute";
39
+ // tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
40
+ // was observed executing; neither is something to escape our way out of.
41
+ if (cwd.includes("#")) return "cwd-format-token";
42
+ let st: ReturnType<typeof statSync>;
43
+ try {
44
+ st = statSync(cwd);
45
+ } catch {
46
+ // tmux would NOT report this — it opens the window and lands the child in $HOME.
47
+ return "cwd-missing";
48
+ }
49
+ return st.isDirectory() ? null : "cwd-not-directory";
50
+ }
@@ -25,9 +25,28 @@
25
25
  * caller's own inbound surface. Merging them would claim knowledge this module cannot have.
26
26
  * 4. A launch with no callback is a REAL outcome, not an error to retry. No watcher, no poll,
27
27
  * no timeout supervisor. The window is visible; the operator can look.
28
+ *
29
+ * ── The optional REQUESTED cwd (issue #73) ──
30
+ *
31
+ * A fresh sibling starts wherever the caller happens to be — unless the caller names ONE
32
+ * literal start directory. That input exists so a cross-repo fresh consultation never has to
33
+ * ride `entwurf_resume_call` for a dormant record's recorded cwd: resume stays a continuity
34
+ * verb, and placement pressure stays here. The rules are deliberately narrow:
35
+ *
36
+ * - `undefined` and the exact empty string mean OMIT: no `-c` reaches tmux and the argv is
37
+ * byte-identical to the pre-#73 shape. Anything else is taken LITERALLY — no trim, no
38
+ * realpath, no project-name resolution, no store/peers/record lookup. The caller is the
39
+ * only cwd authority this module knows.
40
+ * - the value is classified by the shared `classify-tmux-cwd.ts` leaf BEFORE any mutation
41
+ * (same four stable reasons as resume; the measured tmux 3.6a facts live on that leaf).
42
+ * This module's hints phrase them as the REQUESTED cwd; resume's say RECORDED.
43
+ * - the receipt echoes what was REQUESTED, exactly as `runtimePath` does. It never reports
44
+ * `pane_current_path`: proving where the pane actually landed belongs to acceptance, not
45
+ * to the launch receipt.
28
46
  */
29
47
 
30
48
  import { randomBytes } from "node:crypto";
49
+ import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
31
50
  import {
32
51
  assertLaunchTarget,
33
52
  LaunchPreconditionError,
@@ -142,10 +161,12 @@ export function buildFreshCallPrompt(params: {
142
161
  }
143
162
 
144
163
  /** A launch that was refused, or a placement that could not be established. Every value is a
145
- * NAMED refusal — this module has no fallback launch. */
164
+ * NAMED refusal — this module has no fallback launch and no fallback directory. The cwd members
165
+ * come from the shared classification leaf and their string values are stable contract. */
146
166
  export type FreshCallRejectReason =
147
167
  | PlacementRejectReason
148
168
  | LaunchRejectReason
169
+ | TmuxCwdRejectReason
149
170
  | "caller-identity-unavailable"
150
171
  | "model-empty"
151
172
  | "model-invalid"
@@ -158,6 +179,10 @@ export type FreshCallRejectReason =
158
179
  export interface FreshCallReceipt extends WindowHandle {
159
180
  backend: FreshCallBackend;
160
181
  model: string;
182
+ /** The REQUESTED start directory — present only when the caller supplied one. The same kind
183
+ * of fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
184
+ * landed. */
185
+ cwd?: string;
161
186
  runtimePath: string;
162
187
  nonce: string;
163
188
  }
@@ -174,20 +199,28 @@ function defaultRandomHex(): string {
174
199
  return randomBytes(12).toString("hex");
175
200
  }
176
201
 
177
- /** Launch argv: the leaf's detached-append shape, the runtime, then the backend's dialect. */
202
+ /** Launch argv: the leaf's detached-append shape, optionally `-c` at the resume-symmetric token
203
+ * position (after `-t`, before `-P -F`), the runtime, then the backend's dialect. An omitted cwd
204
+ * yields the exact pre-#73 argv — no carrier at all. */
178
205
  export function buildFreshCallArgs(
179
206
  placement: Placement,
180
207
  runtimePath: string,
181
208
  backendArgs: readonly string[],
209
+ cwd?: string,
182
210
  ): string[] {
183
211
  assertSelector("session", placement.sessionId);
184
212
  assertLaunchTarget(runtimePath);
213
+ if (cwd !== undefined) {
214
+ const bad = classifyTmuxCwd(cwd);
215
+ if (bad) throw new Error(`mux-fresh-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
216
+ }
185
217
  return [
186
218
  "new-window",
187
219
  "-d",
188
220
  "-a",
189
221
  "-t",
190
222
  `${placement.sessionId}:{end}`,
223
+ ...(cwd === undefined ? [] : ["-c", cwd]),
191
224
  "-P",
192
225
  "-F",
193
226
  APPEND_FORMAT,
@@ -207,7 +240,7 @@ export function buildFreshCallArgs(
207
240
  * against a store, or guesses it: an empty value is a named refusal, not a lookup.
208
241
  */
209
242
  export function freshCall(
210
- params: { backend: FreshCallBackend; model: string; task: string; callerGardenId: string | null },
243
+ params: { backend: FreshCallBackend; model: string; task: string; cwd?: string; callerGardenId: string | null },
211
244
  env: NodeJS.ProcessEnv = process.env,
212
245
  nonce: string = mintNonce(),
213
246
  ): FreshCallResult {
@@ -220,6 +253,14 @@ export function freshCall(
220
253
  const task = params.task.trim();
221
254
  if (task.length === 0) return { ok: false, reason: "task-empty" };
222
255
  if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
256
+ // ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
257
+ // value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
258
+ // classification below instead of being silently repaired into a different directory.
259
+ const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
260
+ if (cwd !== undefined) {
261
+ const badCwd = classifyTmuxCwd(cwd);
262
+ if (badCwd) return { ok: false, reason: badCwd };
263
+ }
223
264
 
224
265
  let runtimePath: string;
225
266
  try {
@@ -240,7 +281,10 @@ export function freshCall(
240
281
  callerGardenId: params.callerGardenId,
241
282
  nonce,
242
283
  });
243
- const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model)), env);
284
+ const run = runTmux(
285
+ buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model), cwd),
286
+ env,
287
+ );
244
288
  assertTmuxOk("new-window", run);
245
289
 
246
290
  let fields: ReturnType<typeof parseWindowFields>;
@@ -265,6 +309,7 @@ export function freshCall(
265
309
  ...fields,
266
310
  backend: params.backend,
267
311
  model,
312
+ ...(cwd === undefined ? {} : { cwd }),
268
313
  runtimePath,
269
314
  nonce,
270
315
  },
@@ -280,6 +325,13 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
280
325
  "anchor-mismatch": "tmux answered about a different pane than the one asked about",
281
326
  "caller-identity-unavailable":
282
327
  "this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
328
+ "cwd-not-absolute":
329
+ "the requested cwd is not an absolute path (the value is taken literally — nothing trims or resolves it)",
330
+ "cwd-format-token":
331
+ "the requested cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
332
+ "cwd-missing":
333
+ "the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
334
+ "cwd-not-directory": "the requested cwd exists but is not a directory",
283
335
  "model-empty": "model is empty after trimming; fresh calls require an explicit model",
284
336
  "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
285
337
  "task-empty": "task is empty after trimming",
@@ -314,6 +366,7 @@ export function renderFreshCall(result: FreshCallResult): { text: string; isErro
314
366
  `[entwurf fresh call →]\n` +
315
367
  ` backend: ${r.backend} (${r.runtimePath})\n` +
316
368
  ` model: ${r.model} (requested on the runtime CLI)\n` +
369
+ (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
317
370
  ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
318
371
  ` pane: ${r.paneId} pid ${r.panePid}\n` +
319
372
  ` nonce: ${r.nonce}\n` +
@@ -4,12 +4,13 @@
4
4
  *
5
5
  * ── Why this is a module and not a parameter on fresh-call ──
6
6
  *
7
- * `mux-fresh-call` carries a TASK to a runtime it names; the sibling starts wherever the caller
8
- * happens to be. A resume carries neither: the argv comes from the record (`entwurf-v2-visible-
9
- * resume` builds it) and the cwd comes from the record too it is the directory the citizen's
10
- * own transcript header remembers. Those are different inputs with a different risk, so they get
11
- * a different module rather than a fourth parameter on a composition whose contract is
12
- * "identity is an OUTPUT".
7
+ * `mux-fresh-call` carries a TASK to a runtime it names, and starts the sibling wherever the
8
+ * caller happens to be unless the caller REQUESTS one literal start directory (#73). A resume
9
+ * carries neither a task nor a caller choice: the argv comes from the record (`entwurf-v2-
10
+ * visible-resume` builds it) and the cwd comes from the record too it is the directory the
11
+ * citizen's own transcript header remembers, never something the caller picks. Those are
12
+ * different inputs with a different risk, so they get a different module rather than a fourth
13
+ * parameter on a composition whose contract is "identity is an OUTPUT".
13
14
  *
14
15
  * visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
15
16
  * resume-call -X-> garden identity, records, locks, delivery
@@ -20,22 +21,15 @@
20
21
  *
21
22
  * `mux-placement`'s `buildAppendArgs` deliberately emits no `-c` ("default shell only"), and it
22
23
  * stays that way — a resume must not widen the leaf's grammar for the three other callers. So
23
- * the `-c` shape lives here, with the three refusals MEASURED on tmux 3.6a (2026-08-06, private
24
- * server):
24
+ * the `-c` SHAPE lives here, while the classification of the value lives in the shared
25
+ * `classify-tmux-cwd.ts` leaf (fresh-call hands tmux the same flag, and a twin copy of the
26
+ * measured rules would rot apart on the next tmux hazard). The measured tmux 3.6a facts —
27
+ * a nonexistent `-c` silently lands the child in `$HOME`, `#` is format-expanded, whitespace
28
+ * is safe — are documented on that leaf. `|` is fine too: the cwd never enters the `-F` row
29
+ * (see `APPEND_FORMAT` below).
25
30
  *
26
- * 1. a NONEXISTENT `-c` is silent. tmux exits 0, opens the window, and the child falls back to
27
- * `$HOME`. A resume whose recorded cwd has been deleted would therefore open a visible
28
- * window in the wrong project and look successful. Nothing downstream can catch that: the
29
- * launch receipt would be perfectly well-formed.
30
- * 2. `-c` is FORMAT-EXPANDED. `#{pane_id}` inside the value silently rewrote the path
31
- * (`<dir>/#{pane_id}` → `<dir>/%0`), and a `#(…)` value was observed running its command.
32
- * A path is data; tmux reads it as a format. So `#` is refused outright.
33
- * 3. whitespace is SAFE — argv is an array and nothing re-splits. A dir named `with space`
34
- * arrived intact. So there is no quoting grammar here, and none is owed. `|` is fine too:
35
- * the cwd never enters the `-F` row (see `APPEND_FORMAT` below).
36
- *
37
- * That is the entire defence: one existence check and one character. No escaping layer, no
38
- * sanitiser, no symlink policy — a symlinked project dir is a normal thing to work in.
31
+ * What stays HERE is the phrasing: this module's hints say "recorded cwd", because a resume's
32
+ * directory comes from the record fresh-call's say "requested cwd" for the same reasons.
39
33
  *
40
34
  * ── What the receipt does NOT say ──
41
35
  *
@@ -47,8 +41,7 @@
47
41
  * to acceptance, not to the product's launch receipt.
48
42
  */
49
43
 
50
- import { statSync } from "node:fs";
51
- import path from "node:path";
44
+ import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
52
45
  import {
53
46
  assertLaunchTarget,
54
47
  LaunchPreconditionError,
@@ -74,14 +67,9 @@ import {
74
67
  export const RESUME_CALL_RUNTIME = "pi";
75
68
 
76
69
  /** Why a resume window could not be opened. Every value is a NAMED refusal; this module has no
77
- * fallback launch and no fallback directory. */
78
- export type ResumeCallRejectReason =
79
- | PlacementRejectReason
80
- | LaunchRejectReason
81
- | "cwd-not-absolute"
82
- | "cwd-format-token"
83
- | "cwd-missing"
84
- | "cwd-not-directory";
70
+ * fallback launch and no fallback directory. The cwd members come from the shared classification
71
+ * leaf and their string values are stable contract. */
72
+ export type ResumeCallRejectReason = PlacementRejectReason | LaunchRejectReason | TmuxCwdRejectReason;
85
73
 
86
74
  /** Coordinates plus what was handed to tmux. `cwd` is the REQUESTED start directory — the same
87
75
  * kind of fact as `runtimePath`, namely what tmux was asked for, not an observation. */
@@ -92,26 +80,6 @@ export interface ResumeCallReceipt extends WindowHandle {
92
80
 
93
81
  export type ResumeCallResult = { ok: true; receipt: ResumeCallReceipt } | { ok: false; reason: ResumeCallRejectReason };
94
82
 
95
- /**
96
- * Classify a candidate start directory. Split into three reasons rather than one because the
97
- * operator's next move differs: an absolute-path bug is a caller defect, a missing directory is
98
- * a moved/deleted project, and a `#` is a path tmux would rewrite under us.
99
- */
100
- export function classifyResumeCwd(cwd: string): ResumeCallRejectReason | null {
101
- if (!path.isAbsolute(cwd)) return "cwd-not-absolute";
102
- // tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
103
- // was observed executing; neither is something to escape our way out of.
104
- if (cwd.includes("#")) return "cwd-format-token";
105
- let st: ReturnType<typeof statSync>;
106
- try {
107
- st = statSync(cwd);
108
- } catch {
109
- // tmux would NOT report this — it opens the window and lands the child in $HOME.
110
- return "cwd-missing";
111
- }
112
- return st.isDirectory() ? null : "cwd-not-directory";
113
- }
114
-
115
83
  /**
116
84
  * Launch argv: the leaf's detached-append shape plus `-c`, the runtime, then the caller's flags.
117
85
  * `--` is what keeps tmux from reading the runtime or its flags as tmux options.
@@ -124,7 +92,7 @@ export function buildResumeCallArgs(
124
92
  ): string[] {
125
93
  assertSelector("session", placement.sessionId);
126
94
  assertLaunchTarget(runtimePath);
127
- const bad = classifyResumeCwd(cwd);
95
+ const bad = classifyTmuxCwd(cwd);
128
96
  if (bad) throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
129
97
  return [
130
98
  "new-window",
@@ -154,7 +122,7 @@ export function resumeCall(
154
122
  params: { cwd: string; runtimeArgs: readonly string[] },
155
123
  env: NodeJS.ProcessEnv = process.env,
156
124
  ): ResumeCallResult {
157
- const badCwd = classifyResumeCwd(params.cwd);
125
+ const badCwd = classifyTmuxCwd(params.cwd);
158
126
  if (badCwd) return { ok: false, reason: badCwd };
159
127
 
160
128
  let runtimePath: string;
@@ -807,8 +807,9 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
807
807
  "bridge-boot-resume": 3,
808
808
  "meta-facts": 4,
809
809
  "meta-identity": 4,
810
+ "meta-retire": 3,
810
811
  "mux-boundary": 14,
811
- "mux-fresh-call": 15,
812
+ "mux-fresh-call": 19,
812
813
  "mux-launcher-fence": 7,
813
814
  "mux-parent-artifact": 3,
814
815
  "mux-resume-call": 12,
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Every cwd claim below is a MEASURED tmux 3.6a behaviour, not a precaution. They were taken on a
10
10
  * private server on 2026-08-06, and each one is a way a resume would look successful while being
11
- * wrong:
11
+ * wrong. Since #73 the classification itself lives in the shared `classify-tmux-cwd.ts` leaf
12
+ * (fresh-call consumes the same rules), so the CWD claims here are asserted against that leaf:
12
13
  *
13
14
  * MUXRESUME-CWD-MISSING-REFUSED a nonexistent `-c` does NOT fail: tmux exits 0, opens the
14
15
  * window, and the child lands in $HOME. Nothing downstream can
@@ -31,10 +32,10 @@ import fs from "node:fs";
31
32
  import os from "node:os";
32
33
  import path from "node:path";
33
34
  import { fileURLToPath } from "node:url";
35
+ import { classifyTmuxCwd } from "../pi-extensions/lib/classify-tmux-cwd.ts";
34
36
  import type { Placement } from "../pi-extensions/lib/mux-placement.ts";
35
37
  import {
36
38
  buildResumeCallArgs,
37
- classifyResumeCwd,
38
39
  RESUME_CALL_REJECT_HINT,
39
40
  RESUME_CALL_RUNTIME,
40
41
  type ResumeCallRejectReason,
@@ -77,32 +78,32 @@ function main(): void {
77
78
 
78
79
  try {
79
80
  // ── cwd classification ────────────────────────────────────────────────────────
80
- ok("an existing absolute directory is accepted", classifyResumeCwd(realDir) === null);
81
+ ok("an existing absolute directory is accepted", classifyTmuxCwd(realDir) === null);
81
82
 
82
83
  ok(
83
84
  "[QK:MUXRESUME-CWD-MISSING-REFUSED] a cwd that no longer exists is refused HERE, because tmux would not refuse it — measured: rc=0, the window opens, and the child silently falls back to $HOME, so the resume would land a visible citizen in the wrong project and look successful",
84
- classifyResumeCwd(path.join(tmp, "deleted-project")) === "cwd-missing",
85
+ classifyTmuxCwd(path.join(tmp, "deleted-project")) === "cwd-missing",
85
86
  );
86
87
  ok(
87
88
  "a path that exists but is a FILE is refused as its own cause, not as missing",
88
- classifyResumeCwd(filePath) === "cwd-not-directory",
89
+ classifyTmuxCwd(filePath) === "cwd-not-directory",
89
90
  );
90
91
  ok(
91
92
  "[QK:MUXRESUME-CWD-FORMAT-REFUSED] a cwd containing '#' is refused unrun — measured: tmux FORMAT-EXPANDS the -c value, so `<dir>/#{pane_id}` silently became `<dir>/%0` and a `#(…)` value was observed executing its command; a path is data and tmux reads it as a format",
92
- classifyResumeCwd(path.join(tmp, "#{pane_id}")) === "cwd-format-token" &&
93
- classifyResumeCwd(path.join(tmp, "#(touch x)")) === "cwd-format-token",
93
+ classifyTmuxCwd(path.join(tmp, "#{pane_id}")) === "cwd-format-token" &&
94
+ classifyTmuxCwd(path.join(tmp, "#(touch x)")) === "cwd-format-token",
94
95
  );
95
96
  ok(
96
97
  "the '#' refusal precedes the filesystem question, whose answer would be about a path tmux is not going to use",
97
- classifyResumeCwd(path.join(realDir, "#nope")) === "cwd-format-token",
98
+ classifyTmuxCwd(path.join(realDir, "#nope")) === "cwd-format-token",
98
99
  );
99
100
  ok(
100
101
  "a relative cwd is refused before anything touches the filesystem",
101
- classifyResumeCwd("project") === "cwd-not-absolute",
102
+ classifyTmuxCwd("project") === "cwd-not-absolute",
102
103
  );
103
104
  ok(
104
105
  "[QK:MUXRESUME-CWD-WHITESPACE-OK] a cwd containing whitespace is ACCEPTED — measured: argv is an array, tmux does not re-split it, and the directory arrived intact; inventing a quoting grammar here would refuse real project paths for a danger that was measured not to exist",
105
- classifyResumeCwd(spaceDir) === null,
106
+ classifyTmuxCwd(spaceDir) === null,
106
107
  );
107
108
 
108
109
  // ── argv shape ────────────────────────────────────────────────────────────────
@@ -74,7 +74,6 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
74
74
  ("promptSuggestionEnabled", ["promptSuggestionEnabled"], False),
75
75
  ("awaySummaryEnabled", ["awaySummaryEnabled"], False),
76
76
  ("autoMemoryEnabled", ["autoMemoryEnabled"], False),
77
- ("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
78
77
  ("verbose", ["verbose"], False),
79
78
  ("autoCompactEnabled", ["autoCompactEnabled"], False),
80
79
  ("showTurnDuration", ["showTurnDuration"], False),
@@ -84,6 +83,19 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
84
83
  ("workflowKeywordTriggerEnabled", ["workflowKeywordTriggerEnabled"], False),
85
84
  ]
86
85
 
86
+ # Keys entwurf used to own but now returns to the operator. An old install-state
87
+ # entry is the only authority to undo our prior write; a bare matching value is
88
+ # not provenance and is never changed. apply() consumes each proven old entry
89
+ # exactly once, then uninstall can no longer restore over the operator's choice.
90
+ RETIRED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
91
+ ("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
92
+ ]
93
+
94
+ _managed_scalar_names = {name for name, _path, _desired in MANAGED_SETTINGS_SCALARS}
95
+ _retired_scalar_names = {name for name, _path, _last in RETIRED_SETTINGS_SCALARS}
96
+ if overlap := _managed_scalar_names & _retired_scalar_names:
97
+ raise RuntimeError(f"settings scalar cannot be both managed and retired: {sorted(overlap)}")
98
+
87
99
 
88
100
  class StateError(RuntimeError):
89
101
  pass
@@ -391,6 +403,9 @@ def apply(repo: Path, asm: Path) -> None:
391
403
  if not isinstance(root, dict):
392
404
  die(f"{claude_root_config_path()} root must be a JSON object")
393
405
 
406
+ for name, path_, last_managed_value in RETIRED_SETTINGS_SCALARS:
407
+ relinquish_retired_scalar(state, settings, name, path_, last_managed_value)
408
+
394
409
  set_nested(settings, ["enabledPlugins", PLUGIN_REF], True)
395
410
  set_nested(settings, ["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm))
396
411
  for path_, desired in [(["permissions", "allow"], PERMISSION_ALLOW), (["permissions", "deny"], PERMISSION_DENY)]:
@@ -446,6 +461,53 @@ def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
446
461
  die(f"unknown state entry kind: {kind}")
447
462
 
448
463
 
464
+ def settings_state_keys(state: dict[str, Any]) -> dict[str, Any]:
465
+ """The settings key ledger, or a loud failure.
466
+
467
+ `load_state` validates only the envelope (schemaVersion/owner), so a consumer
468
+ that indexes straight into files.settings.keys turns a corrupt state file into
469
+ a bare KeyError traceback instead of an operator diagnostic. Ownership
470
+ decisions read this ledger, so an unreadable one must stop before any write.
471
+ """
472
+ files = state.get("files")
473
+ entry = files.get("settings") if isinstance(files, dict) else None
474
+ keys = entry.get("keys") if isinstance(entry, dict) else None
475
+ if not isinstance(keys, dict):
476
+ die(f"install state {state_path()} has no files.settings.keys ledger; re-run install-meta-bridge")
477
+ return keys
478
+
479
+
480
+ def relinquish_retired_scalar(
481
+ state: dict[str, Any], settings: dict[str, Any], name: str, path: list[str], last_managed_value: Any
482
+ ) -> None:
483
+ """Return one formerly managed scalar to operator ownership.
484
+
485
+ Only a preserved install-state entry proves entwurf wrote this path. If the
486
+ current value still has the exact JSON scalar type+value we last managed,
487
+ restore the snapshot. A changed or absent current value is already the
488
+ operator's and stays untouched. Malformed provenance fails before any file
489
+ write; silently discarding it would make a still-dangerous value look clean.
490
+ """
491
+ keys = settings_state_keys(state)
492
+ entry = keys.get(name)
493
+ if entry is None:
494
+ return
495
+ if not isinstance(entry, dict) or entry.get("kind") != "scalar" or entry.get("path") != path:
496
+ die(f"retired scalar state entry {name} is malformed; refusing to discard ownership evidence")
497
+ original = entry.get("original")
498
+ if (
499
+ not isinstance(original, dict)
500
+ or set(original) != {"existed", "value"}
501
+ or type(original.get("existed")) is not bool
502
+ ):
503
+ die(f"retired scalar state entry {name} has malformed original; refusing to guess")
504
+
505
+ existed, value = get_nested(settings, path)
506
+ if existed and type(value) is type(last_managed_value) and value == last_managed_value:
507
+ restore_entry(settings, entry)
508
+ keys.pop(name)
509
+
510
+
449
511
  def preflight_uninstall() -> None:
450
512
  load_state(required=True)
451
513
  print(f"[meta-bridge-state] uninstall preflight ok ({state_path()})")
@@ -536,6 +598,18 @@ def check(repo: Path, asm: Path) -> None:
536
598
  if recorded_asm
537
599
  else desired_marketplace(asm)
538
600
  )
601
+ state_settings_keys = settings_state_keys(state)
602
+ for name, path_, last_managed_value in RETIRED_SETTINGS_SCALARS:
603
+ if name in state_settings_keys:
604
+ failures.append(f"install-state still owns retired scalar {name}; re-run install-meta-bridge to relinquish it")
605
+ continue
606
+ existed, value = get_nested(settings, path_)
607
+ if existed and type(value) is type(last_managed_value) and value == last_managed_value:
608
+ print(
609
+ f"NOTE: settings {name}={json.dumps(last_managed_value)} is operator-owned; "
610
+ f"entwurf no longer suppresses or restores this warning choice"
611
+ )
612
+
539
613
  checks = [
540
614
  (["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
541
615
  (["extraKnownMarketplaces", MARKETPLACE], marketplace_expected, "known marketplace"),
@@ -0,0 +1,47 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "meta-retire",
4
+ "mutants": [
5
+ {
6
+ "claim": "META-RETIRE-COMPARE-TYPE",
7
+ "title": "comparing the current value without its JSON scalar type lets a truthy 1 pass as the true entwurf wrote, so relinquishment overwrites a value that was never ours (#71)",
8
+ "subject": "scripts/meta-bridge-state.py",
9
+ "find": [
10
+ " existed, value = get_nested(settings, path)",
11
+ " if existed and type(value) is type(last_managed_value) and value == last_managed_value:"
12
+ ],
13
+ "replace": [" existed, value = get_nested(settings, path)", " if existed and value == last_managed_value:"],
14
+ "gate": ["bash", "run.sh", "smoke-meta-install-state"],
15
+ "timeoutSeconds": 300,
16
+ "signature": "[QK:META-RETIRE-COMPARE-TYPE]",
17
+ "signatureSource": "scripts/smoke-meta-install-state.sh"
18
+ },
19
+ {
20
+ "claim": "META-RETIRE-MALFORMED-SILENT",
21
+ "title": "skipping a malformed retired entry instead of failing discards the only evidence that entwurf owns the key, leaving a still-dangerous value looking operator-clean (#71)",
22
+ "subject": "scripts/meta-bridge-state.py",
23
+ "find": [
24
+ " die(f\"retired scalar state entry {name} is malformed; refusing to discard ownership evidence\")"
25
+ ],
26
+ "replace": [" return"],
27
+ "gate": ["bash", "run.sh", "smoke-meta-install-state"],
28
+ "timeoutSeconds": 300,
29
+ "signature": "[QK:META-RETIRE-MALFORMED-SILENT]",
30
+ "signatureSource": "scripts/smoke-meta-install-state.sh"
31
+ },
32
+ {
33
+ "claim": "META-RETIRE-FRESH-TOUCH",
34
+ "title": "synthesising provenance when install-state carries none lets relinquishment rewrite a value the operator set themselves, the exact ownership violation #71 exists to stop",
35
+ "subject": "scripts/meta-bridge-state.py",
36
+ "find": [" if entry is None:", " return"],
37
+ "replace": [
38
+ " if entry is None:",
39
+ " entry = {\"kind\": \"scalar\", \"path\": path, \"original\": {\"existed\": False, \"value\": None}}"
40
+ ],
41
+ "gate": ["bash", "run.sh", "smoke-meta-install-state"],
42
+ "timeoutSeconds": 300,
43
+ "signature": "[QK:META-RETIRE-FRESH-TOUCH]",
44
+ "signatureSource": "scripts/smoke-meta-install-state.sh"
45
+ }
46
+ ]
47
+ }
@@ -78,15 +78,59 @@
78
78
  "claim": "FRESHCALL-RECEIPT-WITHOUT-CORRELATION",
79
79
  "title": "the launch receipt grows a sibling garden-id field, making an async correlation look like a synchronous return",
80
80
  "subject": "pi-extensions/lib/mux-fresh-call.ts",
81
- "find": ["\tbackend: FreshCallBackend;\n\tmodel: string;\n\truntimePath: string;\n\tnonce: string;\n}"],
82
- "replace": [
83
- "\tbackend: FreshCallBackend;\n\tmodel: string;\n\truntimePath: string;\n\tnonce: string;\n\tgardenId?: string;\n}"
84
- ],
81
+ "find": ["\truntimePath: string;\n\tnonce: string;\n}"],
82
+ "replace": ["\truntimePath: string;\n\tnonce: string;\n\tgardenId?: string;\n}"],
85
83
  "gate": ["bash", "run.sh", "check-mux-fresh-call"],
86
84
  "timeoutSeconds": 120,
87
85
  "signature": "[QK:FRESHCALL-RECEIPT-WITHOUT-CORRELATION]",
88
86
  "signatureSource": "test/mux-fresh-call.test.ts"
89
87
  },
88
+ {
89
+ "claim": "FRESHCALL-CWD-ARGV",
90
+ "title": "the requested cwd stops reaching tmux, so a cross-repo fresh sibling silently opens in the CALLER's repo and the launch looks successful",
91
+ "subject": "pi-extensions/lib/mux-fresh-call.ts",
92
+ "find": ["\t\t...(cwd === undefined ? [] : [\"-c\", cwd]),\n"],
93
+ "replace": [""],
94
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
95
+ "timeoutSeconds": 120,
96
+ "signature": "[QK:FRESHCALL-CWD-ARGV]",
97
+ "signatureSource": "test/mux-fresh-call.test.ts"
98
+ },
99
+ {
100
+ "claim": "FRESHCALL-CWD-REFUSED-PREMUTATION",
101
+ "title": "the pre-mutation cwd classification is ignored, so a relative/'#'/deleted/file path rides on toward tmux instead of refusing by name",
102
+ "subject": "pi-extensions/lib/mux-fresh-call.ts",
103
+ "find": ["\t\tif (badCwd) return { ok: false, reason: badCwd };"],
104
+ "replace": ["\t\tvoid badCwd;"],
105
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
106
+ "timeoutSeconds": 120,
107
+ "signature": "[QK:FRESHCALL-CWD-REFUSED-PREMUTATION]",
108
+ "signatureSource": "test/mux-fresh-call.test.ts"
109
+ },
110
+ {
111
+ "claim": "FRESHCALL-CWD-RECEIPT-REQUESTED",
112
+ "title": "production freshCall stops assembling the requested cwd into its receipt, so the operator-visible answer silently loses the one placement fact the caller asked for",
113
+ "subject": "pi-extensions/lib/mux-fresh-call.ts",
114
+ "find": ["\t\t\t...(cwd === undefined ? {} : { cwd }),\n"],
115
+ "replace": [""],
116
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
117
+ "timeoutSeconds": 120,
118
+ "signature": "[QK:FRESHCALL-CWD-RECEIPT-REQUESTED]",
119
+ "signatureSource": "test/mux-fresh-call.test.ts"
120
+ },
121
+ {
122
+ "claim": "FRESHCALL-CWD-SURFACE-PARITY",
123
+ "title": "one surface drops the optional cwd from its schema, so cross-repo fresh works from pi and silently cannot exist from the MCP bridge",
124
+ "subject": "mcp/entwurf-bridge/src/index.ts",
125
+ "find": [
126
+ "\t\tcwd: z\n\t\t\t.string()\n\t\t\t.optional()\n\t\t\t.describe(\n\t\t\t\t\"Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \\\"\\\" to start in this agent's own cwd. Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). The receipt echoes what was REQUESTED, never an observation.\",\n\t\t\t),\n"
127
+ ],
128
+ "replace": [""],
129
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
130
+ "timeoutSeconds": 120,
131
+ "signature": "[QK:FRESHCALL-CWD-SURFACE-PARITY]",
132
+ "signatureSource": "test/fresh-call-surfaces.contract.test.ts"
133
+ },
90
134
  {
91
135
  "claim": "FRESHCALL-PI-SURFACE-IDENTITY",
92
136
  "title": "native pi takes the caller garden id from the environment instead of its own resident closure — the uuidv7 confusion, reintroduced",
@@ -5,7 +5,7 @@
5
5
  {
6
6
  "claim": "MUXRESUME-CWD-MISSING-REFUSED",
7
7
  "title": "a deleted recorded cwd is accepted — tmux then opens the window in $HOME and the wrong-project resume looks successful",
8
- "subject": "pi-extensions/lib/mux-resume-call.ts",
8
+ "subject": "pi-extensions/lib/classify-tmux-cwd.ts",
9
9
  "find": ["\t\treturn \"cwd-missing\";"],
10
10
  "replace": ["\t\treturn null;"],
11
11
  "gate": ["bash", "run.sh", "check-mux-resume-call"],
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "claim": "MUXRESUME-CWD-FORMAT-REFUSED",
18
18
  "title": "the '#' refusal stops matching, so tmux format-expands the path it was handed",
19
- "subject": "pi-extensions/lib/mux-resume-call.ts",
19
+ "subject": "pi-extensions/lib/classify-tmux-cwd.ts",
20
20
  "find": ["\tif (cwd.includes(\"#\")) return \"cwd-format-token\";"],
21
21
  "replace": ["\tif (cwd.includes(\"\\u0000\")) return \"cwd-format-token\";"],
22
22
  "gate": ["bash", "run.sh", "check-mux-resume-call"],
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "claim": "MUXRESUME-CWD-WHITESPACE-OK",
29
29
  "title": "a quoting fear is added back as a whitespace refusal, which would reject real project directories tmux handles fine",
30
- "subject": "pi-extensions/lib/mux-resume-call.ts",
30
+ "subject": "pi-extensions/lib/classify-tmux-cwd.ts",
31
31
  "find": ["\tif (cwd.includes(\"#\")) return \"cwd-format-token\";"],
32
32
  "replace": ["\tif (cwd.includes(\"#\") || /\\s/.test(cwd)) return \"cwd-format-token\";"],
33
33
  "gate": ["bash", "run.sh", "check-mux-resume-call"],