@junghanacs/entwurf 0.13.0 → 0.14.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 +52 -17
- package/BASELINE.md +60 -206
- package/CHANGELOG.md +64 -0
- package/CONTRIBUTING.md +14 -10
- package/DELIVERY.md +118 -261
- package/README.md +91 -431
- package/VERIFY.md +74 -95
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +236 -0
- package/docs/external-mcp-host.md +131 -0
- package/docs/fresh-cut-policy.md +99 -0
- package/docs/setup-clean-host.md +124 -328
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +176 -67
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +25 -11
- package/pi-extensions/entwurf-control.ts +220 -71
- package/pi-extensions/lib/acp/acp-client.ts +22 -0
- package/pi-extensions/lib/acp/augment.ts +42 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
- package/pi-extensions/lib/acp/backend.ts +366 -38
- package/pi-extensions/lib/acp/config.ts +1 -1
- package/pi-extensions/lib/acp/engraving.ts +43 -1
- package/pi-extensions/lib/acp/event-mapper.ts +16 -13
- package/pi-extensions/lib/acp/models.ts +4 -4
- package/pi-extensions/lib/acp/overlay.ts +11 -9
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/prompts/engraving.md +13 -5
- package/run.sh +688 -282
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-carrier-augment.ts +246 -10
- package/scripts/check-acp-cortex.ts +5 -5
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-prompt-lifecycle.ts +565 -0
- package/scripts/check-acp-stop-reason.ts +342 -0
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +198 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +101 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +121 -81
- package/scripts/check-release-gate-outcomes.ts +452 -0
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/live-skip.ts +33 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/lib/probe-acp-turn.ts +33 -12
- package/scripts/lib/step-outcome.sh +88 -0
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-augment.json +106 -0
- package/scripts/mutants/acp-cortex.json +2 -2
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
- package/scripts/mutants/acp-stop-reason.json +80 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +6 -1021
- package/scripts/mutants/release-gate.json +140 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +74 -23
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
- package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +8 -14
- package/scripts/smoke-acp-long-turn-live.ts +185 -0
- package/scripts/smoke-acp-mcp-live.ts +2 -2
- package/scripts/smoke-acp-memory-containment-live.ts +2 -2
- package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
- package/scripts/smoke-acp-overlay-live.ts +2 -2
- package/scripts/smoke-acp-provider-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +3 -3
- package/scripts/smoke-acp-session-reuse-live.ts +2 -2
- package/scripts/smoke-acp-skill-live.ts +2 -2
- package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
- package/scripts/smoke-acp-v2-send-live.ts +4 -3
- package/scripts/smoke-agy-native-push-live.ts +10 -20
- package/scripts/smoke-claude-native-resume-live.sh +13 -3
- package/scripts/smoke-entwurf-chain-live.ts +352 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +4 -0
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -236
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-launcher-fence — the shared fail-closed protection for the operator's real `claude`
|
|
3
|
+
* launcher, owed by BOTH mux LIVE smokes (issue #67).
|
|
4
|
+
*
|
|
5
|
+
* The observed incident: a Claude child launched with the operator's real HOME but a fixture
|
|
6
|
+
* `XDG_DATA_HOME` self-updated INTO the fixture tree and rewrote the real launcher (the
|
|
7
|
+
* `~/.local/bin/claude` install shim) to point at `<fixture>/claude/versions/<v>`. Fixture
|
|
8
|
+
* teardown then deleted that tree and left the operator's launcher dangling. This module owns
|
|
9
|
+
* the three protections the smokes share, so neither carries its own copy:
|
|
10
|
+
*
|
|
11
|
+
* 1. PREFLIGHT (`snapshotClaudeLauncher`) — fail-closed. Before any Claude-capable child
|
|
12
|
+
* starts, resolve the actual absolute launcher from the exact child PATH, require it and
|
|
13
|
+
* its symlink/realpath-resolved target to be present, regular, executable and OUTSIDE the
|
|
14
|
+
* fixture cleanup root, and capture enough identity/content evidence (kind, link text,
|
|
15
|
+
* resolved path, content hash) to prove "unchanged" later. Any doubt throws — a smoke that
|
|
16
|
+
* cannot pin the launcher's identity must not launch the child that could destroy it.
|
|
17
|
+
* 2. INTEGRITY ORACLE (`verifyClaudeLauncher`) — on success AND on every failure teardown,
|
|
18
|
+
* before fixture removal, re-derive the same facts and report every mismatch by name.
|
|
19
|
+
* 3. CLEANUP GUARD (`launcherReferencesFixture`) — the one question that decides whether
|
|
20
|
+
* `rm -rf <fixture>` is destructive: does the launcher, or what it currently resolves to,
|
|
21
|
+
* live inside the fixture root? If yes, the smoke must BLOCK its own cleanup loudly and
|
|
22
|
+
* leave the tree in place rather than dangle the operator's launcher.
|
|
23
|
+
*
|
|
24
|
+
* It also owns the env repair that removes the incident's trigger: `restoreOriginalXdg` gives a
|
|
25
|
+
* real-HOME Claude cell EXACT operator-env parity on the four XDG roots — each variable restored
|
|
26
|
+
* to its original value if it was set, and DELETED if it was originally absent. Filling a
|
|
27
|
+
* canonical default for an absent variable would be a guess about how the runtime reads its
|
|
28
|
+
* config; parity, not widening, is the contract. That parity covers the four PERSISTENT
|
|
29
|
+
* CONFIG/DATA/STATE/CACHE roots only: `XDG_RUNTIME_DIR` deliberately remains the smokes' private
|
|
30
|
+
* fixture runtime surface, so this is not a claim of total operator-env parity.
|
|
31
|
+
*
|
|
32
|
+
* This is a scripts-side harness module. It never inspects or alters anything unless the caller
|
|
33
|
+
* hands it an environment and a fixture root, and it must stay out of shipped production source.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
|
|
40
|
+
/** The four XDG roots a real-HOME Claude cell must see with exact operator parity. */
|
|
41
|
+
export const CLAUDE_CELL_XDG_VARS = ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"] as const;
|
|
42
|
+
|
|
43
|
+
/** Original presence/value of each XDG root: a string if it was set (even empty), null if absent. */
|
|
44
|
+
export type OriginalXdg = Record<string, string | null>;
|
|
45
|
+
|
|
46
|
+
/** Capture the four XDG roots BEFORE any fixture redirect touches the environment. */
|
|
47
|
+
export function snapshotOriginalXdg(env: NodeJS.ProcessEnv): OriginalXdg {
|
|
48
|
+
const out: OriginalXdg = {};
|
|
49
|
+
for (const name of CLAUDE_CELL_XDG_VARS) out[name] = env[name] ?? null;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Restore EXACT original presence/value of the four XDG roots on a cell env. An originally
|
|
55
|
+
* absent variable is deleted, never filled with a canonical default — parity, not a guess.
|
|
56
|
+
*/
|
|
57
|
+
export function restoreOriginalXdg(env: NodeJS.ProcessEnv, originals: OriginalXdg): NodeJS.ProcessEnv {
|
|
58
|
+
for (const name of CLAUDE_CELL_XDG_VARS) {
|
|
59
|
+
const original = originals[name];
|
|
60
|
+
if (original === null || original === undefined) delete env[name];
|
|
61
|
+
else env[name] = original;
|
|
62
|
+
}
|
|
63
|
+
return env;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Everything the preflight pinned, and the oracle later re-derives. */
|
|
67
|
+
export interface ClaudeLauncherSnapshot {
|
|
68
|
+
/** Absolute launcher path selected from the exact child PATH. */
|
|
69
|
+
launcherPath: string;
|
|
70
|
+
/** What the launcher IS at its own path: a symlink or a regular file. */
|
|
71
|
+
kind: "symlink" | "file";
|
|
72
|
+
/** Raw readlink text when the launcher is a symlink, else null. */
|
|
73
|
+
linkText: string | null;
|
|
74
|
+
/** Fully realpath-resolved target the launcher executes. */
|
|
75
|
+
resolvedPath: string;
|
|
76
|
+
/** sha256 of the resolved target's content — the one axis that catches a same-path, same-link in-place rewrite. */
|
|
77
|
+
sha256: string;
|
|
78
|
+
/** Realpath of the fixture cleanup root the launcher must stay outside of. */
|
|
79
|
+
fixtureRoot: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isInside(parent: string, p: string): boolean {
|
|
83
|
+
const rel = path.relative(parent, p);
|
|
84
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sha256File(file: string): string {
|
|
88
|
+
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isExecutableFile(p: string): boolean {
|
|
92
|
+
try {
|
|
93
|
+
if (!fs.statSync(p).isFile()) return false;
|
|
94
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve `cmd` the way the child's exec will: first PATH entry holding an executable file.
|
|
103
|
+
* Returns the joined candidate path (not yet symlink-resolved), or null when nothing matches.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveFromPath(cmd: string, env: NodeJS.ProcessEnv): string | null {
|
|
106
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
107
|
+
if (!dir) continue;
|
|
108
|
+
const candidate = path.join(dir, cmd);
|
|
109
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* FAIL-CLOSED PREFLIGHT. Pin the launcher's identity before any Claude-capable child starts,
|
|
116
|
+
* or throw. Every refusal names itself: a smoke that cannot prove the launcher is a real,
|
|
117
|
+
* executable, fixture-independent file must not launch the child that could rewrite it.
|
|
118
|
+
*/
|
|
119
|
+
export function snapshotClaudeLauncher(opts: { env: NodeJS.ProcessEnv; fixtureRoot: string }): ClaudeLauncherSnapshot {
|
|
120
|
+
const fixtureRoot = fs.realpathSync(opts.fixtureRoot);
|
|
121
|
+
const launcherPath = resolveFromPath("claude", opts.env);
|
|
122
|
+
if (!launcherPath) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"claude-launcher-fence: fail-closed — no executable `claude` on the exact child PATH, so the launcher's identity cannot be pinned before launch",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!path.isAbsolute(launcherPath)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`claude-launcher-fence: fail-closed — \`claude\` resolved through a relative PATH entry (${launcherPath}); a cwd-dependent launcher cannot be pinned`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (isInside(fixtureRoot, launcherPath)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`claude-launcher-fence: fail-closed — the selected launcher ${launcherPath} is INSIDE the fixture cleanup root ${fixtureRoot}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const lst = fs.lstatSync(launcherPath);
|
|
138
|
+
const kind: ClaudeLauncherSnapshot["kind"] = lst.isSymbolicLink() ? "symlink" : "file";
|
|
139
|
+
if (kind === "file" && !lst.isFile()) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`claude-launcher-fence: fail-closed — the launcher ${launcherPath} is neither a regular file nor a symlink`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const linkText = kind === "symlink" ? fs.readlinkSync(launcherPath) : null;
|
|
145
|
+
let resolvedPath: string;
|
|
146
|
+
try {
|
|
147
|
+
resolvedPath = fs.realpathSync(launcherPath);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`claude-launcher-fence: fail-closed — the launcher ${launcherPath} does not resolve to a real target: ${
|
|
151
|
+
err instanceof Error ? err.message : String(err)
|
|
152
|
+
}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!isExecutableFile(resolvedPath)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`claude-launcher-fence: fail-closed — the launcher's resolved target ${resolvedPath} is not a regular executable file`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (isInside(fixtureRoot, resolvedPath)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`claude-launcher-fence: fail-closed — the launcher's resolved target ${resolvedPath} is INSIDE the fixture cleanup root ${fixtureRoot}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
launcherPath,
|
|
167
|
+
kind,
|
|
168
|
+
linkText,
|
|
169
|
+
resolvedPath,
|
|
170
|
+
sha256: sha256File(resolvedPath),
|
|
171
|
+
fixtureRoot,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* INTEGRITY ORACLE. Re-derive every pinned fact and return one problem string per mismatch —
|
|
177
|
+
* empty means the launcher, its link, its resolved target and that target's content are all
|
|
178
|
+
* exactly as the preflight saw them, and the target is still a regular executable. Runs on the
|
|
179
|
+
* success path AND on every failure teardown, BEFORE fixture removal.
|
|
180
|
+
*/
|
|
181
|
+
export function verifyClaudeLauncher(snapshot: ClaudeLauncherSnapshot): string[] {
|
|
182
|
+
const problems: string[] = [];
|
|
183
|
+
let lst: fs.Stats;
|
|
184
|
+
try {
|
|
185
|
+
lst = fs.lstatSync(snapshot.launcherPath);
|
|
186
|
+
} catch {
|
|
187
|
+
problems.push(`the launcher ${snapshot.launcherPath} no longer exists`);
|
|
188
|
+
return problems;
|
|
189
|
+
}
|
|
190
|
+
const kindNow: ClaudeLauncherSnapshot["kind"] = lst.isSymbolicLink() ? "symlink" : "file";
|
|
191
|
+
if (kindNow !== snapshot.kind) {
|
|
192
|
+
problems.push(`the launcher changed kind: was ${snapshot.kind}, is now ${kindNow}`);
|
|
193
|
+
}
|
|
194
|
+
if (kindNow === "symlink") {
|
|
195
|
+
// A raced-away link is an INTEGRITY PROBLEM, never an exception that could mask the run
|
|
196
|
+
// error this oracle runs alongside.
|
|
197
|
+
let linkNow: string | null = null;
|
|
198
|
+
try {
|
|
199
|
+
linkNow = fs.readlinkSync(snapshot.launcherPath);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
problems.push(
|
|
202
|
+
`the launcher's link text could not be re-read: ${err instanceof Error ? err.message : String(err)}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (linkNow !== null && linkNow !== snapshot.linkText) {
|
|
206
|
+
problems.push(`the launcher's link text changed: was ${snapshot.linkText ?? "(none)"}, is now ${linkNow}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
let resolvedNow: string | null = null;
|
|
210
|
+
try {
|
|
211
|
+
resolvedNow = fs.realpathSync(snapshot.launcherPath);
|
|
212
|
+
} catch {
|
|
213
|
+
problems.push(`the launcher ${snapshot.launcherPath} no longer resolves to a real target`);
|
|
214
|
+
}
|
|
215
|
+
if (resolvedNow !== null) {
|
|
216
|
+
if (resolvedNow !== snapshot.resolvedPath) {
|
|
217
|
+
problems.push(`the launcher's resolved target moved: was ${snapshot.resolvedPath}, is now ${resolvedNow}`);
|
|
218
|
+
}
|
|
219
|
+
if (!isExecutableFile(resolvedNow)) {
|
|
220
|
+
problems.push(`the launcher's resolved target ${resolvedNow} is no longer a regular executable file`);
|
|
221
|
+
} else {
|
|
222
|
+
try {
|
|
223
|
+
if (sha256File(resolvedNow) !== snapshot.sha256) {
|
|
224
|
+
problems.push(`the launcher's resolved target ${resolvedNow} changed content (sha256 mismatch)`);
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
problems.push(
|
|
228
|
+
`the launcher's resolved target ${resolvedNow} could not be hashed: ${err instanceof Error ? err.message : String(err)}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (isInside(snapshot.fixtureRoot, resolvedNow)) {
|
|
233
|
+
problems.push(
|
|
234
|
+
`the launcher's resolved target ${resolvedNow} now lies INSIDE the fixture cleanup root ${snapshot.fixtureRoot}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return problems;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What the cleanup guard could prove, stated honestly: a KNOWN fixture reference and an
|
|
242
|
+
* UNPROVABLE state both block removal, but each is named as itself in `problems`. */
|
|
243
|
+
export interface LauncherCleanupVerdict {
|
|
244
|
+
/** True only when removal is PROVEN safe: the launcher demonstrably does not reference the fixture. */
|
|
245
|
+
safeToRemove: boolean;
|
|
246
|
+
/** Named reasons removal is blocked — a fixture reference, or a syscall that left safety unproven. */
|
|
247
|
+
problems: string[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* CLEANUP GUARD. Decides whether `rm -rf <fixture>` is destructive: does the launcher, its
|
|
252
|
+
* current link target, or its current resolved path lie inside the fixture cleanup root — the
|
|
253
|
+
* exact state in which removal would dangle the operator's launcher? FAIL-CLOSED on uncertainty:
|
|
254
|
+
* a readlink/lstat/realpath error blocks removal with a "not proven safe" problem rather than
|
|
255
|
+
* being laundered into either a false "references fixture" or a false all-clear. A launcher that
|
|
256
|
+
* is missing ENTIRELY is safe to clean around — nothing is left for removal to sever, and the
|
|
257
|
+
* integrity oracle reports that damage by name.
|
|
258
|
+
*/
|
|
259
|
+
export function assessLauncherCleanup(snapshot: ClaudeLauncherSnapshot): LauncherCleanupVerdict {
|
|
260
|
+
if (isInside(snapshot.fixtureRoot, snapshot.launcherPath)) {
|
|
261
|
+
return {
|
|
262
|
+
safeToRemove: false,
|
|
263
|
+
problems: [`the launcher ${snapshot.launcherPath} itself lies inside the fixture cleanup root`],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
let lst: fs.Stats;
|
|
267
|
+
try {
|
|
268
|
+
lst = fs.lstatSync(snapshot.launcherPath);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { safeToRemove: true, problems: [] };
|
|
271
|
+
return {
|
|
272
|
+
safeToRemove: false,
|
|
273
|
+
problems: [
|
|
274
|
+
`the launcher ${snapshot.launcherPath} could not be inspected (${
|
|
275
|
+
err instanceof Error ? err.message : String(err)
|
|
276
|
+
}) — removal is not proven safe`,
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (lst.isSymbolicLink()) {
|
|
281
|
+
let hop: string;
|
|
282
|
+
try {
|
|
283
|
+
hop = path.resolve(path.dirname(snapshot.launcherPath), fs.readlinkSync(snapshot.launcherPath));
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return {
|
|
286
|
+
safeToRemove: false,
|
|
287
|
+
problems: [
|
|
288
|
+
`the launcher's link text could not be read (${
|
|
289
|
+
err instanceof Error ? err.message : String(err)
|
|
290
|
+
}) — removal is not proven safe`,
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (isInside(snapshot.fixtureRoot, hop)) {
|
|
295
|
+
return {
|
|
296
|
+
safeToRemove: false,
|
|
297
|
+
problems: [`the launcher's link target ${hop} lies inside the fixture cleanup root`],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const resolved = fs.realpathSync(snapshot.launcherPath);
|
|
303
|
+
if (isInside(snapshot.fixtureRoot, resolved)) {
|
|
304
|
+
return {
|
|
305
|
+
safeToRemove: false,
|
|
306
|
+
problems: [`the launcher's resolved target ${resolved} lies inside the fixture cleanup root`],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// A dangling or unresolvable chain: the first hop above was proven outside the fixture, but
|
|
311
|
+
// the FULL chain was not — fail closed rather than guess.
|
|
312
|
+
return {
|
|
313
|
+
safeToRemove: false,
|
|
314
|
+
problems: [
|
|
315
|
+
`the launcher ${snapshot.launcherPath} could not be fully resolved (${
|
|
316
|
+
err instanceof Error ? err.message : String(err)
|
|
317
|
+
}) — removal is not proven safe`,
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return { safeToRemove: true, problems: [] };
|
|
322
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The TypeScript half of the release-gate STEP OUTCOME protocol
|
|
2
|
+
// (scripts/lib/step-outcome.sh owns the contract prose and the shell half).
|
|
3
|
+
//
|
|
4
|
+
// A LIVE smoke that lacks a prerequisite must be DISTINGUISHABLE from one that
|
|
5
|
+
// ran and passed. Before P1 every smoke exited 0 on a skip, so `run_live_step`
|
|
6
|
+
// counted it PASS and the release summary could not prove a required call had
|
|
7
|
+
// happened at all. `skipLive` is the single exit any smoke takes when it
|
|
8
|
+
// declines: one reserved code the aggregate classifies as SKIP, plus one marker
|
|
9
|
+
// line so an operator reading the log sees WHICH prerequisite was missing
|
|
10
|
+
// without decoding a number.
|
|
11
|
+
//
|
|
12
|
+
// The two halves must agree. `check-release-gate-outcomes` reads this constant
|
|
13
|
+
// and the shell one and refuses a mismatch — a protocol that drifted between
|
|
14
|
+
// languages would silently reclassify every skip as a failure (or worse).
|
|
15
|
+
|
|
16
|
+
/** SKIP. Must equal `ENTWURF_STEP_SKIP_EXIT` in scripts/lib/step-outcome.sh. */
|
|
17
|
+
export const LIVE_SKIP_EXIT = 97;
|
|
18
|
+
|
|
19
|
+
/** The machine-greppable prefix every skip line carries. */
|
|
20
|
+
export const LIVE_SKIP_MARKER = "[entwurf:skip]";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decline this smoke: print the marker + reason on stderr and exit with the
|
|
24
|
+
* protocol's SKIP code. Never returns.
|
|
25
|
+
*
|
|
26
|
+
* `label` is the smoke's own name (so a multi-step aggregate log stays
|
|
27
|
+
* attributable); `reason` states the missing prerequisite AND how to supply it —
|
|
28
|
+
* an operator who hits a SKIP in a cut run needs the fix, not the diagnosis.
|
|
29
|
+
*/
|
|
30
|
+
export function skipLive(label: string, reason: string): never {
|
|
31
|
+
console.error(`${LIVE_SKIP_MARKER} ${label} — ${reason}`);
|
|
32
|
+
process.exit(LIVE_SKIP_EXIT);
|
|
33
|
+
}
|
|
@@ -78,6 +78,70 @@ export function signatureOnFailureLine(output: string, token: string): boolean {
|
|
|
78
78
|
return output.split("\n").some((line) => line.includes(token) && !/^\s*ok\b/.test(line));
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** run_vitest prints this line when the runner asked it for a structured report.
|
|
82
|
+
* Its PRESENCE — not the report's readability — is what declares the lane structured. */
|
|
83
|
+
export const VITEST_STRUCTURED_MARKER = "__ENTWURF_VITEST_JSON__";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a gate run said about WHICH tests failed.
|
|
87
|
+
*
|
|
88
|
+
* - `"legacy"`: no structured marker — a hand-built node:assert/shell gate whose
|
|
89
|
+
* `ok`-line oracle is unchanged.
|
|
90
|
+
* - `"unreadable"`: the marker was printed but the report is missing or malformed.
|
|
91
|
+
* This NEVER falls back to token scanning: a structured lane that lost its report
|
|
92
|
+
* has no attribution at all, so the mutant is WRONG-REASON, not KILLED.
|
|
93
|
+
* - a title array: the exact `fullName` of every FAILED test.
|
|
94
|
+
*/
|
|
95
|
+
export type FailedTestTitles = "legacy" | "unreadable" | string[];
|
|
96
|
+
|
|
97
|
+
/** Read the failed-test titles a vitest gate wrote, if it declared itself structured. */
|
|
98
|
+
export function readVitestFailedTitles(output: string, reportPath: string | null): FailedTestTitles {
|
|
99
|
+
if (!output.split("\n").some((line) => line.trim() === VITEST_STRUCTURED_MARKER)) return "legacy";
|
|
100
|
+
if (!reportPath) return "unreadable";
|
|
101
|
+
let raw: string;
|
|
102
|
+
try {
|
|
103
|
+
raw = fs.readFileSync(reportPath, "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
return "unreadable";
|
|
106
|
+
}
|
|
107
|
+
let report: {
|
|
108
|
+
testResults?: Array<{ assertionResults?: Array<{ status?: string; fullName?: string; title?: string }> }>;
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
report = JSON.parse(raw);
|
|
112
|
+
} catch {
|
|
113
|
+
return "unreadable";
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(report.testResults)) return "unreadable";
|
|
116
|
+
const titles: string[] = [];
|
|
117
|
+
for (const suite of report.testResults) {
|
|
118
|
+
for (const assertion of suite.assertionResults ?? []) {
|
|
119
|
+
if (assertion.status !== "failed") continue;
|
|
120
|
+
titles.push(assertion.fullName ?? assertion.title ?? "");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return titles;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Attribute a kill to its claim. A Vitest failure's CODE FRAME quotes the source lines
|
|
128
|
+
* around the assertion — including an adjacent PASSING test's `it("[QK:…]" …)` title —
|
|
129
|
+
* so scanning output lines would certify a claim whose test never failed (measured:
|
|
130
|
+
* issue #62 review). Structured lanes therefore read only the failed-test title set.
|
|
131
|
+
*/
|
|
132
|
+
export function signatureAttributedToFailure(output: string, token: string, failed: FailedTestTitles): boolean {
|
|
133
|
+
if (failed === "legacy") return signatureOnFailureLine(output, token);
|
|
134
|
+
if (failed === "unreadable") return false;
|
|
135
|
+
return failed.some((title) => title.includes(token));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One legible word for the report line, so a WRONG-REASON says WHY it could not attribute. */
|
|
139
|
+
export function describeAttribution(failed: FailedTestTitles): string {
|
|
140
|
+
if (failed === "legacy") return "failure-line";
|
|
141
|
+
if (failed === "unreadable") return "vitest-structured-but-unreadable";
|
|
142
|
+
return `vitest-failed-titles(${failed.length})`;
|
|
143
|
+
}
|
|
144
|
+
|
|
81
145
|
// ── manifest schema (fail-loud, exact keys) ─────────────────────────────────
|
|
82
146
|
|
|
83
147
|
export interface MutantSpec {
|
|
@@ -430,6 +494,8 @@ export interface GateRunResult {
|
|
|
430
494
|
timedOut: boolean;
|
|
431
495
|
output: string;
|
|
432
496
|
seconds: number;
|
|
497
|
+
/** Structured failed-test attribution; `"legacy"` for every non-vitest gate. */
|
|
498
|
+
failedTitles: FailedTestTitles;
|
|
433
499
|
}
|
|
434
500
|
|
|
435
501
|
const OUTPUT_CAP = 4 * 1024 * 1024;
|
|
@@ -437,12 +503,23 @@ const OUTPUT_CAP = 4 * 1024 * 1024;
|
|
|
437
503
|
/** Env prefixes the outer fence strips so a gate child starts from a neutral host. */
|
|
438
504
|
const STRIP_ENV_PREFIXES = ["ENTWURF_", "AGY_", "PI_SESSION_ID", "PI_AGENT_ID"];
|
|
439
505
|
|
|
506
|
+
/** Where a vitest-backed gate writes its machine report for this one invocation. */
|
|
507
|
+
function vitestReportPath(invocationDir: string): string {
|
|
508
|
+
return path.join(invocationDir, "vitest-report.json");
|
|
509
|
+
}
|
|
510
|
+
|
|
440
511
|
function fencedEnv(invocationDir: string): NodeJS.ProcessEnv {
|
|
441
512
|
const env: NodeJS.ProcessEnv = {};
|
|
442
513
|
for (const [k, v] of Object.entries(process.env)) {
|
|
443
514
|
if (STRIP_ENV_PREFIXES.some((p) => k === p || k.startsWith(p))) continue;
|
|
444
515
|
env[k] = v;
|
|
445
516
|
}
|
|
517
|
+
// A vitest-backed gate writes its machine report HERE, inside the per-invocation dir
|
|
518
|
+
// (outside the snapshot repo, so the work-surface purity hash never sees it). The
|
|
519
|
+
// report is a FILE and not stdout on purpose: `output` merges stdout+stderr, so one
|
|
520
|
+
// vite warning ahead of the JSON would break the parse and silently downgrade a real
|
|
521
|
+
// kill to WRONG-REASON. Legacy gates ignore the variable.
|
|
522
|
+
env.ENTWURF_MUTATION_VITEST_REPORT = vitestReportPath(invocationDir);
|
|
446
523
|
for (const d of ["home", "xdg-data", "xdg-config", "xdg-cache", "xdg-state"]) {
|
|
447
524
|
fs.mkdirSync(path.join(invocationDir, d), { recursive: true });
|
|
448
525
|
}
|
|
@@ -499,7 +576,9 @@ export function runGateBounded(opts: {
|
|
|
499
576
|
});
|
|
500
577
|
child.on("close", (code) => {
|
|
501
578
|
clearTimeout(timer);
|
|
502
|
-
|
|
579
|
+
// Read the report BEFORE the caller removes the invocation dir.
|
|
580
|
+
const failedTitles = readVitestFailedTitles(output, vitestReportPath(opts.invocationDir));
|
|
581
|
+
resolve({ exitCode: code, timedOut, output, failedTitles, seconds: (Date.now() - started) / 1000 });
|
|
503
582
|
});
|
|
504
583
|
});
|
|
505
584
|
}
|
|
@@ -548,6 +627,29 @@ function countOccurrences(haystack: string, needle: string): number {
|
|
|
548
627
|
return count;
|
|
549
628
|
}
|
|
550
629
|
|
|
630
|
+
/** Tail of a red control run's captured output that gets echoed into the log. */
|
|
631
|
+
const CONTROL_TAIL_LINES = 40;
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Echo a RED control run's captured output. Without this the runner reports the
|
|
635
|
+
* verdict (`RED (exit=1 …)`) and DROPS the reason it already holds, which makes a
|
|
636
|
+
* CONTROL-RED that only reproduces on one host — a CI runner, say — undiagnosable
|
|
637
|
+
* from its log: the same single line no matter how often it is re-run. A bounded
|
|
638
|
+
* tail is enough, because a gate names its failure on the way out. Silence is a
|
|
639
|
+
* finding too, so an empty capture is reported as empty rather than skipped.
|
|
640
|
+
*/
|
|
641
|
+
function logControlOutput(output: string, log: (line: string) => void): void {
|
|
642
|
+
const body = output.replace(/\s+$/, "");
|
|
643
|
+
if (body === "") {
|
|
644
|
+
log(" │ (the control run produced no output — the failure is upstream of the gate's own reporting)");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const lines = body.split("\n");
|
|
648
|
+
const dropped = Math.max(0, lines.length - CONTROL_TAIL_LINES);
|
|
649
|
+
if (dropped > 0) log(` │ … ${dropped} earlier line(s) omitted`);
|
|
650
|
+
for (const line of lines.slice(-CONTROL_TAIL_LINES)) log(` │ ${line}`);
|
|
651
|
+
}
|
|
652
|
+
|
|
551
653
|
/**
|
|
552
654
|
* Run every mutant grouped by its gate command, with the control-mutant-restore-
|
|
553
655
|
* control state machine. A red CONTROL-PRE aborts the whole group (every mutant
|
|
@@ -593,6 +695,7 @@ export async function qualifyMutants(
|
|
|
593
695
|
` control-pre ${gate.join(" ")}: ${controlPreOk ? "green" : `RED (exit=${pre.exitCode} timedOut=${pre.timedOut})`} in ${pre.seconds.toFixed(1)}s`,
|
|
594
696
|
);
|
|
595
697
|
if (!controlPreOk) {
|
|
698
|
+
logControlOutput(pre.output, log);
|
|
596
699
|
group.control = "pre-red";
|
|
597
700
|
for (const m of groupMutants) {
|
|
598
701
|
group.mutants.push({
|
|
@@ -678,7 +781,7 @@ export async function qualifyMutants(
|
|
|
678
781
|
matchCount: 1,
|
|
679
782
|
timedOut: run.timedOut,
|
|
680
783
|
exitCode: run.exitCode,
|
|
681
|
-
signatureOnFailureLine:
|
|
784
|
+
signatureOnFailureLine: signatureAttributedToFailure(run.output, m.signature, run.failedTitles),
|
|
682
785
|
restoredOk,
|
|
683
786
|
});
|
|
684
787
|
if (!restoredOk) groupImpure = true;
|
|
@@ -687,7 +790,9 @@ export async function qualifyMutants(
|
|
|
687
790
|
verdict,
|
|
688
791
|
seconds: run.seconds,
|
|
689
792
|
subjectSha256: originalSha,
|
|
690
|
-
detail:
|
|
793
|
+
detail:
|
|
794
|
+
`exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}` +
|
|
795
|
+
` attribution=${describeAttribution(run.failedTitles)}`,
|
|
691
796
|
});
|
|
692
797
|
log(
|
|
693
798
|
` claim ${m.claim}: ${verdict} in ${run.seconds.toFixed(1)}s (subject ${m.subject} sha256=${originalSha.slice(0, 12)}…)`,
|
|
@@ -705,6 +810,7 @@ export async function qualifyMutants(
|
|
|
705
810
|
log(
|
|
706
811
|
` control-post ${gate.join(" ")}: ${controlPostOk ? "green" : `RED (exit=${post.exitCode} timedOut=${post.timedOut}) — restore contamination or gate state leak`} in ${post.seconds.toFixed(1)}s`,
|
|
707
812
|
);
|
|
813
|
+
if (!controlPostOk) logControlOutput(post.output, log);
|
|
708
814
|
}
|
|
709
815
|
|
|
710
816
|
const postTree = computeTreeManifest(snapshot.repoDir);
|
|
@@ -29,20 +29,29 @@ export interface ProbePhaseTimeouts {
|
|
|
29
29
|
initializeMs: number;
|
|
30
30
|
newSessionMs: number;
|
|
31
31
|
setModelMs: number;
|
|
32
|
-
promptMs: number;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// a D verdict is only readable against
|
|
38
|
-
// invent its own.
|
|
34
|
+
// BOOTSTRAP phase timeouts — MUST equal backend.ts's INITIALIZE/NEW_SESSION/
|
|
35
|
+
// SET_MODEL_TIMEOUT_MS (check-probe-ordering pins them against the source).
|
|
36
|
+
// §11-7: a bootstrap-phase D verdict is only readable against production's own
|
|
37
|
+
// boundaries, so the probe may not invent its own.
|
|
39
38
|
export const PROBE_PHASE_TIMEOUTS: ProbePhaseTimeouts = {
|
|
40
39
|
initializeMs: 30_000,
|
|
41
40
|
newSessionMs: 30_000,
|
|
42
41
|
setModelMs: 30_000,
|
|
43
|
-
promptMs: 600_000,
|
|
44
42
|
};
|
|
45
43
|
|
|
44
|
+
// The prompt phase has NO production counterpart to match: backend.ts ends a
|
|
45
|
+
// prompt on lifecycle events only (resolve / abort / child gone) and carries no
|
|
46
|
+
// wall-clock cutoff, by policy — an active turn is not a failed turn for being
|
|
47
|
+
// long. This value is therefore the MEASUREMENT HARNESS's own bounded
|
|
48
|
+
// observation horizon: it stops a probe RUN from hanging forever. It is not a
|
|
49
|
+
// production contract, not an equivalence, and not a deadline any §11-7 verdict
|
|
50
|
+
// is read against. Horizon expiry means the run's prompt window is
|
|
51
|
+
// INCONCLUSIVE — the artifact is preserved and read as "not measured", never as
|
|
52
|
+
// a model of production killing a turn.
|
|
53
|
+
export const PROBE_PROMPT_OBSERVATION_MS = 600_000;
|
|
54
|
+
|
|
46
55
|
/** The two adapter methods the turn drives — satisfied by the REAL claudeAdapter
|
|
47
56
|
* (emitted) in the LIVE runner and by recording stubs in the gate. */
|
|
48
57
|
export interface ProbeAdapterSeam {
|
|
@@ -108,7 +117,10 @@ export interface ProbeTurnParams {
|
|
|
108
117
|
adapter: ProbeAdapterSeam;
|
|
109
118
|
enrichMcpServers: ProbeMcpEnricher;
|
|
110
119
|
log: (event: ProbeEventName, payload?: Record<string, unknown>) => void;
|
|
120
|
+
/** Bootstrap boundaries (production-pinned). Defaults to PROBE_PHASE_TIMEOUTS. */
|
|
111
121
|
timeouts?: ProbePhaseTimeouts;
|
|
122
|
+
/** Harness observation horizon for the prompt phase — NOT a production deadline. */
|
|
123
|
+
promptObservationMs?: number;
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
export interface ProbeTurnResult {
|
|
@@ -158,7 +170,7 @@ export async function driveProbeTurn(connection: AcpConnectionLike, params: Prob
|
|
|
158
170
|
}),
|
|
159
171
|
);
|
|
160
172
|
|
|
161
|
-
// _meta + wire servers exactly as backend.ts assembles them (S2g
|
|
173
|
+
// _meta + wire servers exactly as backend.ts assembles them (S2g / carrier-less shape): the
|
|
162
174
|
// injected production buildSessionMeta (omitted key when undefined) + the
|
|
163
175
|
// injected production envelope enrichment over the resolved config.
|
|
164
176
|
const sessionMeta = params.adapter.buildSessionMeta(
|
|
@@ -196,11 +208,20 @@ export async function driveProbeTurn(connection: AcpConnectionLike, params: Prob
|
|
|
196
208
|
}),
|
|
197
209
|
);
|
|
198
210
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
211
|
+
// The horizon below bounds THIS MEASUREMENT RUN, not the turn: production has
|
|
212
|
+
// no prompt deadline, so an expiry here is an inconclusive observation.
|
|
213
|
+
const promptObservationMs = params.promptObservationMs ?? PROBE_PROMPT_OBSERVATION_MS;
|
|
214
|
+
const promptResult = await runPhase(
|
|
215
|
+
"prompt",
|
|
216
|
+
PROBE_EVENTS.promptStart,
|
|
217
|
+
PROBE_EVENTS.promptEnd,
|
|
218
|
+
log,
|
|
219
|
+
promptObservationMs,
|
|
220
|
+
() =>
|
|
221
|
+
connection.prompt({
|
|
222
|
+
sessionId: acpSessionId,
|
|
223
|
+
prompt: [{ type: "text", text: params.promptText }],
|
|
224
|
+
}),
|
|
204
225
|
);
|
|
205
226
|
|
|
206
227
|
return { acpSessionId, stopReason: promptResult?.stopReason };
|