@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.
- package/AGENTS.md +2 -1
- package/BASELINE.md +7 -5
- package/CHANGELOG.md +344 -0
- package/DELIVERY.md +37 -20
- package/ENTWURF.json +236 -0
- package/README.md +75 -65
- package/VERIFY.md +9 -9
- package/docs/external-mcp-host.md +1 -1
- package/docs/setup-clean-host.md +59 -33
- package/mcp/entwurf-bridge/dist/ENTWURF.json +236 -0
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +32 -190
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +23 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +23 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +15 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +21 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +4 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +29 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +13 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +25 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +101 -32
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +57 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +23 -45
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +12 -12
- package/mcp/entwurf-bridge/src/index.ts +126 -321
- package/native/win32-x64/entwurf_win.node +0 -0
- package/native/win32-x64/manifest.json +2 -2
- package/package.json +5 -4
- package/pi-extensions/lib/agent-guidance.ts +341 -0
- package/pi-extensions/lib/entwurf-self-address.ts +13 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +25 -2
- package/pi-extensions/lib/entwurf-v2-decider.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +27 -30
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +15 -16
- package/pi-extensions/lib/fresh-call-composition.ts +21 -10
- package/pi-extensions/lib/herdr-fresh-call.ts +4 -2
- package/pi-extensions/lib/meta-sender-identity.ts +4 -0
- package/pi-extensions/lib/mux-fresh-call.ts +29 -30
- package/pi-extensions/lib/mux-resume-call.ts +18 -19
- package/pi-extensions/lib/native-push/register.ts +17 -2
- package/pi-extensions/lib/omp-fresh-preflight.ts +2 -2
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +1 -1
- package/pi-extensions/lib/remote-pair-receive.ts +27 -19
- package/pi-extensions/lib/remote-pair-send.ts +124 -34
- package/pi-extensions/meta-bridge-omp.ts +69 -7
- package/pi-extensions/meta-bridge-pi-durable.ts +23 -64
- package/run.sh +182 -22
- package/scripts/build-bridge.sh +10 -0
- package/scripts/check-agy-sender-identity.ts +6 -4
- package/scripts/check-capability-bundle-reach.ts +86 -0
- package/scripts/check-entwurf-self-address.ts +37 -6
- package/scripts/check-entwurf-v2-contract.ts +24 -2
- package/scripts/check-entwurf-v2-production.ts +45 -31
- package/scripts/check-entwurf-v2-surface.ts +94 -98
- package/scripts/check-gate-qualification.ts +17 -13
- package/scripts/check-mux-resume-call.ts +20 -3
- package/scripts/check-native-push-adapter.ts +4 -0
- package/scripts/check-native-push-register.ts +32 -82
- package/scripts/check-remote-pair-installed.ts +2 -2
- package/scripts/dev-bin.sh +112 -1
- package/scripts/lib/chain-terminus-publication.ts +58 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +30 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +112 -3
- package/scripts/lib/pi-durable-fresh-live-receipts.ts +29 -1
- package/scripts/mutants/agent-guidance.json +160 -0
- package/scripts/mutants/bridge-boot-resume.json +6 -8
- package/scripts/mutants/capability-bundle-reach.json +124 -0
- package/scripts/mutants/chain-terminus-publication.json +17 -0
- package/scripts/mutants/codex-native.json +98 -0
- package/scripts/mutants/control-send-receipt.json +23 -6
- package/scripts/mutants/mux-fresh-call.json +30 -4
- package/scripts/mutants/mux-resume-call.json +28 -3
- package/scripts/mutants/native-push-paused.json +17 -0
- package/scripts/mutants/omp-fresh.json +13 -2
- package/scripts/mutants/pack-install.json +46 -0
- package/scripts/mutants/pi-durable.json +17 -5
- package/scripts/mutants/remote-pair-display.json +3 -3
- package/scripts/mutants/remote-pair-receive.json +41 -11
- package/scripts/mutants/remote-pair-roundtrip.json +67 -7
- package/scripts/mutants/remote-pair-send.json +177 -19
- package/scripts/mutants/remote-pair-transport.json +4 -4
- package/scripts/mutants/self-address.json +10 -10
- package/scripts/mutants/source-install.json +66 -0
- package/scripts/mutants/v2-surface.json +50 -64
- package/scripts/mutants/v2-visible-resume.json +11 -0
- package/scripts/remote-pair-receive.ts +12 -12
- package/scripts/smoke-agy-install-state.sh +121 -0
- package/scripts/smoke-agy-native-push-live.ts +13 -0
- package/scripts/smoke-codex-fresh-live.ts +50 -27
- package/scripts/smoke-entwurf-chain-live.ts +7 -9
- package/scripts/smoke-herdr-fresh-call-live.ts +4 -4
- package/scripts/smoke-herdr-plugin-build-live.ts +179 -50
- package/scripts/smoke-mux-lifecycle-live.ts +140 -8
- package/scripts/smoke-omp-fresh-live.ts +5 -4
- package/scripts/smoke-pi-durable-fresh-live.ts +29 -7
- 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
|
|
232
|
-
|
|
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 =
|
|
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
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
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 (
|
|
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
|
-
// ──
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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+
|
|
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))
|
package/scripts/dev-bin.sh
CHANGED
|
@@ -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
|
-
|
|
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;
|