@junghanacs/entwurf 0.23.0 → 0.24.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 +3 -1
- package/BASELINE.md +2 -0
- package/CHANGELOG.md +307 -0
- package/FAQ.md +47 -0
- package/README.md +5 -3
- package/VERIFY.md +43 -2
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +97 -18
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -1
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/package.json +15 -13
- package/pi-extensions/entwurf-control.ts +56 -0
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +20 -10
- package/pi-extensions/lib/acp/context.ts +30 -10
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/session-store.ts +2 -2
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/callback-env.ts +78 -0
- package/pi-extensions/lib/compaction-send-guard.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +56 -22
- package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
- package/pi-extensions/lib/herdr-placement.ts +13 -4
- package/pi-extensions/lib/mux-fresh-call.ts +23 -8
- package/pi-extensions/meta-bridge-omp.ts +35 -48
- package/run.sh +327 -246
- package/scripts/check-acp-backend-preflight.ts +117 -9
- package/scripts/check-acp-carrier-augment.ts +6 -4
- package/scripts/check-acp-event-mapper.ts +17 -6
- package/scripts/check-acp-prompt-builder.ts +17 -9
- package/scripts/check-acp-prompt-lifecycle.ts +23 -6
- package/scripts/check-acp-session-reuse.ts +64 -51
- package/scripts/check-acp-session-store.ts +31 -8
- package/scripts/check-acp-stop-reason.ts +20 -2
- package/scripts/check-acp-stream-hooks.ts +22 -5
- package/scripts/check-acp-usage-accounting.ts +42 -18
- package/scripts/check-entwurf-bridge-boot.ts +16 -1
- package/scripts/check-fresh-call-dispatch.ts +4 -5
- package/scripts/check-gate-qualification.ts +45 -5
- package/scripts/check-herdr-activation.ts +73 -0
- package/scripts/check-herdr-fresh-call.ts +22 -6
- package/scripts/check-herdr-sandbox.ts +14 -2
- package/scripts/check-install-container.sh +1 -1
- package/scripts/check-pi-launch.ts +361 -0
- package/scripts/check-release-gate-outcomes.ts +7 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/mutation-qualify.ts +1 -1
- package/scripts/mutants/acp-backend-preflight.json +20 -0
- package/scripts/mutants/bridge-boot-resume.json +26 -0
- package/scripts/mutants/herdr-activation.json +16 -0
- package/scripts/mutants/herdr-fresh-call.json +11 -0
- package/scripts/mutants/herdr-plugin-build.json +1 -1
- package/scripts/mutants/mux-fresh-call.json +21 -8
- package/scripts/mutants/omp-fresh.json +5 -9
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +23 -5
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
- package/scripts/smoke-herdr-raw-install-live.sh +877 -0
- package/scripts/smoke-omp-fresh-live.ts +4 -2
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-provider-state.sh +2 -2
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -391
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -434,7 +434,9 @@ ok(
|
|
|
434
434
|
// callback-only prompt is delivered first, and the task is released only by the exact
|
|
435
435
|
// successful callback result. The transcript is where both are visible, in order, in one
|
|
436
436
|
// native session.
|
|
437
|
-
|
|
437
|
+
// Since the callback moved out of band (ENTWURF_CALLBACK_TARGET/NONCE in the sibling env), the
|
|
438
|
+
// callback-only prompt carries no address; the receipt is the no-arg first action itself.
|
|
439
|
+
const bootstrapLine = buildOmpCallbackOnlyPrompt()
|
|
438
440
|
.split("\n")
|
|
439
441
|
.find((l) => l.includes("FIRST AND ONLY ACTION")) as string;
|
|
440
442
|
const transcript = fs.readFileSync(transcriptPath as string, "utf8");
|
|
@@ -442,7 +444,7 @@ const callbackAt = transcript.indexOf(bootstrapLine);
|
|
|
442
444
|
const taskAt = transcript.indexOf(sceneFact);
|
|
443
445
|
receipts["5-two-stage-order"] = `callback-only prompt @${callbackAt}, task scene fact @${taskAt}`;
|
|
444
446
|
ok(
|
|
445
|
-
"stage one is in the sibling's own transcript: the extension delivered the callback-ONLY prompt
|
|
447
|
+
"stage one is in the sibling's own transcript: the extension delivered the callback-ONLY prompt (no-arg first action; the address rides the sibling env, never this prompt)",
|
|
446
448
|
callbackAt >= 0,
|
|
447
449
|
` expected line: ${bootstrapLine}`,
|
|
448
450
|
);
|
|
@@ -53,7 +53,7 @@ cat > "$SB/bin/entwurf-bridge" <<'FAKE'
|
|
|
53
53
|
while IFS= read -r line; do
|
|
54
54
|
case "$line" in
|
|
55
55
|
*'"id":1'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
|
|
56
|
-
*'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"}]}}' ;;
|
|
56
|
+
*'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"},{"name":"entwurf_callback"}]}}' ;;
|
|
57
57
|
esac
|
|
58
58
|
done
|
|
59
59
|
FAKE
|
|
@@ -67,7 +67,7 @@ write_mcp_fake() { # $1 = path
|
|
|
67
67
|
while IFS= read -r line; do
|
|
68
68
|
case "$line" in
|
|
69
69
|
*'"id":1'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
|
|
70
|
-
*'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"}]}}' ;;
|
|
70
|
+
*'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"},{"name":"entwurf_callback"}]}}' ;;
|
|
71
71
|
esac
|
|
72
72
|
done
|
|
73
73
|
FAKE
|
|
@@ -93,7 +93,7 @@ write_stale_fake() { # $1 = path
|
|
|
93
93
|
while IFS= read -r line; do
|
|
94
94
|
case "$line" in
|
|
95
95
|
*'"id":1'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"stale-bridge","version":"0"}}}' ;;
|
|
96
|
-
*'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"}]}}' ;;
|
|
96
|
+
*'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"},{"name":"entwurf_callback"}]}}' ;;
|
|
97
97
|
esac
|
|
98
98
|
done
|
|
99
99
|
FAKE
|
package/scripts/tsconfig.json
CHANGED
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
"./**/*.ts",
|
|
29
29
|
"../test/**/*.ts",
|
|
30
30
|
"../pi-extensions/lib/**/*.ts",
|
|
31
|
+
// The behavior-adjacent tests the root config just excluded (#119 V2). Listed at
|
|
32
|
+
// pi-extensions/** rather than pi-extensions/lib/** so a test beside an extension
|
|
33
|
+
// OUTSIDE lib/ lands in a fence too — the root exclude is glob-wide, and a file in
|
|
34
|
+
// no fence is worse than a file in two.
|
|
35
|
+
"../pi-extensions/**/*.test.ts",
|
|
31
36
|
"../pi-extensions/model-lock.ts",
|
|
32
37
|
"../pi-extensions/meta-bridge-hook.ts",
|
|
33
38
|
"../pi-extensions/meta-bridge-hook-copilot.ts",
|
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* check-entwurf-facts — deterministic gate for the fact-provider's pure core
|
|
3
|
-
* (0.11 Stage 0 step 4, slice 1). Locks the FACT shape and the R1/R3b liveness
|
|
4
|
-
* invariant in code BEFORE any IO wiring (gate-first).
|
|
5
|
-
*
|
|
6
|
-
* Proves:
|
|
7
|
-
* - R1 (out-of-domain → unsupported, NEVER coerced): claude-code / codex /
|
|
8
|
-
* antigravity resolve to `unsupported` for EVERY socket input (alive, dead,
|
|
9
|
-
* indeterminate, null) — the socket value is never folded into the fact for a
|
|
10
|
-
* backend whose liveness predicate is undefined.
|
|
11
|
-
* - R3b (in-domain 4-value): pi maps alive→alive, dead→dead,
|
|
12
|
-
* indeterminate→indeterminate, and null→indeterminate (no proof is
|
|
13
|
-
* `indeterminate`, never `dead`).
|
|
14
|
-
* - liveness is always a member of FACT_LIVENESSES.
|
|
15
|
-
* - identity / cwd-history fields pass through verbatim from the meta-record.
|
|
16
|
-
* - facts-only keyset: PeerFact carries EXACTLY the identity facts + `liveness`
|
|
17
|
-
* — no verb-routing field (resumable/sendable/transport/dispatch/action) and
|
|
18
|
-
* no transcriptPath. (동결결정 10: the fact layer must not bake routing.)
|
|
19
|
-
*
|
|
20
|
-
* Pure; no IO, no backend, no API.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import assert from "node:assert/strict";
|
|
24
|
-
import {
|
|
25
|
-
type FactList,
|
|
26
|
-
isOutOfSocketDomainGardenIdConflict,
|
|
27
|
-
type PeerFact,
|
|
28
|
-
type RecordLessSocketFact,
|
|
29
|
-
resolveFactList,
|
|
30
|
-
resolvePeerFact,
|
|
31
|
-
type SocketProbe,
|
|
32
|
-
} from "../pi-extensions/lib/entwurf-facts.ts";
|
|
33
|
-
import {
|
|
34
|
-
FACT_LIVENESSES,
|
|
35
|
-
type FactLiveness,
|
|
36
|
-
isLivenessSupported,
|
|
37
|
-
LIVENESS_DOMAIN_BACKENDS,
|
|
38
|
-
} from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
39
|
-
import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
|
|
40
|
-
import {
|
|
41
|
-
META_CITIZEN_BACKENDS,
|
|
42
|
-
type MetaCitizenBackend,
|
|
43
|
-
type MetaIdentity,
|
|
44
|
-
} from "../pi-extensions/lib/meta-session.ts";
|
|
45
|
-
import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
|
|
46
|
-
|
|
47
|
-
// Derive the backend coverage from the SSOT registry so a NEW backend in
|
|
48
|
-
// META_CITIZEN_BACKENDS auto-extends this gate (Fable/GPT non-blocking suggestion):
|
|
49
|
-
// out-of-domain backends must all resolve to `unsupported`, the in-domain set is
|
|
50
|
-
// the only one with a defined liveness predicate. A drift assertion below pins
|
|
51
|
-
// the in-domain set to ["pi"] so widening the domain forces new R3b coverage.
|
|
52
|
-
const OUT_OF_DOMAIN = META_CITIZEN_BACKENDS.filter((b) => !isLivenessSupported(b));
|
|
53
|
-
const IN_DOMAIN = META_CITIZEN_BACKENDS.filter((b) => isLivenessSupported(b));
|
|
54
|
-
|
|
55
|
-
let passed = 0;
|
|
56
|
-
function ok(label: string, cond: boolean): void {
|
|
57
|
-
assert.ok(cond, label);
|
|
58
|
-
console.log(` ok ${label}`);
|
|
59
|
-
passed++;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function identity(backend: MetaCitizenBackend, over: Partial<MetaIdentity> = {}): MetaIdentity {
|
|
63
|
-
return {
|
|
64
|
-
schemaVersion: 3,
|
|
65
|
-
gardenId: "20260611T093858-14984d",
|
|
66
|
-
backend,
|
|
67
|
-
nativeSessionId: "native-abc",
|
|
68
|
-
cwd: "/home/junghan/repos/gh/entwurf",
|
|
69
|
-
model: "claude-opus-5",
|
|
70
|
-
transcriptPath: "/home/junghan/.claude/projects/x/native-abc.jsonl",
|
|
71
|
-
createdAt: "2026-06-11T00:38:58.000Z",
|
|
72
|
-
recordUpdatedAt: "2026-06-11T02:40:00.000Z",
|
|
73
|
-
...over,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const SOCKET_INPUTS: (SocketLiveness | null)[] = ["alive", "dead", "indeterminate", null];
|
|
78
|
-
|
|
79
|
-
// ── R1: out-of-domain backend → unsupported for EVERY socket input ──────────
|
|
80
|
-
ok("R1 out-of-domain set is non-empty (gate is actually exercising R1)", OUT_OF_DOMAIN.length > 0);
|
|
81
|
-
for (const backend of OUT_OF_DOMAIN) {
|
|
82
|
-
for (const socket of SOCKET_INPUTS) {
|
|
83
|
-
const fact = resolvePeerFact(identity(backend), socket);
|
|
84
|
-
ok(`R1 ${backend} + socket=${socket ?? "null"} → unsupported (not coerced)`, fact.liveness === "unsupported");
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Drift guard: the in-domain set is exactly the contract's domain and is ["pi"].
|
|
89
|
-
// If the domain widens, this fails → forces adding R3b value coverage for the
|
|
90
|
-
// new backend instead of silently leaving it untested.
|
|
91
|
-
assert.deepStrictEqual(IN_DOMAIN, [...LIVENESS_DOMAIN_BACKENDS], "IN_DOMAIN ≠ LIVENESS_DOMAIN_BACKENDS");
|
|
92
|
-
assert.deepStrictEqual(IN_DOMAIN, ["pi"], "liveness domain widened — add R3b coverage for the new backend");
|
|
93
|
-
ok("drift guard: in-domain set == LIVENESS_DOMAIN_BACKENDS == ['pi']", true);
|
|
94
|
-
|
|
95
|
-
// ── R3b: in-domain (pi) 4-value mapping, null = indeterminate (never dead) ──
|
|
96
|
-
const PI_CASES: [SocketLiveness | null, FactLiveness][] = [
|
|
97
|
-
["alive", "alive"],
|
|
98
|
-
["dead", "dead"],
|
|
99
|
-
["indeterminate", "indeterminate"],
|
|
100
|
-
[null, "indeterminate"],
|
|
101
|
-
];
|
|
102
|
-
for (const [socket, expected] of PI_CASES) {
|
|
103
|
-
const fact = resolvePeerFact(identity("pi"), socket);
|
|
104
|
-
ok(`R3b pi + socket=${socket ?? "null"} → ${expected}`, fact.liveness === expected);
|
|
105
|
-
}
|
|
106
|
-
ok(
|
|
107
|
-
"R3b pi null is indeterminate, NOT dead (no proof ≠ dead)",
|
|
108
|
-
resolvePeerFact(identity("pi"), null).liveness === "indeterminate",
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
// ── liveness always ∈ FACT_LIVENESSES ──────────────────────────────────────
|
|
112
|
-
for (const backend of META_CITIZEN_BACKENDS) {
|
|
113
|
-
for (const socket of SOCKET_INPUTS) {
|
|
114
|
-
const fact = resolvePeerFact(identity(backend), socket);
|
|
115
|
-
ok(
|
|
116
|
-
`liveness ∈ FACT_LIVENESSES (${backend}/${socket ?? "null"} → ${fact.liveness})`,
|
|
117
|
-
(FACT_LIVENESSES as readonly string[]).includes(fact.liveness),
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── identity / cwd-history pass through verbatim ────────────────────────────
|
|
123
|
-
{
|
|
124
|
-
const id = identity("pi", {
|
|
125
|
-
gardenId: "20260611T112732-0f42b6",
|
|
126
|
-
nativeSessionId: "uuid-xyz",
|
|
127
|
-
cwd: "/tmp/work",
|
|
128
|
-
model: null,
|
|
129
|
-
createdAt: "2026-06-11T01:00:00.000Z",
|
|
130
|
-
recordUpdatedAt: "2026-06-11T03:00:00.000Z",
|
|
131
|
-
});
|
|
132
|
-
const fact = resolvePeerFact(id, "alive");
|
|
133
|
-
ok("passthrough gardenId", fact.gardenId === id.gardenId);
|
|
134
|
-
ok("passthrough backend", fact.backend === id.backend);
|
|
135
|
-
ok("passthrough nativeSessionId", fact.nativeSessionId === id.nativeSessionId);
|
|
136
|
-
ok("passthrough cwd", fact.cwd === id.cwd);
|
|
137
|
-
ok("passthrough model (null preserved)", fact.model === null);
|
|
138
|
-
ok("passthrough createdAt", fact.createdAt === id.createdAt);
|
|
139
|
-
ok("passthrough recordUpdatedAt", fact.recordUpdatedAt === id.recordUpdatedAt);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ── facts-only keyset: identity facts + liveness + the three observed axes, and
|
|
143
|
-
// still NO verb-routing / no transcript PATH ────────────────────────────────
|
|
144
|
-
{
|
|
145
|
-
const fact = resolvePeerFact(identity("pi"), "alive");
|
|
146
|
-
const keys = Object.keys(fact).sort();
|
|
147
|
-
const expected = [
|
|
148
|
-
"backend",
|
|
149
|
-
"createdAt",
|
|
150
|
-
"cwd",
|
|
151
|
-
"gardenId",
|
|
152
|
-
"liveness",
|
|
153
|
-
"model",
|
|
154
|
-
"nativeSessionId",
|
|
155
|
-
"recordUpdatedAt",
|
|
156
|
-
"receiver",
|
|
157
|
-
"transcript",
|
|
158
|
-
"placement",
|
|
159
|
-
].sort();
|
|
160
|
-
assert.deepStrictEqual(keys, expected, `PeerFact keyset drift: got ${keys.join(",")}`);
|
|
161
|
-
ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript/placement)", true);
|
|
162
|
-
|
|
163
|
-
// #101: an unmeasured row says so. `unobserved` is the default for a caller that
|
|
164
|
-
// injected no observer — never `none`/`absent`, which would be a fabricated fact.
|
|
165
|
-
ok(
|
|
166
|
-
"an observer-less composition reports all three axes as unobserved (never a default fact)",
|
|
167
|
-
fact.receiver === "unobserved" && fact.transcript === "unobserved" && fact.placement.kind === "unobserved",
|
|
168
|
-
);
|
|
169
|
-
const observed = resolvePeerFact(identity("claude-code"), null, {
|
|
170
|
-
receiver: "inactive",
|
|
171
|
-
transcript: "absent",
|
|
172
|
-
placement: UNOBSERVED_PLACEMENT,
|
|
173
|
-
});
|
|
174
|
-
ok(
|
|
175
|
-
"an injected observation rides onto the fact verbatim",
|
|
176
|
-
observed.receiver === "inactive" && observed.transcript === "absent" && observed.placement.kind === "unobserved",
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
const FORBIDDEN = [
|
|
180
|
-
"resumable",
|
|
181
|
-
"sendable",
|
|
182
|
-
"transport",
|
|
183
|
-
"dispatch",
|
|
184
|
-
"action",
|
|
185
|
-
"transcriptPath",
|
|
186
|
-
"parentGardenId",
|
|
187
|
-
"isEntwurf",
|
|
188
|
-
];
|
|
189
|
-
for (const k of FORBIDDEN) {
|
|
190
|
-
ok(`no '${k}' on PeerFact (facts-only, no verb-routing/transcript)`, !(k in fact));
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Type-level guard: the test references PeerFact so a field rename breaks tsc.
|
|
195
|
-
const _typecheck: (f: PeerFact) => FactLiveness = (f) => f.liveness;
|
|
196
|
-
void _typecheck;
|
|
197
|
-
|
|
198
|
-
// ════════════════════════════════════════════════════════════════════════════
|
|
199
|
-
// slice 2 — resolveFactList: meta-store ⨯ socket union (설계 동결 2026-06-11,
|
|
200
|
-
// GPT힣 + Fable; #50 C4 demotion). PeerFact for record citizens,
|
|
201
|
-
// RecordLessSocketFact for sockets no record claims — a DIAGNOSTIC subject the
|
|
202
|
-
// provider folds into `record-less-socket`, never a citizen; gardenId is the
|
|
203
|
-
// correlation key.
|
|
204
|
-
// ════════════════════════════════════════════════════════════════════════════
|
|
205
|
-
|
|
206
|
-
function socketProbe(gardenId: string, liveness: SocketLiveness): SocketProbe {
|
|
207
|
-
return { gardenId, liveness };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const GID_PI_LIVE = "20260611T115213-3aa371";
|
|
211
|
-
const GID_PI_DORMANT = "20260611T093858-14984d";
|
|
212
|
-
const GID_CLAUDE = "20260611T112732-0f42b6";
|
|
213
|
-
const GID_SOCKET_ONLY = "20260611T135517-5f0d25";
|
|
214
|
-
|
|
215
|
-
// ── basic union: pi-live citizen + claude citizen + record-less live socket ──
|
|
216
|
-
{
|
|
217
|
-
const ids = [identity("pi", { gardenId: GID_PI_LIVE }), identity("claude-code", { gardenId: GID_CLAUDE })];
|
|
218
|
-
const probes = [socketProbe(GID_PI_LIVE, "alive"), socketProbe(GID_SOCKET_ONLY, "alive")];
|
|
219
|
-
const out = resolveFactList(ids, probes);
|
|
220
|
-
ok("union: 2 citizens → 2 PeerFacts", out.peers.length === 2);
|
|
221
|
-
ok("union: 1 record-less socket → 1 RecordLessSocketFact", out.recordLessSockets.length === 1);
|
|
222
|
-
ok(
|
|
223
|
-
"union: pi-live citizen liveness = alive (from its probe)",
|
|
224
|
-
out.peers.find((p) => p.gardenId === GID_PI_LIVE)?.liveness === "alive",
|
|
225
|
-
);
|
|
226
|
-
ok(
|
|
227
|
-
"union: claude citizen liveness = unsupported (out-of-domain, socket ignored)",
|
|
228
|
-
out.peers.find((p) => p.gardenId === GID_CLAUDE)?.liveness === "unsupported",
|
|
229
|
-
);
|
|
230
|
-
ok("union: record-less gardenId surfaced", out.recordLessSockets[0]?.gardenId === GID_SOCKET_ONLY);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ── dormant trap (Fable): pi citizen probed dead → dead (NOT indeterminate) ──
|
|
234
|
-
{
|
|
235
|
-
const out = resolveFactList([identity("pi", { gardenId: GID_PI_DORMANT })], [socketProbe(GID_PI_DORMANT, "dead")]);
|
|
236
|
-
ok(
|
|
237
|
-
"dormant trap: pi citizen + probe=dead → liveness=dead (resumable, not stranded)",
|
|
238
|
-
out.peers[0]?.liveness === "dead",
|
|
239
|
-
);
|
|
240
|
-
ok("dormant trap: dead pi citizen is a PeerFact, never record-less", out.recordLessSockets.length === 0);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ── F3 preserve: pi citizen probed indeterminate → not folded to dead ────────
|
|
244
|
-
{
|
|
245
|
-
const out = resolveFactList([identity("pi", { gardenId: GID_PI_LIVE })], [socketProbe(GID_PI_LIVE, "indeterminate")]);
|
|
246
|
-
ok(
|
|
247
|
-
"F3 preserve: pi citizen + probe=indeterminate → indeterminate (never folded)",
|
|
248
|
-
out.peers[0]?.liveness === "indeterminate",
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// ── wiring invariant: unprobed in-domain citizen → throw (null is unprobed-only) ─
|
|
253
|
-
{
|
|
254
|
-
let threw = false;
|
|
255
|
-
try {
|
|
256
|
-
resolveFactList([identity("pi", { gardenId: GID_PI_LIVE })], []);
|
|
257
|
-
} catch {
|
|
258
|
-
threw = true;
|
|
259
|
-
}
|
|
260
|
-
ok("wiring invariant: in-domain citizen absent from probes → throw (never silent null/indeterminate)", threw);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ── fail-loud (동결3): out-of-domain citizen owning a control socket = ambiguity ─
|
|
264
|
-
{
|
|
265
|
-
let threw = false;
|
|
266
|
-
try {
|
|
267
|
-
resolveFactList([identity("claude-code", { gardenId: GID_CLAUDE })], [socketProbe(GID_CLAUDE, "alive")]);
|
|
268
|
-
} catch {
|
|
269
|
-
threw = true;
|
|
270
|
-
}
|
|
271
|
-
ok("dedup/authority: out-of-socket-domain citizen + same-gid socket → fail-loud", threw);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ── dedup (동결3): pi citizen + its socket → PeerFact only, never both ────────
|
|
275
|
-
{
|
|
276
|
-
const out = resolveFactList([identity("pi", { gardenId: GID_PI_LIVE })], [socketProbe(GID_PI_LIVE, "alive")]);
|
|
277
|
-
ok("dedup: pi citizen consumes its socket (1 PeerFact)", out.peers.length === 1);
|
|
278
|
-
ok("dedup: consumed gid not also emitted as RecordLessSocketFact", out.recordLessSockets.length === 0);
|
|
279
|
-
const allGids = [...out.peers.map((p) => p.gardenId), ...out.recordLessSockets.map((s) => s.gardenId)];
|
|
280
|
-
ok("dedup: no gardenId appears in both sections", new Set(allGids).size === allGids.length);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// ── duplicate inputs → throw ─────────────────────────────────────────────────
|
|
284
|
-
{
|
|
285
|
-
let dupProbe = false;
|
|
286
|
-
try {
|
|
287
|
-
resolveFactList([], [socketProbe(GID_PI_LIVE, "alive"), socketProbe(GID_PI_LIVE, "dead")]);
|
|
288
|
-
} catch {
|
|
289
|
-
dupProbe = true;
|
|
290
|
-
}
|
|
291
|
-
ok("duplicate socket probe for one gid → throw", dupProbe);
|
|
292
|
-
|
|
293
|
-
let dupId = false;
|
|
294
|
-
try {
|
|
295
|
-
resolveFactList(
|
|
296
|
-
[identity("pi", { gardenId: GID_PI_LIVE }), identity("pi", { gardenId: GID_PI_LIVE })],
|
|
297
|
-
[socketProbe(GID_PI_LIVE, "alive")],
|
|
298
|
-
);
|
|
299
|
-
} catch {
|
|
300
|
-
dupId = true;
|
|
301
|
-
}
|
|
302
|
-
ok("duplicate meta-record for one gid → throw", dupId);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ── RecordLessSocketFact (#50 C4): gid + 3-value liveness, nothing else ──────
|
|
306
|
-
// The demotion is structural: no enrich (cwd/model/idle came from get_info-ing
|
|
307
|
-
// the socket — treating it as a citizen), no kind tag, no synthetic identity.
|
|
308
|
-
{
|
|
309
|
-
const out = resolveFactList([], [socketProbe(GID_SOCKET_ONLY, "alive")]);
|
|
310
|
-
const s = out.recordLessSockets[0] as RecordLessSocketFact;
|
|
311
|
-
ok("record-less: liveness ∈ SocketLiveness 3-value (never unsupported)", s.liveness === "alive");
|
|
312
|
-
const keys = Object.keys(s).sort();
|
|
313
|
-
assert.deepStrictEqual(keys, ["gardenId", "liveness"], `RecordLessSocketFact keyset drift: ${keys.join(",")}`);
|
|
314
|
-
ok("record-less: keyset exact (gardenId + liveness ONLY — enrich is gone with the listing)", true);
|
|
315
|
-
const FORBIDDEN = [
|
|
316
|
-
"resumable",
|
|
317
|
-
"sendable",
|
|
318
|
-
"transport",
|
|
319
|
-
"dispatch",
|
|
320
|
-
"action",
|
|
321
|
-
"backend",
|
|
322
|
-
"nativeSessionId",
|
|
323
|
-
"isEntwurf",
|
|
324
|
-
"cwd",
|
|
325
|
-
"model",
|
|
326
|
-
"idle",
|
|
327
|
-
"infoError",
|
|
328
|
-
"kind",
|
|
329
|
-
];
|
|
330
|
-
for (const k of FORBIDDEN) {
|
|
331
|
-
ok(`record-less: no '${k}' (no verb-routing, no synthetic identity, no enrich)`, !(k in s));
|
|
332
|
-
}
|
|
333
|
-
// dead/indeterminate stay surfaced too — hiding a dead socket is GC's job.
|
|
334
|
-
const dead = resolveFactList([], [socketProbe(GID_SOCKET_ONLY, "dead")]);
|
|
335
|
-
ok("record-less: dead socket stays surfaced (GC hides, the listing never does)", dead.recordLessSockets.length === 1);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// ── determinism: output sorted by gardenId in each section ───────────────────
|
|
339
|
-
{
|
|
340
|
-
const ids = [
|
|
341
|
-
identity("pi", { gardenId: "20260611T222222-bbbbbb" }),
|
|
342
|
-
identity("pi", { gardenId: "20260611T111111-aaaaaa" }),
|
|
343
|
-
];
|
|
344
|
-
const probes = [
|
|
345
|
-
socketProbe("20260611T222222-bbbbbb", "alive"),
|
|
346
|
-
socketProbe("20260611T111111-aaaaaa", "alive"),
|
|
347
|
-
socketProbe("20260611T333333-cccccc", "alive"),
|
|
348
|
-
];
|
|
349
|
-
const out = resolveFactList(ids, probes);
|
|
350
|
-
ok("determinism: peers sorted by gardenId", out.peers[0]?.gardenId === "20260611T111111-aaaaaa");
|
|
351
|
-
ok(
|
|
352
|
-
"determinism: recordLessSockets sorted by gardenId",
|
|
353
|
-
out.recordLessSockets[0]?.gardenId === "20260611T333333-cccccc",
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ── isOutOfSocketDomainGardenIdConflict: shared record-side predicate ──────
|
|
358
|
-
// (fact-provider listing + v2 decider dispatch consume this same fn — 4c 재유도
|
|
359
|
-
// 금지 동형). The union over socketGids ∪ symlinkedGardenIds is the fact-provider:125
|
|
360
|
-
// gap closure: a symlinked socket is never probed, so its gid is absent from
|
|
361
|
-
// socketGids; the predicate must STILL flag an out-of-domain citizen owning it.
|
|
362
|
-
{
|
|
363
|
-
const G = "20260611T444444-dddddd";
|
|
364
|
-
const realSockets = new Set([G]);
|
|
365
|
-
const symlinked = new Set([G]);
|
|
366
|
-
const empty = new Set<string>();
|
|
367
|
-
for (const piBackend of IN_DOMAIN) {
|
|
368
|
-
ok(
|
|
369
|
-
`conflict-predicate: socket-domain (${piBackend}) is not a record-side conflict`,
|
|
370
|
-
!isOutOfSocketDomainGardenIdConflict(piBackend, G, realSockets, symlinked),
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
ok(
|
|
374
|
-
"conflict-predicate: out-of-socket-domain + real socket → conflict",
|
|
375
|
-
isOutOfSocketDomainGardenIdConflict("claude-code", G, realSockets, empty),
|
|
376
|
-
);
|
|
377
|
-
ok(
|
|
378
|
-
"conflict-predicate: out-of-socket-domain + symlinked socket → conflict",
|
|
379
|
-
isOutOfSocketDomainGardenIdConflict("claude-code", G, empty, symlinked),
|
|
380
|
-
);
|
|
381
|
-
ok(
|
|
382
|
-
"conflict-predicate: out-of-socket-domain + no socket → no conflict",
|
|
383
|
-
!isOutOfSocketDomainGardenIdConflict("claude-code", G, empty, empty),
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Type-level guard: reference FactList so a shape rename breaks tsc.
|
|
388
|
-
const _factlist: (f: FactList) => number = (f) => f.peers.length + f.recordLessSockets.length;
|
|
389
|
-
void _factlist;
|
|
390
|
-
|
|
391
|
-
console.log(`\n[check-entwurf-facts] ${passed} assertions ok`);
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* check-entwurf-resume-args — deterministic gate for the resume-argv SSOT (`buildResumePiArgs`).
|
|
3
|
-
*
|
|
4
|
-
* The contract this gate holds changed with S1, and the change is the point: the builder used to
|
|
5
|
-
* emit a HEADLESS child (`--mode json -p … <prompt>`) because its only caller was the detached
|
|
6
|
-
* `spawn-bg` watcher. That transport was withdrawn, and the argv was MEASURED against the runtime
|
|
7
|
-
* before the new consumer was written (2026-08-06, private tmux + fixture): dropped into a tmux
|
|
8
|
-
* window, `-p` is pi's own "non-interactive mode" and the operator gets a JSON stream instead of a
|
|
9
|
-
* session they can type into. The shape that actually reopens a visible citizen carries neither.
|
|
10
|
-
*
|
|
11
|
-
* So this gate pins the ONE shipped posture, and pins the absences as hard as the presences —
|
|
12
|
-
* a re-introduced `-p` would still open a window, still stand a socket up, and still look green
|
|
13
|
-
* to anything that only asserted `--entwurf-control`:
|
|
14
|
-
*
|
|
15
|
-
* RESUMEARGS-CONTROL-FIRST `--entwurf-control` leads, so the resumed session is addressable
|
|
16
|
-
* RESUMEARGS-NO-HEADLESS no `--mode`, no `-p` — the window is interactive
|
|
17
|
-
* RESUMEARGS-NO-PROMPT-TAIL no positional prompt — a resume runs no turn
|
|
18
|
-
* RESUMEARGS-EXT-ONCE-BEFORE-SESSION bridge args exactly once, between control and --session
|
|
19
|
-
* RESUMEARGS-PROVIDER-OPTIONAL a recorded provider is emitted, an absent one emits no flag
|
|
20
|
-
* RESUMEARGS-SESSION-IS-FILE `--session <abs path>`, never `--session-id` (which MINTS)
|
|
21
|
-
*
|
|
22
|
-
* Pure string assembly — no IO, no spawn.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import assert from "node:assert/strict";
|
|
26
|
-
import { buildResumePiArgs } from "../pi-extensions/lib/entwurf-resume-args.ts";
|
|
27
|
-
|
|
28
|
-
let passed = 0;
|
|
29
|
-
function ok(label: string, cond: boolean): void {
|
|
30
|
-
assert.ok(cond, label);
|
|
31
|
-
console.log(` ok ${label}`);
|
|
32
|
-
passed++;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// The resume target is a session FILE (#50 C2), not a garden id: the record owns the address, so
|
|
36
|
-
// argv only has to name WHICH transcript to reopen.
|
|
37
|
-
const SESSION_FILE =
|
|
38
|
-
"/home/op/.pi/agent/sessions/-home-op-repo/2026-06-13T09-10-00-000Z_019e8faa-04ea-7b73-bf2c-1465d525c2e8.jsonl";
|
|
39
|
-
// Production shape, measured: getEntwurfExplicitExtensions emits `-e <path>`, not `--extension`.
|
|
40
|
-
const EXT = ["-e", "/path/to/entwurf/index.ts"] as const;
|
|
41
|
-
|
|
42
|
-
function valueAfter(args: readonly string[], flag: string): string | undefined {
|
|
43
|
-
const i = args.indexOf(flag);
|
|
44
|
-
return i === -1 ? undefined : args[i + 1];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function main(): void {
|
|
48
|
-
// ── The shipped posture, with a recorded ACP provider (the only shape whose ext args are
|
|
49
|
-
// non-empty — measured as `["-e", "<bridge>"]` on a real recorded provider=entwurf record).
|
|
50
|
-
{
|
|
51
|
-
const args = buildResumePiArgs({
|
|
52
|
-
sessionFile: SESSION_FILE,
|
|
53
|
-
explicitExtensionArgs: EXT,
|
|
54
|
-
provider: "entwurf",
|
|
55
|
-
model: "claude-sonnet-5",
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// Assertion order is a contract, not a reading preference: each claim must be the FIRST
|
|
59
|
-
// one a defect against it reaches, or a red would be reported under someone else's name.
|
|
60
|
-
// The absences come first because a re-introduced headless prefix also displaces the
|
|
61
|
-
// leading flag, and "the argv is headless again" is the truer diagnosis of that.
|
|
62
|
-
ok(
|
|
63
|
-
"[QK:RESUMEARGS-NO-HEADLESS] no --mode and no -p — `-p` is pi's non-interactive mode, so the headless prefix would open a window streaming JSON instead of a session the operator can use",
|
|
64
|
-
!args.includes("--mode") && !args.includes("-p") && !args.includes("--print"),
|
|
65
|
-
);
|
|
66
|
-
ok(
|
|
67
|
-
"[QK:RESUMEARGS-CONTROL-FIRST] --entwurf-control is the FIRST token — the resumed session stands its control socket up, which is the whole difference between a restored transcript and an addressable citizen",
|
|
68
|
-
args[0] === "--entwurf-control",
|
|
69
|
-
);
|
|
70
|
-
ok(
|
|
71
|
-
"[QK:RESUMEARGS-NO-PROMPT-TAIL] the argv ends at --model <m> with NO positional prompt — a resume reopens a conversation and runs no turn (measured: the transcript stayed byte-identical)",
|
|
72
|
-
args[args.length - 2] === "--model" && args[args.length - 1] === "claude-sonnet-5",
|
|
73
|
-
);
|
|
74
|
-
ok(
|
|
75
|
-
"no --no-extensions either: the one-shot posture that let `pi -p` exit is exactly what a resident resume must not emit",
|
|
76
|
-
!args.includes("--no-extensions"),
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
// SESSION-IS-FILE precedes the ext-args claim because the ext claim is stated RELATIVE to
|
|
80
|
-
// `--session`: if that flag were renamed, the positional assertion below would go red about
|
|
81
|
-
// the extensions while the actual defect is the flag that MINTS a session.
|
|
82
|
-
ok(
|
|
83
|
-
"[QK:RESUMEARGS-SESSION-IS-FILE] the builder targets an exact FILE via --session and never --session-id, which would CREATE the id when missing and silently mint an empty session instead of resuming one",
|
|
84
|
-
valueAfter(args, "--session") === SESSION_FILE && !args.includes("--session-id"),
|
|
85
|
-
);
|
|
86
|
-
ok(
|
|
87
|
-
"[QK:RESUMEARGS-EXT-ONCE-BEFORE-SESSION] the bridge args appear EXACTLY once and sit between --entwurf-control and --session — dropping them re-opens the #29 'Unknown provider' footgun, and doubling them is a different broken argv",
|
|
88
|
-
args.filter((a) => a === "-e").length === 1 &&
|
|
89
|
-
args.indexOf("--entwurf-control") < args.indexOf("-e") &&
|
|
90
|
-
args.indexOf("-e") < args.indexOf("--session"),
|
|
91
|
-
);
|
|
92
|
-
ok("a recorded provider is laid out as two tokens", valueAfter(args, "--provider") === "entwurf");
|
|
93
|
-
ok("model is laid out as two tokens", valueAfter(args, "--model") === "claude-sonnet-5");
|
|
94
|
-
ok(
|
|
95
|
-
"the full measured argv is reproduced exactly",
|
|
96
|
-
args.join(" ") ===
|
|
97
|
-
`--entwurf-control -e /path/to/entwurf/index.ts --session ${SESSION_FILE} --provider entwurf --model claude-sonnet-5`,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ── The native shape: no recorded provider, no bridge. Measured as explicitExtensionArgs=[].
|
|
102
|
-
for (const provider of [null, undefined] as const) {
|
|
103
|
-
const args = buildResumePiArgs({
|
|
104
|
-
sessionFile: SESSION_FILE,
|
|
105
|
-
explicitExtensionArgs: [],
|
|
106
|
-
provider,
|
|
107
|
-
model: "gpt-5.6-terra",
|
|
108
|
-
});
|
|
109
|
-
ok(
|
|
110
|
-
`[QK:RESUMEARGS-PROVIDER-OPTIONAL] provider=${provider}: NO --provider flag is emitted — an empty flag value would be a different citizen's launch, not a missing one`,
|
|
111
|
-
!args.includes("--provider"),
|
|
112
|
-
);
|
|
113
|
-
ok(
|
|
114
|
-
`provider=${provider}: the argv is exactly the four tokens the native shape needs`,
|
|
115
|
-
args.join(" ") === `--entwurf-control --session ${SESSION_FILE} --model gpt-5.6-terra`,
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
console.log(`\ncheck-entwurf-resume-args: ${passed} checks passed`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
main();
|