@junghanacs/entwurf 0.41.1 → 0.45.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 (102) hide show
  1. package/AGENTS.md +2 -1
  2. package/BASELINE.md +7 -5
  3. package/CHANGELOG.md +344 -0
  4. package/DELIVERY.md +37 -20
  5. package/ENTWURF.json +236 -0
  6. package/README.md +75 -65
  7. package/VERIFY.md +9 -9
  8. package/docs/external-mcp-host.md +1 -1
  9. package/docs/setup-clean-host.md +59 -33
  10. package/mcp/entwurf-bridge/dist/ENTWURF.json +236 -0
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +32 -190
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +10 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +23 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +7 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +23 -24
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +15 -11
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +21 -10
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +4 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +5 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +29 -24
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -16
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +13 -2
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +2 -2
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +1 -1
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +25 -17
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +101 -32
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +57 -7
  29. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +23 -45
  30. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +12 -12
  31. package/mcp/entwurf-bridge/src/index.ts +126 -321
  32. package/native/win32-x64/entwurf_win.node +0 -0
  33. package/native/win32-x64/manifest.json +2 -2
  34. package/package.json +5 -4
  35. package/pi-extensions/lib/agent-guidance.ts +341 -0
  36. package/pi-extensions/lib/entwurf-self-address.ts +13 -0
  37. package/pi-extensions/lib/entwurf-v2-contract.ts +25 -2
  38. package/pi-extensions/lib/entwurf-v2-decider.ts +7 -0
  39. package/pi-extensions/lib/entwurf-v2-surface.ts +27 -30
  40. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +15 -16
  41. package/pi-extensions/lib/fresh-call-composition.ts +21 -10
  42. package/pi-extensions/lib/herdr-fresh-call.ts +4 -2
  43. package/pi-extensions/lib/meta-sender-identity.ts +4 -0
  44. package/pi-extensions/lib/mux-fresh-call.ts +29 -30
  45. package/pi-extensions/lib/mux-resume-call.ts +18 -19
  46. package/pi-extensions/lib/native-push/register.ts +17 -2
  47. package/pi-extensions/lib/omp-fresh-preflight.ts +2 -2
  48. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +1 -1
  49. package/pi-extensions/lib/remote-pair-receive.ts +27 -19
  50. package/pi-extensions/lib/remote-pair-send.ts +124 -34
  51. package/pi-extensions/meta-bridge-omp.ts +69 -7
  52. package/pi-extensions/meta-bridge-pi-durable.ts +23 -64
  53. package/run.sh +182 -22
  54. package/scripts/build-bridge.sh +10 -0
  55. package/scripts/check-agy-sender-identity.ts +6 -4
  56. package/scripts/check-capability-bundle-reach.ts +86 -0
  57. package/scripts/check-entwurf-self-address.ts +37 -6
  58. package/scripts/check-entwurf-v2-contract.ts +24 -2
  59. package/scripts/check-entwurf-v2-production.ts +45 -31
  60. package/scripts/check-entwurf-v2-surface.ts +94 -98
  61. package/scripts/check-gate-qualification.ts +17 -13
  62. package/scripts/check-mux-resume-call.ts +20 -3
  63. package/scripts/check-native-push-adapter.ts +4 -0
  64. package/scripts/check-native-push-register.ts +32 -82
  65. package/scripts/check-remote-pair-installed.ts +2 -2
  66. package/scripts/dev-bin.sh +112 -1
  67. package/scripts/lib/chain-terminus-publication.ts +58 -0
  68. package/scripts/lib/codex-fresh-live-protocol.ts +30 -0
  69. package/scripts/lib/codex-fresh-source-receipts.ts +112 -3
  70. package/scripts/lib/pi-durable-fresh-live-receipts.ts +29 -1
  71. package/scripts/mutants/agent-guidance.json +160 -0
  72. package/scripts/mutants/bridge-boot-resume.json +6 -8
  73. package/scripts/mutants/capability-bundle-reach.json +124 -0
  74. package/scripts/mutants/chain-terminus-publication.json +17 -0
  75. package/scripts/mutants/codex-native.json +98 -0
  76. package/scripts/mutants/control-send-receipt.json +23 -6
  77. package/scripts/mutants/mux-fresh-call.json +30 -4
  78. package/scripts/mutants/mux-resume-call.json +28 -3
  79. package/scripts/mutants/native-push-paused.json +17 -0
  80. package/scripts/mutants/omp-fresh.json +13 -2
  81. package/scripts/mutants/pack-install.json +46 -0
  82. package/scripts/mutants/pi-durable.json +17 -5
  83. package/scripts/mutants/remote-pair-display.json +3 -3
  84. package/scripts/mutants/remote-pair-receive.json +41 -11
  85. package/scripts/mutants/remote-pair-roundtrip.json +67 -7
  86. package/scripts/mutants/remote-pair-send.json +177 -19
  87. package/scripts/mutants/remote-pair-transport.json +4 -4
  88. package/scripts/mutants/self-address.json +10 -10
  89. package/scripts/mutants/source-install.json +66 -0
  90. package/scripts/mutants/v2-surface.json +50 -64
  91. package/scripts/mutants/v2-visible-resume.json +11 -0
  92. package/scripts/remote-pair-receive.ts +12 -12
  93. package/scripts/smoke-agy-install-state.sh +121 -0
  94. package/scripts/smoke-agy-native-push-live.ts +13 -0
  95. package/scripts/smoke-codex-fresh-live.ts +50 -27
  96. package/scripts/smoke-entwurf-chain-live.ts +7 -9
  97. package/scripts/smoke-herdr-fresh-call-live.ts +4 -4
  98. package/scripts/smoke-herdr-plugin-build-live.ts +179 -50
  99. package/scripts/smoke-mux-lifecycle-live.ts +140 -8
  100. package/scripts/smoke-omp-fresh-live.ts +5 -4
  101. package/scripts/smoke-pi-durable-fresh-live.ts +29 -7
  102. package/scripts/smoke-setup-verdict.sh +27 -2
@@ -36,6 +36,7 @@ import fs from "node:fs";
36
36
  import os from "node:os";
37
37
  import path from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
+ import { ENTWURF_RESUME_LAUNCH_REJECT_HINTS, ENTWURF_TOOL_GUIDANCE } from "../pi-extensions/lib/agent-guidance.ts";
39
40
  import { classifyTmuxCwd } from "../pi-extensions/lib/classify-tmux-cwd.ts";
40
41
  import type { Placement } from "../pi-extensions/lib/mux-placement.ts";
41
42
  import {
@@ -216,6 +217,12 @@ function main(): void {
216
217
  "every reject reason has a hint a caller can act on — a reason without one is a reason they will guess about",
217
218
  reasons.every((r) => typeof RESUME_CALL_REJECT_HINT[r] === "string" && RESUME_CALL_REJECT_HINT[r].length > 0),
218
219
  );
220
+ ok(
221
+ "[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",
222
+ reasons.every((r) => RESUME_CALL_REJECT_HINT[r] === ENTWURF_RESUME_LAUNCH_REJECT_HINTS[r]) &&
223
+ JSON.stringify(Object.keys(RESUME_CALL_REJECT_HINT).sort()) === JSON.stringify([...reasons].sort()) &&
224
+ JSON.stringify(Object.keys(ENTWURF_RESUME_LAUNCH_REJECT_HINTS).sort()) === JSON.stringify([...reasons].sort()),
225
+ );
219
226
  ok(
220
227
  "[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
228
  JSON.stringify(RESUME_CALL_RUNTIME) === JSON.stringify({ pi: "pi", "claude-code": "claude" }),
@@ -227,9 +234,19 @@ function main(): void {
227
234
  "[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
235
  !/gardenId|readAddressableMetaIdentity|acquireLock|transcriptPath/.test(SRC),
229
236
  );
237
+ // Every static form (from / bare / export-from) is parsed and dynamic loading is refused, so a
238
+ // fourth lane cannot enter through a shape the parser does not see. The one non-mux member is the
239
+ // node:fs-only guidance reader (#154 C1-D), whose own leaf-ness is GUIDANCE-READER-LEAF-IMPORTS.
240
+ const resumeImports = [
241
+ ...SRC.matchAll(/^\s*(?:import|export)\s[^;]*?\sfrom\s+["']([^"']+)["']/gm),
242
+ ...SRC.matchAll(/^\s*import\s+["']([^"']+)["']/gm),
243
+ ].map((m) => m[1]);
230
244
  ok(
231
- "it imports only the mux lane — no entwurf core, no delivery",
232
- !/from "\.\/entwurf-/.test(SRC) && !/meta-session/.test(SRC),
245
+ "[QK:MUXRESUME-IMPORT-SET] it imports exactly the mux lane plus the guidance reader — no entwurf core, no delivery, no dynamic import",
246
+ JSON.stringify([...resumeImports].sort()) ===
247
+ JSON.stringify(["./agent-guidance.ts", "./classify-tmux-cwd.ts", "./mux-launch.ts", "./mux-placement.ts"]) &&
248
+ !/\bimport\s*\(|\brequire\s*\(/.test(SRC) &&
249
+ !/meta-session/.test(SRC),
233
250
  );
234
251
  ok(
235
252
  "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",
@@ -273,7 +290,7 @@ function main(): void {
273
290
 
274
291
  // ── the description contract ────────────────────────────────────────────────
275
292
  // 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];
293
+ const MCP_DESC = ENTWURF_TOOL_GUIDANCE.entwurf_resume_call.descriptions.mcp;
277
294
  const statesTheContract = (text: string): boolean =>
278
295
  /dormant/i.test(text) && /submits NO prompt/i.test(text) && /observation/i.test(text) && /launch/i.test(text);
279
296
 
@@ -18,6 +18,10 @@
18
18
  * - resolver fail-fast: resolveNativePushAdapter("antigravity") resolves; unknown throws.
19
19
  *
20
20
  * Pure; no backend, no socket, no real process.
21
+ *
22
+ * #152: this is the adapter LEAF only. Public agy delivery, sender replyability and
23
+ * registration are paused before any adapter is resolved; a green here is not agy
24
+ * availability and not a live pass.
21
25
  */
22
26
 
23
27
  import assert from "node:assert/strict";
@@ -4,17 +4,16 @@
4
4
  * agy) and an ISOLATED temp meta-record store (never the real ~/.pi).
5
5
  *
6
6
  * Proves:
7
- * - live probe → CREATE: a fresh live conversation mints a garden citizen whose meta-record
8
- * carries the backend / nativeSessionId / caller-stated cwd; the action is "create".
9
- * - re-register → ATTACH: the same conversation resolves to the SAME garden id and REFRESHES
10
- * the cwd (봉인 5) — it never mints a second id.
11
- * - not-live probe → REFUSE: a dead / indeterminate probe THROWS and writes NO record (no
12
- * garden id is engraved onto a pointer that does not resolve to a live host).
7
+ * - #152 PAUSE: antigravity — the only registerable backend — is refused by name BEFORE any
8
+ * adapter is resolved or probed, and NO record is written. While the pause holds, the create /
9
+ * attach / refuse-not-live mechanics behind that guard are unreachable, so this gate no longer
10
+ * claims them; they return with the pause's lift.
11
+ * - the paused-policy check sits BEFORE the probe in the register source.
13
12
  * - RECEIVER-MARKER ABSTINENCE (보정①): the register source references NO receiver-marker
14
13
  * writer — a native-push citizen has no idle-wake watch, so arming one would smuggle
15
14
  * native-push liveness into the mailbox deliverability atom.
16
15
  *
17
- * Deterministic (fake adapter); the only IO is an isolated mkdtemp store (like the meta gates).
16
+ * Deterministic (trap adapter); the only IO is an isolated mkdtemp store (like the meta gates).
18
17
  */
19
18
 
20
19
  import assert from "node:assert/strict";
@@ -22,8 +21,6 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
22
21
  import * as os from "node:os";
23
22
  import * as path from "node:path";
24
23
  import { fileURLToPath } from "node:url";
25
- import { readMetaIdentityByGardenId } from "../pi-extensions/lib/meta-session.ts";
26
- import type { NativePushAdapter, NativePushProbeResult } from "../pi-extensions/lib/native-push/adapter.ts";
27
24
  import { registerNativeConversation } from "../pi-extensions/lib/native-push/register.ts";
28
25
 
29
26
  let passed = 0;
@@ -35,83 +32,31 @@ function ok(label: string, cond: boolean): void {
35
32
 
36
33
  const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
37
34
 
38
- /** A fake native-push adapter whose probe returns a fixed result and whose send is unused. */
39
- function fakeAdapter(probe: NativePushProbeResult): NativePushAdapter {
40
- return {
41
- id: "antigravity",
42
- retriable: true,
43
- async probe() {
44
- return probe;
45
- },
46
- async send() {
47
- throw new Error("register gate: adapter.send must not be called during registration");
48
- },
49
- };
50
- }
51
-
52
- const ALIVE: NativePushProbeResult = {
53
- status: "alive",
54
- route: { backend: "antigravity", lsAddress: "127.0.0.1:5599" },
55
- };
56
-
57
35
  async function main(): Promise<void> {
58
36
  const dir = mkdtempSync(path.join(os.tmpdir(), "entwurf-register-"));
59
37
  try {
60
- // ── live probe → CREATE ───────────────────────────────────────────────────
61
- const created = await registerNativeConversation(
62
- { backend: "antigravity", nativeSessionId: "conv-abc", cwd: "/work/one" },
63
- { resolveAdapter: () => fakeAdapter(ALIVE), sessionsDir: dir },
64
- );
65
- ok("create: action = create", created.action === "create");
66
- ok("create: gardenId shaped YYYYMMDDTHHMMSS-xxxxxx", /^\d{8}T\d{6}-[0-9a-f]{6}$/.test(created.gardenId));
67
- ok("create: backend antigravity", created.backend === "antigravity");
68
- ok("create: nativeSessionId echoed", created.nativeSessionId === "conv-abc");
69
- ok("create: cwd = caller-stated", created.cwd === "/work/one");
70
- // the on-disk record agrees (read back through the authority).
71
- const rec = readMetaIdentityByGardenId(created.gardenId, dir);
72
- ok("create: record backend antigravity", rec.backend === "antigravity");
73
- ok("create: record nativeSessionId bound", rec.nativeSessionId === "conv-abc");
74
- ok("create: record cwd recorded", rec.cwd === "/work/one");
75
-
76
- // ── re-register same conversation → ATTACH (same gid, cwd refreshed) ───────
77
- const reattached = await registerNativeConversation(
78
- { backend: "antigravity", nativeSessionId: "conv-abc", cwd: "/work/two" },
79
- { resolveAdapter: () => fakeAdapter(ALIVE), sessionsDir: dir },
80
- );
81
- ok("attach: action = attach", reattached.action === "attach");
82
- ok("attach: SAME garden id (no second mint)", reattached.gardenId === created.gardenId);
83
- ok(
84
- "attach: cwd REFRESHED to the new value",
85
- reattached.gardenId === created.gardenId && reattached.cwd === "/work/two",
86
- );
87
- const rec2 = readMetaIdentityByGardenId(created.gardenId, dir);
88
- ok("attach: on-disk cwd refreshed", rec2.cwd === "/work/two");
89
- // exactly ONE record file for this conversation (no duplicate mint).
90
- const records = readdirSync(dir).filter((f) => f.endsWith(".meta.json"));
91
- ok("attach: exactly ONE meta-record (no duplicate garden id)", records.length === 1);
92
-
93
- // ── not-live probe → REFUSE (throw), NO record written ────────────────────
94
- for (const probe of [
95
- { status: "dead", reason: "no host" } as const,
96
- { status: "indeterminate", reason: "no port served" } as const,
97
- ]) {
98
- const before = readdirSync(dir).length;
99
- let threw = false;
100
- try {
101
- await registerNativeConversation(
102
- { backend: "antigravity", nativeSessionId: "conv-ghost", cwd: "/work/x" },
103
- { resolveAdapter: () => fakeAdapter(probe), sessionsDir: dir },
104
- );
105
- } catch (err) {
106
- threw = true;
107
- ok(
108
- `refuse(${probe.status}): error names the non-live probe`,
109
- new RegExp(probe.status).test((err as Error).message),
110
- );
111
- }
112
- ok(`refuse(${probe.status}): THROWS (no garden id for a non-live conversation)`, threw);
113
- ok(`refuse(${probe.status}): NO record written`, readdirSync(dir).length === before);
38
+ // ── #152: antigravity is PAUSED → REFUSE by name, no adapter, NO record ──────
39
+ let resolved = 0;
40
+ const before = readdirSync(dir).length;
41
+ let message = "";
42
+ try {
43
+ await registerNativeConversation(
44
+ { backend: "antigravity", nativeSessionId: "conv-abc", cwd: "/work/one" },
45
+ {
46
+ resolveAdapter: () => {
47
+ resolved++;
48
+ throw new Error("register gate: no adapter may be resolved for a paused backend");
49
+ },
50
+ sessionsDir: dir,
51
+ },
52
+ );
53
+ } catch (err) {
54
+ message = (err as Error).message;
114
55
  }
56
+ ok("paused: register THROWS for antigravity", message !== "");
57
+ ok("paused: the error names the pause (#152)", /paused/.test(message) && /#152/.test(message));
58
+ ok("paused: no adapter resolved (nothing probed)", resolved === 0);
59
+ ok("paused: NO record written", readdirSync(dir).length === before);
115
60
  } finally {
116
61
  rmSync(dir, { recursive: true, force: true });
117
62
  }
@@ -127,6 +72,11 @@ async function main(): Promise<void> {
127
72
  // it DOES reuse the upsert authority (봉인 5) and the adapter probe.
128
73
  ok("wiring: register reuses upsertMetaSession", /upsertMetaSession/.test(src));
129
74
  ok("wiring: register probes the adapter before minting", /\.probe\(/.test(src));
75
+ ok(
76
+ "wiring: the paused-policy check precedes the adapter probe",
77
+ src.indexOf("nativePushPaused(input.backend)") > 0 &&
78
+ src.indexOf("nativePushPaused(input.backend)") < src.indexOf(".probe("),
79
+ );
130
80
 
131
81
  console.log(`\ncheck-native-push-register: ${passed} assertions passed`);
132
82
  }
@@ -190,8 +190,8 @@ try {
190
190
  );
191
191
  const summary = (vitest.stdout.match(/Tests\s+.*$/m) ?? [""])[0].trim();
192
192
  ok(
193
- "[QK:RPI-ROUNDTRIP-ON-INSTALL] the T2 cells (A→B, reverse dispatch, opt-in absent off, other host refuses, both socket dirs) pass against the installed bridge and entry",
194
- vitest.status === 0 && /^Tests\s+5 passed\b/.test(summary),
193
+ "[QK:RPI-ROUNDTRIP-ON-INSTALL] the T2 cells (A→B, reverse dispatch, opt-in absent off, other host refuses, both socket dirs, and the #149 three-route cells: independent, reverse, undeclared sender, cross claim) pass against the installed bridge and entry",
194
+ vitest.status === 0 && /^Tests\s+9 passed\b/.test(summary),
195
195
  `${summary}\n${vitest.stdout
196
196
  .split("\n")
197
197
  .filter((l) => /×|FAIL|Error/.test(l))
@@ -34,6 +34,14 @@
34
34
  # remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign).
35
35
  # no name = all managed bins.
36
36
  # verify <name> require OUR symlink → target and that exact link as the PATH winner.
37
+ # exclusive require that NO other root answers to any package.json bin name — not only
38
+ # the PATH winner. A winner check alone let a later PATH entry (an old global
39
+ # shim) stay selectable by a shell with another PATH order, e.g. non-interactive
40
+ # SSH. Read-only: every entry of this PATH plus the global bin dir that `pnpm bin
41
+ # -g` / `npm prefix -g` report. Same resolved target = the same install (allowed);
42
+ # anything else is another root, never guessed as ours. exit 0 none / 5 another
43
+ # root (named, with a conditional removal hint — nothing is removed) / 6 the
44
+ # package manifest or a package manager could not be inspected (fail-closed).
37
45
  #
38
46
  # Overridable for the isolated smoke:
39
47
  # ENTWURF_DEV_BIN_DIR link location (default: ~/.local/bin — where agy itself lives)
@@ -42,6 +50,7 @@
42
50
  # ENTWURF_AGY_STATUSLINE_TARGET entwurf-agy-statusline target (default: $REPO/scripts/agy-statusline.sh)
43
51
  # ENTWURF_AGY_IMPRINT_TARGET entwurf-agy-imprint target (default: $REPO/scripts/agy-imprint.sh)
44
52
  # ENTWURF_COPILOT_STATUSLINE_TARGET entwurf-copilot-statusline target (default: $REPO/scripts/copilot-statusline.sh)
53
+ # ENTWURF_DEV_BIN_PM_TIMEOUT seconds one package-manager query may take (default 10)
45
54
  # XDG_DATA_HOME state root (states under $XDG_DATA_HOME/entwurf/dev-bin/)
46
55
  set -euo pipefail
47
56
 
@@ -68,6 +77,9 @@ bin_target() { # $1 = name → prints target path (env-overridable for smoke)
68
77
  *) return 1 ;;
69
78
  esac
70
79
  }
80
+ # Every name bin_target answers for. Kept apart from MANAGED_BINS (what expose links): whether a
81
+ # name is exposed must not change the target exclusive expects for it.
82
+ TARGET_BINS="entwurf entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint entwurf-copilot-statusline"
71
83
  state_file_for() { echo "$STATE_DIR/$1.install-state.json"; }
72
84
 
73
85
  state_link() { # $1 = state file → recorded linkPath, or empty
@@ -184,6 +196,104 @@ verify_one() { # $1 = name
184
196
  log "[dev-bin verify] $name → $target (PATH winner: $link)"
185
197
  }
186
198
 
199
+ do_exclusive() {
200
+ # Our target per package.json bin name: a managed name through bin_target (smoke-overridable),
201
+ # every other name at its package.json path in this checkout. The manifest is read INSIDE the
202
+ # verdict step: a read that fails outside it would reach the verdict as zero names and pass.
203
+ local -a pairs=()
204
+ local b
205
+ for b in $TARGET_BINS; do pairs+=("$b=$(bin_target "$b")"); done
206
+ python3 - "${ENTWURF_DEV_BIN_PM_TIMEOUT:-10}" "$REPO_DIR" "${pairs[@]}" <<'PY'
207
+ import json, os, shutil, signal, subprocess, sys
208
+
209
+ timeout = float(sys.argv[1])
210
+ manifest = os.path.join(sys.argv[2], "package.json")
211
+ try:
212
+ with open(manifest) as f:
213
+ bins = json.load(f)["bin"]
214
+ if not (isinstance(bins, dict) and "entwurf" in bins
215
+ and all(isinstance(k, str) and k and isinstance(v, str) and v for k, v in bins.items())):
216
+ raise ValueError('"bin" is not a non-empty object of name -> path strings naming entwurf')
217
+ except (OSError, ValueError, KeyError, TypeError) as e:
218
+ print(f"[dev-bin] FAIL: could not inspect the package manifest {manifest} — {type(e).__name__}: {e}."
219
+ " Not read as 'no other root'.", file=sys.stderr)
220
+ print(" nothing was removed, no profile edited, no session stopped.", file=sys.stderr)
221
+ sys.exit(6) # an unreadable manifest is never "0 names, no other root"
222
+ managed = dict(pair.split("=", 1) for pair in sys.argv[3:])
223
+ ours = {name: os.path.realpath(managed.get(name, os.path.join(sys.argv[2], rel)))
224
+ for name, rel in bins.items()}
225
+
226
+ dirs = {} # realpath(dir) -> (dir as seen, [where it was seen])
227
+ def add(d, where):
228
+ key = os.path.realpath(d)
229
+ dirs.setdefault(key, (d, []))[1].append(where)
230
+
231
+ for d in os.environ.get("PATH", "").split(":"):
232
+ add(d or os.getcwd(), "PATH") # an empty POSIX PATH entry means the cwd
233
+
234
+ def query(argv):
235
+ exe = shutil.which(argv[0])
236
+ if exe is None:
237
+ return "skip", f"{argv[0]} not on PATH — its global bin dir was not checked"
238
+ try:
239
+ p = subprocess.Popen([exe] + argv[1:], cwd="/", stdin=subprocess.DEVNULL,
240
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
241
+ except OSError as e:
242
+ return "error", f"`{' '.join(argv)}` could not start: {e}"
243
+ try:
244
+ out, err = p.communicate(timeout=timeout)
245
+ except subprocess.TimeoutExpired:
246
+ os.killpg(p.pid, signal.SIGKILL)
247
+ p.communicate()
248
+ return "error", f"`{' '.join(argv)}` did not answer within {timeout:g}s"
249
+ if p.returncode != 0:
250
+ tail = err.decode(errors="replace").strip().splitlines()[-1:] or [""]
251
+ return "error", f"`{' '.join(argv)}` exited {p.returncode}: {tail[0]}"
252
+ lines = out.decode(errors="replace").strip().splitlines()
253
+ if len(lines) != 1 or not os.path.isabs(lines[0]):
254
+ return "error", f"`{' '.join(argv)}` printed {len(lines)} line(s), not one absolute path"
255
+ return "ok", lines[0]
256
+
257
+ checked, skipped, errors = ["PATH"], [], []
258
+ for argv, label, to_bin in ((["pnpm", "bin", "-g"], "pnpm global bin", lambda s: s),
259
+ (["npm", "prefix", "-g"], "npm global bin", lambda s: os.path.join(s, "bin"))):
260
+ state, value = query(argv)
261
+ if state == "ok":
262
+ add(to_bin(value), label)
263
+ checked.append(f"{label} {to_bin(value)}")
264
+ elif state == "skip":
265
+ skipped.append(value)
266
+ else:
267
+ errors.append(value)
268
+
269
+ others = []
270
+ for _key, (d, where) in dirs.items():
271
+ for name, target in ours.items():
272
+ path = os.path.join(d, name)
273
+ if os.path.lexists(path) and os.path.realpath(path) != target:
274
+ others.append((path, os.path.realpath(path), where))
275
+
276
+ for path, resolved, where in others:
277
+ print(f"[dev-bin] FAIL: another entwurf root is still selectable: {path} -> {resolved}", file=sys.stderr)
278
+ print(f" seen via: {', '.join(where)}", file=sys.stderr)
279
+ hints = [h for w, h in (("pnpm global bin", "pnpm remove -g @junghanacs/entwurf"),
280
+ ("npm global bin", "npm uninstall -g @junghanacs/entwurf")) if w in where]
281
+ for h in hints:
282
+ print(f" if a global @junghanacs/entwurf installed it: {h}", file=sys.stderr)
283
+ print(" " + ("otherwise " if hints else "") + "remove it through whoever owns it.", file=sys.stderr)
284
+ for e in errors:
285
+ print(f"[dev-bin] FAIL: could not inspect a package manager — {e}. Not read as 'no other root'.", file=sys.stderr)
286
+ if others or errors:
287
+ print(f" this checkout: {ours.get('entwurf', '?')}", file=sys.stderr)
288
+ print(" nothing was removed, no profile edited, no session stopped. Then re-run: ./run.sh setup <project>", file=sys.stderr)
289
+ sys.exit(5 if others else 6)
290
+ print(f"[dev-bin exclusive] no other root under the {len(ours)} package bin names in: {'; '.join(checked)}")
291
+ for s in skipped:
292
+ print(f" not checked: {s}")
293
+ print(" covers only these directories — another shell's PATH or another package manager is not covered.")
294
+ PY
295
+ }
296
+
187
297
  remove_one() { # $1 = name
188
298
  local name="$1" target link sf recorded
189
299
  target="$(bin_target "$name")" || fail "unknown managed bin: $name"
@@ -235,5 +345,6 @@ case "${1:-}" in
235
345
  expose) shift; do_expose "${1:-}" ;;
236
346
  remove) shift; do_remove "${1:-}" ;;
237
347
  verify) shift; [ -n "${1:-}" ] || fail "verify requires one managed bin name"; verify_one "$1" ;;
238
- *) echo "usage: dev-bin.sh <expose|remove> [bin-name] | verify <bin-name>" >&2; exit 2 ;;
348
+ exclusive) do_exclusive ;;
349
+ *) echo "usage: dev-bin.sh <expose|remove> [bin-name] | verify <bin-name> | exclusive" >&2; exit 2 ;;
239
350
  esac
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The terminus wait of `smoke-entwurf-chain-live`, as one function with its fs seams injectable, so
3
+ * the oracle that judges a live chain can be driven offline through the exact publication states.
4
+ *
5
+ * `enqueueMetaMessage` (pi-extensions/lib/meta-session.ts) publishes in two writes: the `.msg` body
6
+ * FIRST, then — after the state stamp — `inbox.signal` LAST. A reader can therefore see the body
7
+ * without the signal for a few milliseconds. The P5 failure on 2026-10-24 is consistent with that by
8
+ * source and by its preserved mailbox: the old wait stopped at the first body and checked the signal
9
+ * at once, and the signal file was written about 5 ms after the body. Its interleaving was not traced.
10
+ * A body without the signal is a publication still in flight, so the wait ends only when both are
11
+ * there, inside the same deadline. What the smoke asserts afterwards (exactly one body, the signal,
12
+ * every identity and read-receipt check) is unchanged and still fails on a missing body, a missing
13
+ * signal or a duplicate.
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import fsp from "node:fs/promises";
17
+ import path from "node:path";
18
+
19
+ export interface TerminusPublicationIo {
20
+ readdir(dir: string): Promise<string[]>;
21
+ exists(file: string): boolean;
22
+ sleep(ms: number): Promise<void>;
23
+ now(): number;
24
+ }
25
+
26
+ const realIo: TerminusPublicationIo = {
27
+ readdir: (dir) => fsp.readdir(dir),
28
+ exists: (file) => existsSync(file),
29
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
30
+ now: () => Date.now(),
31
+ };
32
+
33
+ export interface TerminusPublication {
34
+ /** Every `.msg` / `.msg.delivered` seen at the last look, so a duplicate stays visible. */
35
+ msgs: string[];
36
+ /** Whether `inbox.signal` existed at the last look. */
37
+ signalled: boolean;
38
+ }
39
+
40
+ /** Wait until the terminus mailbox holds a body AND its doorbell, or the deadline passes. */
41
+ export async function awaitTerminusPublication(
42
+ boxDir: string,
43
+ untilMs: number,
44
+ pollMs: number,
45
+ io: TerminusPublicationIo = realIo,
46
+ ): Promise<TerminusPublication> {
47
+ let msgs: string[] = [];
48
+ let signalled = false;
49
+ while (io.now() < untilMs) {
50
+ msgs = (await io.readdir(boxDir).catch(() => [] as string[])).filter(
51
+ (f) => f.endsWith(".msg") || f.endsWith(".msg.delivered"),
52
+ );
53
+ signalled = io.exists(path.join(boxDir, "inbox.signal"));
54
+ if (msgs.length > 0 && signalled) break;
55
+ await io.sleep(pollMs);
56
+ }
57
+ return { msgs, signalled };
58
+ }
@@ -70,6 +70,36 @@ export function buildCodexInstruction(input: CodexInstructionInput): string {
70
70
  );
71
71
  }
72
72
 
73
+ /**
74
+ * The initial Pi's own fresh-call task: its bootstrap, then a passive wait.
75
+ *
76
+ * `[GLG 직접, 2026-10-24]` a fresh sibling may check its caller with the public read-only listing,
77
+ * then calls back, then works — the same promise the shared framing makes. This task used to say
78
+ * "Your FIRST tool call must be … callback … and nothing before it" and forbade rechecking
79
+ * "identity with shell or other tools", which forbade the very check the framing offers. What it
80
+ * still asks for is narrower and fixture-specific: no shell recheck of what session-info already
81
+ * says, the zero-argument callback (no target or nonce to copy — the address rides the launch
82
+ * env), no other tool before it, and no tool used to wait. `[측정 2026-10-24, r3]` a `bash true`
83
+ * before the callback is still refused by the order audit (`assertBootstrapOrder`).
84
+ *
85
+ * THIS ONE LAUNCH TAKES THE OPTION. `[GLG 직접, 2026-10-24]` the shared framing OFFERS the check; the
86
+ * four impact LIVEs that followed measured it taken zero times, so the positive path had only unit
87
+ * evidence. This guided task asks the initial Pi to take it — one peers call, its result, then the
88
+ * callback — and the smoke requires a peers prefix of at least one (`minPeersPrefix`). It is a
89
+ * fixture's choice of the offered option, not a product rule, and a PASS here is evidence that the
90
+ * path works end to end, not evidence of any sibling's own judgement.
91
+ */
92
+ export function buildInitialPiBootstrapTask(initialPiWaitToken: string): string {
93
+ return (
94
+ "Session-info already supplies device, time and cwd; do not recheck them with shell. " +
95
+ "This launch is a guided caller check: first call mcp__entwurf_bridge__entwurf_peers once (read-only, no " +
96
+ "arguments) to corroborate your caller, and only after its result call mcp__entwurf_bridge__entwurf_callback " +
97
+ "with no arguments. Use no other tool before the callback. " +
98
+ `After your required callback receipt, end the turn and wait passively for one addressed message containing ${initialPiWaitToken}. ` +
99
+ "That message is the only authority to open the Codex sibling. Do not call shell, sleep, terminal, or any tool to wait."
100
+ );
101
+ }
102
+
73
103
  export interface InitialPiPhaseOneInput {
74
104
  initialPiWaitToken: string;
75
105
  codexWaitToken: string;
@@ -9,6 +9,11 @@ export interface SourceToolReceipt {
9
9
  text: string;
10
10
  status: SourceToolStatus;
11
11
  isError: boolean;
12
+ /** Pi only: the transcript row index of this call's assistant message. Rows are appended in
13
+ * order, so this is a source fact about WHEN the call was recorded. Codex leaves it unset. */
14
+ callOrdinal?: number;
15
+ /** Pi only: the transcript row index of this call's toolResult. Unset while pending and on Codex. */
16
+ resultOrdinal?: number;
12
17
  }
13
18
 
14
19
  export interface ExpectedSourceToolCall {
@@ -104,6 +109,107 @@ export function assertExactSourceToolCalls(
104
109
  if (remaining.length > 0) throw new Error(`${label}: missing source calls ${JSON.stringify(remaining)}`);
105
110
  }
106
111
 
112
+ /** The callback a fresh sibling makes before its task, and the one read-only check it may make first. */
113
+ export interface BootstrapOrderExpectation {
114
+ /** Exact arguments of the read-only `entwurf_peers` check the framing offers before the callback. */
115
+ peersArguments: Record<string, unknown>;
116
+ /** The callback role: zero-argument `entwurf_callback`, or Codex's `entwurf_v2` to the caller. */
117
+ callback: ExpectedSourceToolCall;
118
+ /** A GUIDED launch that asked for the check requires at least this many peers checks. Absent = 0:
119
+ * the check is optional everywhere else, and a fixture's guided task is not a product rule. */
120
+ minPeersPrefix?: number;
121
+ }
122
+
123
+ /**
124
+ * Bootstrap arguments: `entwurf_v2` keeps its schema default (absent `wants_reply` IS false — see
125
+ * `normalizeSourceArguments`), because Codex's callback IS that verb and a model that omits the key
126
+ * has obeyed it. Every other verb compared here is byte-exact: `entwurf_callback` is ZERO-argument,
127
+ * so `{ wants_reply: false }` is a model supplying a parameter the verb does not have, and the peers
128
+ * check is held to the exact call the framing offers.
129
+ */
130
+ function bootstrapArgumentsMatch(toolName: string, expected: Record<string, unknown>, actual: Record<string, unknown>) {
131
+ return toolName === "entwurf_v2" ? sourceArgumentsMatch(expected, actual) : isDeepStrictEqual(expected, actual);
132
+ }
133
+
134
+ /**
135
+ * The bootstrap order of one fresh sibling, judged on its source receipts IN CALL ORDER:
136
+ * zero or more successful exact `entwurf_peers` checks, then exactly one successful exact callback,
137
+ * then the rest — which is returned for the existing exact-once role audit. `[GLG 직접, 2026-10-24]`
138
+ * checking the caller and then calling back is the right order; nothing else may come first.
139
+ *
140
+ * Refused: any other call before the callback (a `bash true`, a task role), a peers check with other
141
+ * arguments or that did not succeed, a missing / pending / failed callback, a callback with drifted
142
+ * arguments, a peers check or a second exact callback AFTER the callback, and — where the source
143
+ * records it — a callback CALLED before a peers check's RESULT was recorded, or a later call recorded
144
+ * before the callback's RESULT.
145
+ *
146
+ * WHAT THE ORDER PROVES DEPENDS ON THE SOURCE. Pi rows carry `callOrdinal`/`resultOrdinal`, so on Pi
147
+ * this proves every peers result was recorded before the callback was called, and the callback's
148
+ * result before every later call. Codex `thread/read` gives item order and a status read at audit
149
+ * time, nothing more, so on Codex this proves CALL ORDER and completion-at-audit only; that a peers
150
+ * result preceded the callback call, and that the callback result preceded the next call, are both
151
+ * UNVERIFIED there.
152
+ * The peers check is never filtered out by position: it is admitted only as a prefix.
153
+ *
154
+ * SCOPE. This is the audit's condition for the short fixture tasks that call it ("use no other tool
155
+ * before the callback"), not a universal list of what a fresh sibling may do; a sibling whose own
156
+ * check does not hold up may also hold the callback, which these fixtures then see as no callback.
157
+ */
158
+ export function assertBootstrapOrder(
159
+ receipts: readonly SourceToolReceipt[],
160
+ expected: BootstrapOrderExpectation,
161
+ label: string,
162
+ ): SourceToolReceipt[] {
163
+ let at = 0;
164
+ for (; at < receipts.length && receipts[at]?.toolName === "entwurf_peers"; at++) {
165
+ const check = receipts[at] as SourceToolReceipt;
166
+ if (!isDeepStrictEqual(check.arguments, expected.peersArguments))
167
+ throw new Error(
168
+ `${label}: pre-callback entwurf_peers arguments differ\nexpected=${JSON.stringify(expected.peersArguments)}\nactual=${JSON.stringify(check.arguments)}`,
169
+ );
170
+ if (check.status !== "completed" || check.isError)
171
+ throw new Error(`${label}: pre-callback entwurf_peers ${check.isError ? "failed" : check.status}`);
172
+ }
173
+ const callback = receipts[at];
174
+ if (callback === undefined) throw new Error(`${label}: no callback after ${at} pre-callback check(s)`);
175
+ const minPeers = expected.minPeersPrefix ?? 0;
176
+ if (at < minPeers)
177
+ throw new Error(`${label}: the guided caller check was not made (peers prefix=${at}, required ≥ ${minPeers})`);
178
+ const role = expected.callback;
179
+ if (
180
+ callback.toolName !== role.toolName ||
181
+ !bootstrapArgumentsMatch(role.toolName, role.arguments, callback.arguments)
182
+ )
183
+ throw new Error(
184
+ `${label}: unexpected pre-callback call ${callback.toolName} ${JSON.stringify(callback.arguments)} — only the read-only entwurf_peers check may precede ${role.toolName} ${JSON.stringify(role.arguments)}`,
185
+ );
186
+ if (callback.status !== "completed" || callback.isError)
187
+ throw new Error(`${label}: the callback ${callback.isError ? "failed" : `remained ${callback.status}`}`);
188
+ // A check is a check only if its answer was in hand before the callback was made.
189
+ for (const check of receipts.slice(0, at)) {
190
+ if (
191
+ check.resultOrdinal !== undefined &&
192
+ callback.callOrdinal !== undefined &&
193
+ !(check.resultOrdinal < callback.callOrdinal)
194
+ )
195
+ throw new Error(`${label}: ${callback.toolName} was called before the entwurf_peers result was recorded`);
196
+ }
197
+ const rest = receipts.slice(at + 1);
198
+ for (const later of rest) {
199
+ if (later.toolName === "entwurf_peers")
200
+ throw new Error(`${label}: entwurf_peers after the callback — the check is offered only before it`);
201
+ if (later.toolName === role.toolName && bootstrapArgumentsMatch(role.toolName, role.arguments, later.arguments))
202
+ throw new Error(`${label}: duplicate callback ${later.toolName}`);
203
+ if (
204
+ callback.resultOrdinal !== undefined &&
205
+ later.callOrdinal !== undefined &&
206
+ later.callOrdinal < callback.resultOrdinal
207
+ )
208
+ throw new Error(`${label}: ${later.toolName} was called before the callback result was recorded`);
209
+ }
210
+ return rest;
211
+ }
212
+
107
213
  /** The only production leaf that turns joined source receipts into cleanup window authority. */
108
214
  export function sourceCleanupWindows(receipts: readonly SourceToolReceipt[]): string[] {
109
215
  const windows: string[] = [];
@@ -199,9 +305,9 @@ function piSourceVerb(nativeName: string): string {
199
305
  /** Join Pi's own toolCall and toolResult rows by native id, retaining pending calls.
200
306
  * Native-name equality is checked before translating the exact current Pi dialect. */
201
307
  export function piSourceToolReceipts(entries: readonly unknown[]): SourceToolReceipt[] {
202
- const calls = new Map<string, { toolName: string; arguments: Record<string, unknown> }>();
203
- const results = new Map<string, { toolName: string; text: string; isError: boolean }>();
204
- for (const raw of entries) {
308
+ const calls = new Map<string, { toolName: string; arguments: Record<string, unknown>; callOrdinal: number }>();
309
+ const results = new Map<string, { toolName: string; text: string; isError: boolean; resultOrdinal: number }>();
310
+ for (const [ordinal, raw] of entries.entries()) {
205
311
  if (typeof raw !== "object" || raw === null) continue;
206
312
  const entry = raw as { type?: unknown; message?: unknown };
207
313
  if (entry.type !== "message" || typeof entry.message !== "object" || entry.message === null) continue;
@@ -229,6 +335,7 @@ export function piSourceToolReceipts(entries: readonly unknown[]): SourceToolRec
229
335
  calls.set(block.id, {
230
336
  toolName: block.name,
231
337
  arguments: block.arguments as Record<string, unknown>,
338
+ callOrdinal: ordinal,
232
339
  });
233
340
  }
234
341
  }
@@ -242,6 +349,7 @@ export function piSourceToolReceipts(entries: readonly unknown[]): SourceToolRec
242
349
  toolName: message.toolName,
243
350
  text: textContent(message.content),
244
351
  isError: message.isError,
352
+ resultOrdinal: ordinal,
245
353
  });
246
354
  }
247
355
  }
@@ -263,6 +371,7 @@ export function piSourceToolReceipts(entries: readonly unknown[]): SourceToolRec
263
371
  text: result.text,
264
372
  status: result.isError ? "failed" : "completed",
265
373
  isError: result.isError,
374
+ resultOrdinal: result.resultOrdinal,
266
375
  });
267
376
  }
268
377
  return joined;