@junghanacs/entwurf 0.41.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +110 -1
  3. package/CHANGELOG.md +328 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/ENTWURF-CLI.json +1229 -0
  6. package/ENTWURF.json +284 -0
  7. package/FAQ.md +4 -4
  8. package/README.md +125 -184
  9. package/VERIFY.md +2 -2
  10. package/docs/external-mcp-host.md +3 -3
  11. package/docs/setup-clean-host.md +37 -12
  12. package/mcp/entwurf-bridge/dist/ENTWURF.json +284 -0
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +46 -196
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +21 -25
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +498 -29
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +23 -20
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +68 -21
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +7 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
  29. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -60
  30. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
  31. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
  35. package/mcp/entwurf-bridge/src/index.ts +149 -327
  36. package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
  37. package/native/win32-x64/manifest.json +1 -1
  38. package/package.json +8 -4
  39. package/pi/pi-durable/bootstrap.mjs +179 -6
  40. package/pi/pi-durable/carrier/runtime.js +108 -9
  41. package/pi/pi-durable/carrier/session-name.js +11 -0
  42. package/pi/pi-durable/carrier/sessions.js +96 -20
  43. package/pi/pi-durable/overlay/upstream-pin.json +11 -5
  44. package/pi-extensions/lib/agent-guidance.ts +341 -0
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +25 -31
  46. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +725 -54
  47. package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
  48. package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
  49. package/pi-extensions/lib/herdr-resume-call.ts +810 -0
  50. package/pi-extensions/lib/meta-session.ts +13 -8
  51. package/pi-extensions/lib/mux-fresh-call.ts +23 -26
  52. package/pi-extensions/lib/mux-resume-call.ts +95 -23
  53. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
  54. package/pi-extensions/lib/pi-durable-location.ts +446 -0
  55. package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
  56. package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
  57. package/pi-extensions/lib/remote-pair-send.ts +7 -4
  58. package/pi-extensions/lib/resume-launch-identity.ts +31 -12
  59. package/pi-extensions/meta-bridge-pi-durable.ts +52 -79
  60. package/run.sh +417 -654
  61. package/scripts/build-bridge.sh +10 -0
  62. package/scripts/check-capability-bundle-reach.ts +86 -0
  63. package/scripts/check-claude-launch.ts +341 -0
  64. package/scripts/check-entwurf-self-address.ts +3 -3
  65. package/scripts/check-entwurf-v2-surface.ts +94 -98
  66. package/scripts/check-fresh-call-dispatch.ts +70 -9
  67. package/scripts/check-fresh-cut-gate.sh +23 -9
  68. package/scripts/check-gate-qualification.ts +24 -17
  69. package/scripts/check-herdr-activation.ts +301 -23
  70. package/scripts/check-herdr-fresh-call.ts +161 -25
  71. package/scripts/check-herdr-plugin-build.ts +173 -1
  72. package/scripts/check-herdr-plugin.ts +28 -2
  73. package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
  74. package/scripts/check-install-surface.ts +1236 -6
  75. package/scripts/check-meta-doctor-oracle.sh +1 -1
  76. package/scripts/check-meta-facts.ts +20 -1
  77. package/scripts/check-mux-resume-call.ts +48 -3
  78. package/scripts/check-peer-facts.ts +39 -2
  79. package/scripts/cli-help.mjs +222 -0
  80. package/scripts/dev-bin.sh +39 -2
  81. package/scripts/herdr-activation.mjs +28 -6
  82. package/scripts/herdr-operator-link.mjs +389 -0
  83. package/scripts/herdr-plugin-activate.mjs +5 -0
  84. package/scripts/herdr-plugin-deactivate.mjs +18 -5
  85. package/scripts/herdr-rails.mjs +43 -7
  86. package/scripts/herdr-runtime.mjs +27 -0
  87. package/scripts/meta-bridge-fresh-cut.ts +1 -27
  88. package/scripts/meta-bridge-prune.ts +1 -1
  89. package/scripts/meta-facts.ts +1 -1
  90. package/scripts/mutants/agent-guidance.json +160 -0
  91. package/scripts/mutants/bridge-boot-resume.json +6 -8
  92. package/scripts/mutants/capability-bundle-reach.json +124 -0
  93. package/scripts/mutants/claude-launch.json +142 -0
  94. package/scripts/mutants/cli-help-source.json +420 -0
  95. package/scripts/mutants/control-send-receipt.json +21 -4
  96. package/scripts/mutants/dev-gate-dispatch.json +109 -0
  97. package/scripts/mutants/fresh-call-dispatch.json +38 -2
  98. package/scripts/mutants/fresh-cut.json +15 -0
  99. package/scripts/mutants/herdr-activation.json +128 -0
  100. package/scripts/mutants/herdr-fresh-call.json +278 -5
  101. package/scripts/mutants/herdr-operator-link.json +318 -0
  102. package/scripts/mutants/herdr-plugin-build.json +55 -0
  103. package/scripts/mutants/herdr-plugin.json +17 -0
  104. package/scripts/mutants/herdr-resume-call.json +288 -0
  105. package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
  106. package/scripts/mutants/meta-facts.json +15 -0
  107. package/scripts/mutants/mux-fresh-call.json +14 -3
  108. package/scripts/mutants/mux-resume-call.json +89 -3
  109. package/scripts/mutants/peer-facts.json +15 -0
  110. package/scripts/mutants/pi-durable.json +996 -6
  111. package/scripts/mutants/remote-pair-send.json +21 -2
  112. package/scripts/mutants/self-address.json +10 -10
  113. package/scripts/mutants/source-install.json +35 -0
  114. package/scripts/mutants/v2-surface.json +50 -64
  115. package/scripts/mutants/v2-visible-resume.json +266 -6
  116. package/scripts/peer-facts.ts +1 -1
  117. package/scripts/smoke-agy-install-state.sh +66 -0
  118. package/scripts/smoke-meta-prune.sh +23 -0
  119. package/scripts/smoke-omp-fresh-live.ts +12 -3
  120. package/scripts/smoke-omp-receive-live.ts +7 -1
  121. package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
  122. package/team.json.example +39 -0
@@ -767,7 +767,7 @@ import { pathToFileURL } from "node:url";
767
767
  const [modPath, active, backends] = process.argv.slice(2);
768
768
  const m = await import(pathToFileURL(modPath).href);
769
769
  const states = Object.fromEntries(backends.split(",").map((b) => [b, "active"]));
770
- m.writeLedger(m.resolveActivationLayout(process.env), m.ledgerBody({
770
+ m.writeLedger(m.resolveActivationLayout(process.env), 2, m.ledgerBody({
771
771
  phase: "active",
772
772
  runtimeRoot: active,
773
773
  artifactIdentity: { kind: "npm", name: "@junghanacs/entwurf", version: "0.34.1", expectedIntegrity: "sha512-fixture", observedDigest: `sha256-${"c".repeat(64)}` },
@@ -236,7 +236,26 @@ function parseProjection(run: CliRun, label: string): Projection {
236
236
  // ── usage ───────────────────────────────────────────────────────────────────
237
237
  {
238
238
  ok("[QK:MF-USAGE] two positional args are a usage error (exit 2)", cli(["/a", "/b"]).status === 2);
239
- ok("a dash argv is a usage error, not a store directory", cli(["--help"]).status === 2);
239
+ // The dash refusal's one line is now only the address of the description (#153). This child gets
240
+ // its own scratch HOME, XDG roots, PI dir and store, so nothing it prints leans on the operator's.
241
+ const usageHome = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-meta-facts-usage-")));
242
+ const dash = cli(["--help"], {
243
+ HOME: usageHome,
244
+ XDG_DATA_HOME: path.join(usageHome, ".local", "share"),
245
+ XDG_STATE_HOME: path.join(usageHome, ".local", "state"),
246
+ XDG_CACHE_HOME: path.join(usageHome, ".cache"),
247
+ XDG_CONFIG_HOME: path.join(usageHome, ".config"),
248
+ PI_CODING_AGENT_DIR: path.join(usageHome, ".pi", "agent"),
249
+ ENTWURF_META_SESSIONS_DIR: path.join(usageHome, "store"),
250
+ });
251
+ ok("a dash argv is a usage error, not a store directory", dash.status === 2);
252
+ ok(
253
+ "[QK:MF-HELP-POINTER] the dash refusal keeps exit 2 and an empty stdout, and its one stderr line points at `entwurf help meta-facts`",
254
+ dash.status === 2 &&
255
+ dash.stdout === "" &&
256
+ dash.stderr ===
257
+ "meta-facts: see `entwurf help meta-facts` (from that checkout's root: `./run.sh help meta-facts`)\n",
258
+ );
240
259
  }
241
260
 
242
261
  // ── reachability: dispatch + compiled twin ──────────────────────────────────
@@ -29,6 +29,10 @@
29
29
  * MUXRESUME-NO-IDENTITY this module never learns which citizen it is reopening
30
30
  * MUXRESUME-SURFACE-SEAM both surfaces hand the launcher IN; neither lets the v2
31
31
  * composition import mux
32
+ * MUXRESUME-V2-NO-HERDR-IMPORT the v2 composition imports no herdr module either (#157 G1a)
33
+ * MUXRESUME-HERDR-COMPOSED the bridge delegates the resume launch to the shared rail root,
34
+ * handing it this leaf's mapping, and reads no rail fact itself
35
+ * (the root's own choice is FCD-RESUME-RAIL-SHARED, beside it)
32
36
  */
33
37
 
34
38
  import assert from "node:assert/strict";
@@ -36,6 +40,7 @@ import fs from "node:fs";
36
40
  import os from "node:os";
37
41
  import path from "node:path";
38
42
  import { fileURLToPath } from "node:url";
43
+ import { ENTWURF_RESUME_LAUNCH_REJECT_HINTS, ENTWURF_TOOL_GUIDANCE } from "../pi-extensions/lib/agent-guidance.ts";
39
44
  import { classifyTmuxCwd } from "../pi-extensions/lib/classify-tmux-cwd.ts";
40
45
  import type { Placement } from "../pi-extensions/lib/mux-placement.ts";
41
46
  import {
@@ -216,6 +221,12 @@ function main(): void {
216
221
  "every reject reason has a hint a caller can act on — a reason without one is a reason they will guess about",
217
222
  reasons.every((r) => typeof RESUME_CALL_REJECT_HINT[r] === "string" && RESUME_CALL_REJECT_HINT[r].length > 0),
218
223
  );
224
+ ok(
225
+ "[QK:MUXRESUME-LAUNCH-REJECT-HINTS-FROM-ROOT] every launch-stage resume hint is the root ENTWURF.json text for its own reason, and the table names exactly the verb's reasons",
226
+ reasons.every((r) => RESUME_CALL_REJECT_HINT[r] === ENTWURF_RESUME_LAUNCH_REJECT_HINTS[r]) &&
227
+ JSON.stringify(Object.keys(RESUME_CALL_REJECT_HINT).sort()) === JSON.stringify([...reasons].sort()) &&
228
+ JSON.stringify(Object.keys(ENTWURF_RESUME_LAUNCH_REJECT_HINTS).sort()) === JSON.stringify([...reasons].sort()),
229
+ );
219
230
  ok(
220
231
  "[QK:MUXRESUME-RUNTIME-TABLE] the runtime is a closed per-backend table, pi → pi and claude-code → claude — never a command a caller supplies",
221
232
  JSON.stringify(RESUME_CALL_RUNTIME) === JSON.stringify({ pi: "pi", "claude-code": "claude" }),
@@ -227,9 +238,19 @@ function main(): void {
227
238
  "[QK:MUXRESUME-NO-IDENTITY] the module never mentions a garden id, a record, a lock or a transcript — it opens a place at a directory and knows nothing about whose citizen it is",
228
239
  !/gardenId|readAddressableMetaIdentity|acquireLock|transcriptPath/.test(SRC),
229
240
  );
241
+ // Every static form (from / bare / export-from) is parsed and dynamic loading is refused, so a
242
+ // fourth lane cannot enter through a shape the parser does not see. The one non-mux member is the
243
+ // node:fs-only guidance reader (#154 C1-D), whose own leaf-ness is GUIDANCE-READER-LEAF-IMPORTS.
244
+ const resumeImports = [
245
+ ...SRC.matchAll(/^\s*(?:import|export)\s[^;]*?\sfrom\s+["']([^"']+)["']/gm),
246
+ ...SRC.matchAll(/^\s*import\s+["']([^"']+)["']/gm),
247
+ ].map((m) => m[1]);
230
248
  ok(
231
- "it imports only the mux lane — no entwurf core, no delivery",
232
- !/from "\.\/entwurf-/.test(SRC) && !/meta-session/.test(SRC),
249
+ "[QK:MUXRESUME-IMPORT-SET] it imports exactly the mux lane plus the guidance reader — no entwurf core, no delivery, no dynamic import",
250
+ JSON.stringify([...resumeImports].sort()) ===
251
+ JSON.stringify(["./agent-guidance.ts", "./classify-tmux-cwd.ts", "./mux-launch.ts", "./mux-placement.ts"]) &&
252
+ !/\bimport\s*\(|\brequire\s*\(/.test(SRC) &&
253
+ !/meta-session/.test(SRC),
233
254
  );
234
255
  ok(
235
256
  "it uses the SHARED APPEND_FORMAT rather than adding a start-path field: `-P -F` prints its row BEFORE the child chdirs (measured — pane_current_path there still reports the CALLER's cwd), so a path field would be both free-form in a `|`-joined row and racy",
@@ -248,6 +269,12 @@ function main(): void {
248
269
  "[QK:MUXRESUME-SURFACE-SEAM] the v2 composition never IMPORTS mux — the launcher is passed IN, which is what keeps docs/mux-launch-rail.md §11 true in both directions",
249
270
  v2ImportSpecifiers.length > 0 && !v2ImportSpecifiers.some((s) => /mux-/.test(s)),
250
271
  );
272
+ // #157 G1a: the herdr resume launch reaches v2 through the same seam, so the same fence holds
273
+ // for it. v2 learns which rail ran only from the `rail` its launch handle carries.
274
+ ok(
275
+ "[QK:MUXRESUME-V2-NO-HERDR-IMPORT] the v2 composition imports no herdr module either — the herdr resume launch is passed IN through the same seam, so neither rail's CLI enters entwurf core",
276
+ v2ImportSpecifiers.length > 0 && !v2ImportSpecifiers.some((s) => /herdr-/.test(s)),
277
+ );
251
278
  ok(
252
279
  "the bridge is the composition root: it imports the mux launcher AND the v2 composition and joins them itself",
253
280
  /mux-resume-call/.test(MCP_SURFACE) && /entwurf-v2-visible-resume/.test(MCP_SURFACE),
@@ -270,10 +297,28 @@ function main(): void {
270
297
  /async \(\{ target \}, extra\) =>/.test(MCP_RESUME_BLOCK) &&
271
298
  /await visibleResume\(target, makeVisibleResumeDeps\(visibleResumeLaunch\)\)/.test(MCP_RESUME_BLOCK),
272
299
  );
300
+ // #157 G1a: the bridge is still where this leaf meets the v2 composition, but the rail is NOT
301
+ // chosen here. The shared root (`dispatchResumeLaunch` in fresh-call-dispatch, held there by
302
+ // FCD-RESUME-RAIL-SHARED) reads the capability fact; the bridge hands it this leaf's fixed
303
+ // mapping and imports no herdr module (its reading no rail fact is FCD-SURFACE-ROOT-ONLY). The
304
+ // launchers are closures inside the bridge entry, so their wiring is read from the bridge source.
305
+ const MCP_IMPORTS = [...MCP_SURFACE.matchAll(/from\s+"([^"]+)"/g)].map((m) => m[1]);
306
+ const TMUX_RESUME_BLOCK = (MCP_SURFACE.split("const tmuxResumeLaunch")[1] ?? "").split("\n};")[0];
307
+ ok(
308
+ "[QK:MUXRESUME-HERDR-COMPOSED] the bridge hands every resume launch to the shared rail root together with this tmux leaf's mapping, and imports no herdr module of its own — a bridge that called this leaf directly would refuse every resume inside herdr, and one that reached a herdr launch itself would be a second copy of the rail choice",
309
+ /^const visibleResumeLaunch: VisibleResumeDeps\["launch"\] = \(input\) => dispatchResumeLaunch\(input, tmuxResumeLaunch\);$/m.test(
310
+ MCP_SURFACE,
311
+ ) &&
312
+ /resumeCall\(\{ backend: input\.backend, cwd: input\.cwd, runtimeArgs: input\.runtimeArgs \}\)/.test(
313
+ TMUX_RESUME_BLOCK,
314
+ ) &&
315
+ MCP_IMPORTS.includes("../../../pi-extensions/lib/fresh-call-dispatch.ts") &&
316
+ !MCP_IMPORTS.some((s) => /herdr-/.test(s)),
317
+ );
273
318
 
274
319
  // ── the description contract ────────────────────────────────────────────────
275
320
  // Not prose parity — three claims an operator acts on, plus the host's own cap.
276
- const MCP_DESC = (MCP_SURFACE.split('"entwurf_resume_call",')[1] ?? "").split("{")[0];
321
+ const MCP_DESC = ENTWURF_TOOL_GUIDANCE.entwurf_resume_call.descriptions.mcp;
277
322
  const statesTheContract = (text: string): boolean =>
278
323
  /dormant/i.test(text) && /submits NO prompt/i.test(text) && /observation/i.test(text) && /launch/i.test(text);
279
324
 
@@ -300,13 +300,33 @@ function parseProjection(run: CliRun, label: string): Projection {
300
300
  console.log(" ok an unreadable store is exit 3 with no JSON");
301
301
  passed++;
302
302
 
303
- const flag = cli(["--help"]);
303
+ // This child gets its own scratch HOME, XDG roots, PI dir and store on top of cli()'s, so the
304
+ // refusal it prints leans on nothing of the operator's.
305
+ const usageHome = tmp("usage");
306
+ const flag = cli(["--help"], {
307
+ HOME: usageHome,
308
+ XDG_DATA_HOME: path.join(usageHome, ".local", "share"),
309
+ XDG_STATE_HOME: path.join(usageHome, ".local", "state"),
310
+ XDG_CACHE_HOME: path.join(usageHome, ".cache"),
311
+ XDG_CONFIG_HOME: path.join(usageHome, ".config"),
312
+ PI_CODING_AGENT_DIR: path.join(usageHome, ".pi", "agent"),
313
+ ENTWURF_META_SESSIONS_DIR: path.join(usageHome, "store"),
314
+ });
304
315
  assert.ok(
305
316
  flag.status === 2 && flag.stdout === "",
306
317
  "[QK:PF-USAGE] a dash argv is a flag this command does not have — reading it as a store directory would answer `--help` with `empty store, exit 0`, a silent wrong fact",
307
318
  );
308
319
  console.log(" ok a dash argv is a usage error, not a store directory");
309
320
  passed++;
321
+ // The leaf's one stderr line is now only the address of the description (#153); the description
322
+ // itself is ENTWURF-CLI.json's, which `entwurf peer-facts --help` answers before this leaf runs.
323
+ ok(
324
+ "[QK:PF-HELP-POINTER] the dash refusal keeps exit 2 and an empty stdout, and its one stderr line points at `entwurf help peer-facts`",
325
+ flag.status === 2 &&
326
+ flag.stdout === "" &&
327
+ flag.stderr ===
328
+ "peer-facts: see `entwurf help peer-facts` (from that checkout's root: `./run.sh help peer-facts`)\n",
329
+ );
310
330
  ok("a second positional is a usage error", cli(["/a", "/b"]).status === 2);
311
331
  }
312
332
 
@@ -317,7 +337,24 @@ function parseProjection(run: CliRun, label: string): Projection {
317
337
  "run.sh dispatches peer-facts through the single run_ts fence",
318
338
  /\n {2}peer-facts\)\n(?:.*\n)*?\s*run_ts scripts\/peer-facts\.ts "\$@"\n/.test(runSh),
319
339
  );
320
- ok("run.sh documents peer-facts in its usage block", runSh.includes("./run.sh peer-facts "));
340
+ // The description is owned by ENTWURF-CLI.json now, so ask the public surface rather than any
341
+ // file: what the root help actually prints. Only PATH crosses; the root help runs no command.
342
+ const rootHelp = spawnSync("bash", [path.join(REPO, "run.sh"), "--help"], {
343
+ encoding: "utf8",
344
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin" },
345
+ });
346
+ ok("the root help describes peer-facts", rootHelp.status === 0 && /^ {2}entwurf peer-facts /m.test(rootHelp.stdout));
347
+ // The scene that opened #153's help work: an agent with no briefing asks `entwurf peer-facts
348
+ // --help`. run.sh answers it from the description before dispatch, so the store is never read and
349
+ // the leaf's own dash refusal (PF-USAGE, exit 2) is not what the operator meets.
350
+ const askHelp = spawnSync("bash", [path.join(REPO, "run.sh"), "peer-facts", "--help"], {
351
+ encoding: "utf8",
352
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin" },
353
+ });
354
+ ok(
355
+ "`entwurf peer-facts --help` is that command's help on stdout, exit 0",
356
+ askHelp.status === 0 && askHelp.stderr === "" && askHelp.stdout.startsWith("Usage: entwurf peer-facts\n"),
357
+ );
321
358
  const build = fs.readFileSync(path.join(REPO, "mcp/entwurf-bridge/tsconfig.build.json"), "utf8");
322
359
  ok(
323
360
  "the bridge build emits the compiled twin (installed hosts cannot strip-types)",
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Renders the entwurf command line's own description from root ENTWURF-CLI.json, its one editable
3
+ * source: the short root page (`entwurf --help`), the full catalog (`entwurf help all`), one
4
+ * command's page, and one guide topic's page. Plain `node` in every mode (no strip-types, no
5
+ * compiled twin): it imports node builtins only and reads one module-relative file, never
6
+ * cwd/HOME/env, so a checkout and an installed package answer from the bytes they carry. The whole
7
+ * file is checked before the first write in every mode; a missing or malformed source is a named
8
+ * failure (exit 1) on stderr with nothing on stdout — there is no older text to fall back to.
9
+ *
10
+ * argv is exactly one of: nothing (the root page), `--all` (the catalog), `--command <name>` (that
11
+ * command's page — what `<command> --help` asks), or `--name <name>` (what `entwurf help <name>`
12
+ * asks: a command's page, else a guide topic's page). A name neither describes is exit 2 with one
13
+ * stderr line. Anything else is exit 2. A topic is static text: it reads none of the settings it
14
+ * talks about, and it is never a command, so `<topic> --help` is refused like any undescribed name.
15
+ *
16
+ * The root page names catalog entries and prints their synopses from the catalog, so a command it
17
+ * shows is always one the catalog describes. A command name in the data owns a description, not a
18
+ * dispatch: run.sh's case and its source-defined gate functions stay the only things that run
19
+ * anything, and check-install-surface compares the two independently.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+
23
+ const SOURCE = new URL("../ENTWURF-CLI.json", import.meta.url);
24
+ const PREFIX = " entwurf ";
25
+
26
+ function fail(detail) {
27
+ process.stderr.write(`entwurf: ENTWURF-CLI.json ${detail}\n`);
28
+ process.exit(1);
29
+ }
30
+
31
+ function refuse(detail) {
32
+ process.stderr.write(`entwurf: ${detail}\n`);
33
+ process.exit(2);
34
+ }
35
+
36
+ function object(value, at, wanted, optional = []) {
37
+ if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${at}: expected an object`);
38
+ const got = Object.keys(value)
39
+ .filter((k) => !optional.includes(k))
40
+ .sort();
41
+ const want = [...wanted].sort();
42
+ if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
43
+ fail(
44
+ `${at}: keys must be exactly ${wanted.join(", ")}${optional.length ? ` (optionally ${optional.join(", ")})` : ""}`,
45
+ );
46
+ }
47
+ return value;
48
+ }
49
+
50
+ function text(value, at, { empty = false } = {}) {
51
+ if (typeof value !== "string" || (!empty && value.length === 0) || value.includes("\n")) {
52
+ fail(`${at}: expected a ${empty ? "" : "non-empty "}single-line string`);
53
+ }
54
+ return value;
55
+ }
56
+ const line = (value, at) => text(value, at);
57
+
58
+ function array(value, at) {
59
+ if (!Array.isArray(value) || value.length === 0) fail(`${at}: expected a non-empty array`);
60
+ return value;
61
+ }
62
+
63
+ /** One aligned row: the head, padded to its column, then the text. The column must leave a gap. */
64
+ function aligned(head, column, body, at) {
65
+ if (!Number.isSafeInteger(column) || column <= head.length) {
66
+ fail(`${at}: column must be an integer past ${JSON.stringify(head.trim())} (> ${head.length})`);
67
+ }
68
+ return `${head.padEnd(column)}${body}`;
69
+ }
70
+
71
+ const argv = process.argv.slice(2);
72
+ const all = argv.length === 1 && argv[0] === "--all";
73
+ const wanted = argv.length === 2 && (argv[0] === "--command" || argv[0] === "--name") ? argv[1] : undefined;
74
+ // Only `entwurf help <name>` may reach a guide; `<command> --help` asks about something to run.
75
+ const topicsToo = argv[0] === "--name";
76
+ if (argv.length !== 0 && !all && wanted === undefined) {
77
+ refuse("cli-help.mjs takes nothing, --all, --command <name> or --name <name>");
78
+ }
79
+
80
+ let raw;
81
+ try {
82
+ raw = readFileSync(SOURCE, "utf8");
83
+ } catch (err) {
84
+ fail(`unreadable: ${err.code ?? err.message}`);
85
+ }
86
+ let data;
87
+ try {
88
+ data = JSON.parse(raw);
89
+ } catch (err) {
90
+ fail(`is not JSON: ${err.message}`);
91
+ }
92
+
93
+ object(data, "root", ["_about", "schemaVersion", "root", "topics", "usage"]);
94
+ object(data._about, "_about", ["purpose", "ownership"]);
95
+ line(data._about.purpose, "_about.purpose");
96
+ line(data._about.ownership, "_about.ownership");
97
+ if (data.schemaVersion !== 4) fail("schemaVersion: unsupported");
98
+
99
+ // ── the catalog: every described command, the page `entwurf help all` prints ──
100
+ const usage = object(data.usage, "usage", ["title", "commandGroups", "notes"]);
101
+ const notes = object(usage.notes, "usage.notes", ["title", "lines"]);
102
+
103
+ const out = [line(usage.title, "usage.title")];
104
+ const entries = new Map();
105
+ array(usage.commandGroups, "usage.commandGroups").forEach((group, g) => {
106
+ if (g > 0) out.push("");
107
+ array(group, `usage.commandGroups[${g}]`).forEach((entry, e) => {
108
+ const at = `usage.commandGroups[${g}][${e}]`;
109
+ object(entry, at, ["synopsis", "column", "summary"], ["details"]);
110
+ const synopsis = line(entry.synopsis, `${at}.synopsis`);
111
+ const name = synopsis.split(" ", 1)[0];
112
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) fail(`${at}.synopsis: ${JSON.stringify(name)} is not a command name`);
113
+ // `all` names the catalog itself (`entwurf help all`), so no command may take it.
114
+ if (name === "all") fail(`${at}.synopsis: "all" is reserved for \`entwurf help all\``);
115
+ if (entries.has(name)) fail(`${at}.synopsis: ${name} is described twice`);
116
+ const head = PREFIX + synopsis;
117
+ if (!Number.isSafeInteger(entry.column) || entry.column <= head.length) {
118
+ fail(`${at}.column: must be an integer past the synopsis (> ${head.length})`);
119
+ }
120
+ const summary = line(entry.summary, `${at}.summary`);
121
+ // Details are a command's own longer text, printed only by its help: blank lines separate
122
+ // its parts, so they may be empty, but the block neither starts nor ends on one.
123
+ const details =
124
+ entry.details === undefined
125
+ ? []
126
+ : array(entry.details, `${at}.details`).map((d, i) => text(d, `${at}.details[${i}]`, { empty: true }));
127
+ if (details.length > 0 && (details[0] === "" || details.at(-1) === "")) {
128
+ fail(`${at}.details: must not start or end with an empty line`);
129
+ }
130
+ entries.set(name, { synopsis, summary, details });
131
+ out.push(`${head.padEnd(entry.column)}# ${summary}`);
132
+ });
133
+ });
134
+ out.push("", line(notes.title, "usage.notes.title"));
135
+ for (const [n, note] of array(notes.lines, "usage.notes.lines").entries()) {
136
+ out.push(` - ${line(note, `usage.notes.lines[${n}]`)}`);
137
+ }
138
+
139
+ // ── the root page: a few commands by task, named from the catalog, then examples ──
140
+ const root = object(data.root, "root", ["title", "usage", "sections", "examples", "footer"]);
141
+ const rootUsage = object(root.usage, "root.usage", ["title", "column", "rows"]);
142
+ const compact = [line(root.title, "root.title"), "", line(rootUsage.title, "root.usage.title")];
143
+ array(rootUsage.rows, "root.usage.rows").forEach((row, r) => {
144
+ const at = `root.usage.rows[${r}]`;
145
+ object(row, at, ["form"], ["text"]);
146
+ const head = PREFIX + line(row.form, `${at}.form`);
147
+ compact.push(row.text === undefined ? head : aligned(head, rootUsage.column, line(row.text, `${at}.text`), at));
148
+ });
149
+ array(root.sections, "root.sections").forEach((section, s) => {
150
+ const at = `root.sections[${s}]`;
151
+ object(section, at, ["title", "column", "rows"]);
152
+ compact.push("", line(section.title, `${at}.title`));
153
+ array(section.rows, `${at}.rows`).forEach((row, r) => {
154
+ const rowAt = `${at}.rows[${r}]`;
155
+ object(row, rowAt, ["name", "text"]);
156
+ const entry = entries.get(line(row.name, `${rowAt}.name`));
157
+ if (entry === undefined) fail(`${rowAt}.name: ${JSON.stringify(row.name)} is not a command the catalog describes`);
158
+ compact.push(aligned(PREFIX + entry.synopsis, section.column, line(row.text, `${rowAt}.text`), rowAt));
159
+ });
160
+ });
161
+ const examples = object(root.examples, "root.examples", ["title", "items"]);
162
+ compact.push("", line(examples.title, "root.examples.title"));
163
+ array(examples.items, "root.examples.items").forEach((item, i) => {
164
+ const at = `root.examples.items[${i}]`;
165
+ object(item, at, ["comment", "command"]);
166
+ if (i > 0) compact.push("");
167
+ compact.push(` # ${line(item.comment, `${at}.comment`)}`);
168
+ const command = line(item.command, `${at}.command`);
169
+ // An example runs a real command: `entwurf <described name> …`, or `entwurf help` (`help`,
170
+ // `help all`, `help <described name>`). Nothing here parses shell beyond those words.
171
+ const [lead, first, second] = command.split(" ");
172
+ if (lead !== "entwurf" || first === undefined) fail(`${at}.command: an example starts with \`entwurf <command>\``);
173
+ const named = first === "help" && second !== undefined ? second : first;
174
+ const helpItself = first === "help" && (second === undefined || second === "all");
175
+ if (!helpItself && !entries.has(named))
176
+ fail(`${at}.command: ${JSON.stringify(named)} is not a command the catalog describes`);
177
+ compact.push(` ${command}`);
178
+ });
179
+ compact.push("");
180
+ for (const [f, footer] of array(root.footer, "root.footer").entries()) compact.push(line(footer, `root.footer[${f}]`));
181
+
182
+ // ── guide topics: read-only pages `entwurf help <name>` prints; never commands ──
183
+ const topics = new Map();
184
+ array(data.topics, "topics").forEach((topic, t) => {
185
+ const at = `topics[${t}]`;
186
+ object(topic, at, ["name", "title", "lines"]);
187
+ const name = line(topic.name, `${at}.name`);
188
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) fail(`${at}.name: ${JSON.stringify(name)} is not a topic name`);
189
+ // A topic shares `entwurf help <name>` with the commands, so one word must answer with one page:
190
+ // no command's name, not `all`, and no other topic's.
191
+ if (name === "all" || entries.has(name) || topics.has(name))
192
+ fail(`${at}.name: ${JSON.stringify(name)} is already a command, \`all\` or another topic`);
193
+ const title = line(topic.title, `${at}.title`);
194
+ // Blank lines separate a page's parts, so they may be empty, but the page neither starts nor ends on one.
195
+ const lines = array(topic.lines, `${at}.lines`).map((l, i) => text(l, `${at}.lines[${i}]`, { empty: true }));
196
+ if (lines[0] === "" || lines.at(-1) === "") fail(`${at}.lines: must not start or end with an empty line`);
197
+ topics.set(name, { title, lines });
198
+ });
199
+
200
+ // A reader that stops early (`entwurf help all | head`) closes the pipe: that ends the page, it is
201
+ // not a failure to report.
202
+ process.stdout.on("error", (err) => {
203
+ if (err.code === "EPIPE") process.exit(0);
204
+ throw err;
205
+ });
206
+ if (wanted === undefined) {
207
+ process.stdout.write(`${(all ? out : compact).join("\n")}\n`);
208
+ } else {
209
+ const entry = entries.get(wanted);
210
+ const topic = topicsToo ? topics.get(wanted) : undefined;
211
+ // JSON quoting keeps any control character in the name on this one line.
212
+ if (!entry && !topic) {
213
+ refuse(`no help for ${JSON.stringify(wanted)} — \`entwurf help all\` lists the described commands`);
214
+ }
215
+ if (entry) {
216
+ const page = [`Usage: entwurf ${entry.synopsis}`, "", entry.summary];
217
+ if (entry.details.length > 0) page.push("", ...entry.details);
218
+ process.stdout.write(`${page.join("\n")}\n`);
219
+ } else {
220
+ process.stdout.write(`${[topic.title, "", ...topic.lines].join("\n")}\n`);
221
+ }
222
+ }
@@ -15,6 +15,14 @@
15
15
  # entwurf-agy-imprint -> scripts/agy-imprint.sh (agy PreInvocation birth hook)
16
16
  # entwurf-copilot-statusline -> scripts/copilot-statusline.sh (copilot settings.statusLine command)
17
17
  #
18
+ # SOURCE CHECKOUTS ONLY (#153). expose and remove refuse, exit 4, before their first write —
19
+ # legacy migration included — when THIS script belongs to an installed package: its own physical
20
+ # location (symlinks resolved, as run.sh resolves its own) under */node_modules/*, the same seam
21
+ # run.sh's setup_mode/run_ts branch on. Never cwd, never a target override. An installed package's
22
+ # commands come from its package manager's bin linking; the moved-checkout rule below is a
23
+ # developer's authority over its own checkouts and is not inherited by an installed root.
24
+ # verify and exclusive only read, so they stay available everywhere.
25
+ #
18
26
  # Ownership discipline (mirrors the agy adopt/refuse rule, 봉인 7), applied PER BIN:
19
27
  # - create/refresh ONLY a link that is OURS — absent, or a symlink into our target, or the
20
28
  # exact link our state recorded (target may have moved checkouts).
@@ -27,11 +35,13 @@
27
35
  # guess). New name appears first (atomic rename), old name is gone after. A re-appearing legacy
28
36
  # name on a later expose is re-reported (drift visibility).
29
37
  #
30
- # expose [name] create/refresh managed link(s) (idempotent; REFUSE foreign → exit 3).
38
+ # expose [name] create/refresh managed link(s) (idempotent; REFUSE foreign → exit 3;
39
+ # from an installed package → exit 4, nothing written).
31
40
  # no name = all managed bins, each attempted INDEPENDENTLY (#86 C1): one
32
41
  # foreign refusal no longer aborts the loop, the trailing summary reports
33
42
  # attempted/ok/refused truthfully, and any refusal still exits 3.
34
- # remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign).
43
+ # remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign;
44
+ # from an installed package → exit 4, nothing removed).
35
45
  # no name = all managed bins.
36
46
  # verify <name> require OUR symlink → target and that exact link as the PATH winner.
37
47
  # exclusive require that NO other root answers to any package.json bin name — not only
@@ -110,6 +120,31 @@ link_is_ours() {
110
120
  return 1 # regular file / dir → foreign
111
121
  }
112
122
 
123
+ # The package root this script belongs to, by its own physical location: a symlinked script or a
124
+ # directory alias still answers with the tree it actually lives in.
125
+ own_package_root() {
126
+ local src="${BASH_SOURCE[0]}" dir
127
+ while [ -L "$src" ]; do
128
+ dir="$(cd -P -- "$(dirname -- "$src")" && pwd)"
129
+ src="$(readlink "$src")"
130
+ case "$src" in /*) ;; *) src="$dir/$src" ;; esac
131
+ done
132
+ cd -P -- "$(dirname -- "$src")/.." && pwd
133
+ }
134
+
135
+ refuse_installed_mutation() { # $1 = verb; exits 4 when this script lives in an installed package
136
+ local root
137
+ root="$(own_package_root)"
138
+ case "$root" in
139
+ */node_modules/*)
140
+ warn "[dev-bin] REFUSE $1: this dev-bin.sh belongs to an installed package ($root)."
141
+ warn " dev-bin manages a SOURCE checkout's links only; an installed package's commands come from its package manager's bin linking."
142
+ warn " nothing was written or removed (no link, no state, no legacy migration)."
143
+ exit 4
144
+ ;;
145
+ esac
146
+ }
147
+
113
148
  path_has_bindir() { case ":$PATH:" in *":$BIN_DIR:"*) return 0 ;; *) return 1 ;; esac; }
114
149
 
115
150
  write_state() { # $1=state_file $2=link $3=target $4=detect
@@ -317,6 +352,7 @@ remove_one() { # $1 = name
317
352
  }
318
353
 
319
354
  do_expose() { # $1 = optional bin name
355
+ refuse_installed_mutation expose
320
356
  migrate_legacy
321
357
  if [ -n "${1:-}" ]; then expose_one "$1"; return $?; fi
322
358
  # No-arg = the setup composition. Attempt EVERY unit independently (#86 C1):
@@ -338,6 +374,7 @@ do_expose() { # $1 = optional bin name
338
374
  log "[dev-bin expose] summary: attempted=$attempted ok=$exposed refused=0"
339
375
  }
340
376
  do_remove() { # $1 = optional bin name
377
+ refuse_installed_mutation remove
341
378
  if [ -n "${1:-}" ]; then remove_one "$1"; else for b in $MANAGED_BINS; do remove_one "$b"; done; fi
342
379
  }
343
380
 
@@ -30,8 +30,16 @@ import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import { artifactStage, certifyArtifactIdentity, resolveRuntimeLayout, sameArtifactRequest } from "./herdr-runtime.mjs";
32
32
 
33
- /** Ledger format. Bump only with a reader that understands both. */
34
- export const ACTIVATION_SCHEMA_VERSION = 2;
33
+ /**
34
+ * Ledger formats this reader admits (#153). The key set is the same for both; 3 is a FLOOR, not a new
35
+ * shape: a reader that admits only 2 refuses a 3-ledger before its first write, which is how an older
36
+ * artifact is kept from acting on a host it no longer understands. Add a version only together with a
37
+ * reader that understands every version already in this set.
38
+ */
39
+ export const ACTIVATION_SCHEMA_VERSIONS = Object.freeze([2, 3]);
40
+
41
+ /** The version a ledger is born with. Every later write carries the version it read — never this. */
42
+ export const FRESH_ACTIVATION_SCHEMA_VERSION = 2;
35
43
 
36
44
  /** Set P, frozen. The ledger may never name anything else. */
37
45
  export const ACTIVATABLE_BACKENDS = Object.freeze(["pi", "claude-code"]);
@@ -140,10 +148,24 @@ function certifyRoot(where, value) {
140
148
  }
141
149
  }
142
150
 
143
- /** Ledgers are replaced, never patched: write beside, then rename. */
144
- export function writeLedger(layout, entry) {
151
+ /**
152
+ * Ledgers are replaced, never patched: write beside, then rename. The version is an explicit
153
+ * argument and the only one: an entry carrying its own `schemaVersion` is refused rather than
154
+ * merged, so no spread order can let a body silently raise or lower the floor. Both refusals come
155
+ * before the first byte.
156
+ */
157
+ export function writeLedger(layout, version, entry) {
158
+ if (!ACTIVATION_SCHEMA_VERSIONS.includes(version)) {
159
+ throw new ActivationError("activation-ledger-version-invalid", `schemaVersion ${JSON.stringify(version)}`);
160
+ }
161
+ if (Object.hasOwn(entry, "schemaVersion")) {
162
+ throw new ActivationError(
163
+ "activation-ledger-version-invalid",
164
+ "the entry carries its own schemaVersion; the version is the explicit argument alone",
165
+ );
166
+ }
145
167
  fs.mkdirSync(layout.stateRoot, { recursive: true });
146
- const body = `${JSON.stringify({ schemaVersion: ACTIVATION_SCHEMA_VERSION, ...entry }, null, 2)}\n`;
168
+ const body = `${JSON.stringify({ schemaVersion: version, ...entry }, null, 2)}\n`;
147
169
  const tmp = `${layout.ledgerPath}.tmp`;
148
170
  fs.writeFileSync(tmp, body);
149
171
  fs.renameSync(tmp, layout.ledgerPath);
@@ -190,7 +212,7 @@ export function readCertifiedLedger(layout) {
190
212
  if (Object.keys(parsed).sort().join(",") !== [...LEDGER_KEYS].sort().join(",")) {
191
213
  throw new ActivationError("activation-ledger-uncertified", `key set: ${Object.keys(parsed).sort()}`);
192
214
  }
193
- if (parsed.schemaVersion !== ACTIVATION_SCHEMA_VERSION) {
215
+ if (!ACTIVATION_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
194
216
  throw new ActivationError("activation-ledger-uncertified", `schemaVersion ${parsed.schemaVersion}`);
195
217
  }
196
218
  if (typeof parsed.runtimeRoot !== "string" || !path.isAbsolute(parsed.runtimeRoot)) {