@junghanacs/entwurf 0.12.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 +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smoke-entwurf-v2-spawn-resume-live — the 0.11.0 (A) acceptance gate: a REAL `pi` child is
|
|
3
|
+
* spawned by the production spawn-bg resume path, stands its control socket up as a resident,
|
|
4
|
+
* runs the resume turn, and the lock is released exactly once. This is the evidence the whole
|
|
5
|
+
* 0.11.0 "spawn-bg resident lifecycle" headline rests on — every other gate proves a SLICE
|
|
6
|
+
* (the deterministic decider table, the matrix-live transport sentinel, the spawn-live OS
|
|
7
|
+
* substrate watcher), but none has ever watched a real child pi resume and DO a model turn.
|
|
8
|
+
* 5c-3c deferred that proof to "5d"; 5d's matrix-live built only the control/mailbox/guard
|
|
9
|
+
* cells. This smoke closes that acceptance debt — and it is the gate v1 deprecation (0.12)
|
|
10
|
+
* is predicated on: until a real spawn-bg resume is seen LIVE, there is no honest ground to
|
|
11
|
+
* turn the v1 entwurf surface off.
|
|
12
|
+
*
|
|
13
|
+
* The FULL loop (no synthetic stand-in):
|
|
14
|
+
* 1. mint a `backend=pi` meta identity in a temp store → take its gardenId as the seed id.
|
|
15
|
+
* 2. seed a REAL dormant pi session: a one-shot `pi --mode json -p --no-extensions` under
|
|
16
|
+
* that gid writes a saved session JSONL into the REAL `~/.pi/agent/sessions` (the dir
|
|
17
|
+
* `findSessionFileById` / `resolveResumeLaunchIdentity` read — NOT env-redirected, because
|
|
18
|
+
* this smoke validates the production resume path, not a fixture path). No control socket
|
|
19
|
+
* survives the one-shot → the citizen is DORMANT.
|
|
20
|
+
* 3. drive the production `runEntwurfV2({intent:"owned-outcome"})` → the decider routes a
|
|
21
|
+
* dormant in-domain pi citizen to spawn-bg resume (DISPATCH_TABLE owned-outcome×dormant) →
|
|
22
|
+
* the production spawn factory launches a REAL detached `pi --entwurf-control` resume child.
|
|
23
|
+
* 4. assert: result = executed/spawn-bg/socket-alive/released; the lock was released EXACTLY
|
|
24
|
+
* once (release-seam counter) and no lock file remains; the resident pid is alive and its
|
|
25
|
+
* socket is connectable.
|
|
26
|
+
* 5. assert the child actually WORKED: poll the same session JSONL for the resume USER nonce
|
|
27
|
+
* (the turn was injected) and then the assistant OK nonce (the model actually replied) —
|
|
28
|
+
* socket-alive alone would prove "process up", not "resumed and did a turn".
|
|
29
|
+
*
|
|
30
|
+
* model-in-loop is IN here (unlike matrix-live, which is a transport/lock sentinel): the
|
|
31
|
+
* assistant nonce is the whole point — a resume that stands a socket up but never produces a
|
|
32
|
+
* model turn is not a resumed citizen. So this is LIVE-only and OUT of `pnpm check`; honest
|
|
33
|
+
* SKIP when LIVE!=1 (a release-gate that hard-fails without auth/model is unrunnable
|
|
34
|
+
* unattended — skip is CI safety, NOT an acceptance PASS).
|
|
35
|
+
* ENTWURF_LIVE_TARGET = "<provider>/<model>" (default "openai-codex/gpt-5.4")
|
|
36
|
+
* ENTWURF_SPAWN_RESUME_ASSISTANT_TIMEOUT_MS (default 180000)
|
|
37
|
+
* LIVE=1 ./run.sh smoke-entwurf-v2-spawn-resume-live
|
|
38
|
+
*
|
|
39
|
+
* Automation seams: the resident child is reaped (SIGTERM→socket-gone poll→SIGKILL) and its
|
|
40
|
+
* real control socket removed in `finally`; the ONE seed session file is deleted on a clean
|
|
41
|
+
* pass (never the whole real sessions dir) and PRESERVED with its path printed on failure,
|
|
42
|
+
* alongside the temp world / gid / lock path / pi seed stderr tail.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { spawnSync } from "node:child_process";
|
|
46
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
47
|
+
import * as fsp from "node:fs/promises";
|
|
48
|
+
import * as os from "node:os";
|
|
49
|
+
import * as path from "node:path";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
import type { SenderEnvelope } from "../pi-extensions/lib/entwurf-control-rpc.ts";
|
|
52
|
+
import { buildSessionName, findSessionFileById, readSessionIdentity } from "../pi-extensions/lib/entwurf-core.ts";
|
|
53
|
+
import { type LockClaim, lockPathFor, releaseLock as realReleaseLock } from "../pi-extensions/lib/entwurf-v2-lock.ts";
|
|
54
|
+
import { makeProductionEntwurfV2Deps } from "../pi-extensions/lib/entwurf-v2-production.ts";
|
|
55
|
+
import type { EntwurfV2RunResult } from "../pi-extensions/lib/entwurf-v2-runner.ts";
|
|
56
|
+
import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
|
|
57
|
+
import { resolveResumeLaunchIdentity } from "../pi-extensions/lib/entwurf-v2-spawn-production.ts";
|
|
58
|
+
import { upsertMetaSession } from "../pi-extensions/lib/meta-session.ts";
|
|
59
|
+
import { controlSocketPath } from "../pi-extensions/lib/socket-discovery.ts";
|
|
60
|
+
|
|
61
|
+
// pi owns the canonical control-socket dir; the resume child opens its socket HERE and the
|
|
62
|
+
// decider's expectedSocketPath is built from the SAME dir — they must coincide (a fresh gid
|
|
63
|
+
// keeps us off any live session's path; cleanup only ever touches our own gid's socket).
|
|
64
|
+
const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
|
|
65
|
+
const SOCKET_SUFFIX = ".sock";
|
|
66
|
+
// Release-gate topology: repo-under-test, not deployment smoke. The spawned
|
|
67
|
+
// resident must load this checkout's extension, independent of global pi packages.
|
|
68
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
69
|
+
const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
|
|
70
|
+
|
|
71
|
+
const SEED_TIMEOUT_MS = 120_000; // F5: a real seed model turn can be slow.
|
|
72
|
+
const OBSERVE_TIMEOUT_MS = 30_000; // spawn-bg watcher's socket-alive observe window.
|
|
73
|
+
const SEED_IDENTITY_POLL_MS = 10_000; // GPT pin: defend against seed file flush/rename timing.
|
|
74
|
+
const USER_TURN_TIMEOUT_MS = 60_000;
|
|
75
|
+
const ASSISTANT_TURN_TIMEOUT_MS = (() => {
|
|
76
|
+
const n = Number(process.env.ENTWURF_SPAWN_RESUME_ASSISTANT_TIMEOUT_MS);
|
|
77
|
+
return Number.isFinite(n) && n > 0 ? n : 180_000;
|
|
78
|
+
})();
|
|
79
|
+
const POLL_MS = 250;
|
|
80
|
+
|
|
81
|
+
let passed = 0;
|
|
82
|
+
const artifacts: Record<string, string> = {};
|
|
83
|
+
|
|
84
|
+
function ok(label: string, cond: boolean): void {
|
|
85
|
+
if (!cond) throw new Error(`SMOKE FAIL: ${label}`);
|
|
86
|
+
console.log(` ok ${label}`);
|
|
87
|
+
passed++;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveTarget(): { provider: string; model: string } {
|
|
91
|
+
const combined = process.env.ENTWURF_LIVE_TARGET?.trim();
|
|
92
|
+
if (combined) {
|
|
93
|
+
const slash = combined.indexOf("/");
|
|
94
|
+
if (slash <= 0 || slash === combined.length - 1) {
|
|
95
|
+
throw new Error(`ENTWURF_LIVE_TARGET must be "<provider>/<model>", got: ${JSON.stringify(combined)}`);
|
|
96
|
+
}
|
|
97
|
+
return { provider: combined.slice(0, slash), model: combined.slice(slash + 1) };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
provider: process.env.ENTWURF_LIVE_PROVIDER?.trim() || "openai-codex",
|
|
101
|
+
model: process.env.ENTWURF_LIVE_MODEL?.trim() || "gpt-5.4",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sleep(ms: number): Promise<void> {
|
|
106
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pidAlive(pid: number): boolean {
|
|
110
|
+
try {
|
|
111
|
+
process.kill(pid, 0);
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Tear the detached resident down for real: SIGTERM, wait for both the pid to die AND the
|
|
119
|
+
// socket to disappear, then a SIGKILL backstop. The child is detached (no ChildProcess handle),
|
|
120
|
+
// so liveness is observed via `kill(pid, 0)` + the socket file, not an `exit` event.
|
|
121
|
+
async function terminateResident(pid: number, sockPath: string, graceMs = 3_000): Promise<void> {
|
|
122
|
+
try {
|
|
123
|
+
process.kill(pid, "SIGTERM");
|
|
124
|
+
} catch {
|
|
125
|
+
return; // already gone
|
|
126
|
+
}
|
|
127
|
+
const deadline = Date.now() + graceMs;
|
|
128
|
+
while (Date.now() < deadline) {
|
|
129
|
+
if (!pidAlive(pid) && !existsSync(sockPath)) return;
|
|
130
|
+
await sleep(POLL_MS);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, "SIGKILL");
|
|
134
|
+
} catch {
|
|
135
|
+
// already gone
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function smokeSender(gardenId: string, cwd: string): SenderEnvelope {
|
|
140
|
+
return {
|
|
141
|
+
sessionId: gardenId,
|
|
142
|
+
agentId: "smoke/spawn-resume-live",
|
|
143
|
+
cwd,
|
|
144
|
+
timestamp: new Date(0).toISOString(),
|
|
145
|
+
origin: "pi-session",
|
|
146
|
+
replyable: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Read the session JSONL and search ONLY the lines appended after the seed (append-only rigor:
|
|
151
|
+
// the resume nonces are unique so a whole-file scan would also be correct, but slicing past the
|
|
152
|
+
// seed line count proves the turn was added by the RESUME, not pre-existing). JSON-parse each
|
|
153
|
+
// line and match on the stringified entry — never assume a type/role shape (GPT pin 7).
|
|
154
|
+
async function pollForNonce(
|
|
155
|
+
sessionFile: string,
|
|
156
|
+
seedLineCount: number,
|
|
157
|
+
needle: string,
|
|
158
|
+
timeoutMs: number,
|
|
159
|
+
): Promise<boolean> {
|
|
160
|
+
const deadline = Date.now() + timeoutMs;
|
|
161
|
+
while (Date.now() < deadline) {
|
|
162
|
+
const lines = readFileSync(sessionFile, "utf8")
|
|
163
|
+
.split("\n")
|
|
164
|
+
.filter((l) => l.trim());
|
|
165
|
+
for (const line of lines.slice(seedLineCount)) {
|
|
166
|
+
let entry: unknown;
|
|
167
|
+
try {
|
|
168
|
+
entry = JSON.parse(line);
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (JSON.stringify(entry).includes(needle)) return true;
|
|
173
|
+
}
|
|
174
|
+
await sleep(POLL_MS);
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function main(): Promise<void> {
|
|
180
|
+
if (process.env.LIVE !== "1") {
|
|
181
|
+
console.log(
|
|
182
|
+
"[smoke-entwurf-v2-spawn-resume-live] skipped — set LIVE=1 to run (spawns a real pi child + opens a real socket).",
|
|
183
|
+
);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const { provider, model } = resolveTarget();
|
|
188
|
+
// ACP override is OUT of scope for 0.11.0 (A): `--no-extensions --provider entwurf` is a
|
|
189
|
+
// broken combination, and this smoke proves spawn-bg resident lifecycle, not backend equality.
|
|
190
|
+
if (provider === "entwurf") {
|
|
191
|
+
throw new Error(
|
|
192
|
+
"spawn-resume-live: provider=entwurf is out of scope for 0.11.0 (A) — use a native target (e.g. openai-codex/gpt-5.4).",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
console.log(`[smoke-entwurf-v2-spawn-resume-live] target = ${provider}/${model}`);
|
|
196
|
+
|
|
197
|
+
// ── temp world (META store / mailbox / locks are temp; pi's SESSION JSONL is REAL) ──
|
|
198
|
+
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "v2resume-"));
|
|
199
|
+
const sessionsDir = path.join(tmp, "meta-sessions");
|
|
200
|
+
const mailboxDir = path.join(tmp, "mailbox");
|
|
201
|
+
const lockDir = path.join(tmp, "locks");
|
|
202
|
+
for (const d of [sessionsDir, mailboxDir, lockDir]) await fsp.mkdir(d, { recursive: true });
|
|
203
|
+
process.env.ENTWURF_META_SESSIONS_DIR = sessionsDir;
|
|
204
|
+
process.env.ENTWURF_META_MAILBOX_DIR = mailboxDir;
|
|
205
|
+
|
|
206
|
+
let gid = "";
|
|
207
|
+
let seedFile: string | null = null;
|
|
208
|
+
let childPid: number | null = null;
|
|
209
|
+
let seedStderrTail = "";
|
|
210
|
+
let succeeded = false;
|
|
211
|
+
|
|
212
|
+
// ── release-seam wrapper (F2): the production factory builds ONE shared `release` closure
|
|
213
|
+
// from `seams.releaseLock` and injects it into the decider AND the spawn watcher's releaseFn,
|
|
214
|
+
// so this single wrapper counts the spawn-bg release too. Production code is unchanged. ──
|
|
215
|
+
let releaseCount = 0;
|
|
216
|
+
const releasedGids: string[] = [];
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
// ── 1. mint a backend=pi meta identity; its gardenId is the seed/resume id ──
|
|
220
|
+
const minted = upsertMetaSession({
|
|
221
|
+
input: { backend: "pi", nativeSessionId: `smoke-resume-${process.pid}`, cwd: tmp, model },
|
|
222
|
+
dir: sessionsDir,
|
|
223
|
+
});
|
|
224
|
+
gid = minted.record.gardenId;
|
|
225
|
+
artifacts["gid"] = gid;
|
|
226
|
+
const sockPath = path.join(REAL_CONTROL_DIR, `${gid}${SOCKET_SUFFIX}`);
|
|
227
|
+
artifacts["socket"] = sockPath;
|
|
228
|
+
artifacts["lock"] = lockPathFor(gid, lockDir);
|
|
229
|
+
|
|
230
|
+
// fresh gid must collide with NOTHING (GPT pin 1 + F1): no live socket, no saved session.
|
|
231
|
+
ok("fresh gid has no pre-existing control socket", !existsSync(controlSocketPath(gid, REAL_CONTROL_DIR)));
|
|
232
|
+
ok("fresh gid has no saved session yet (dormant-to-be)", findSessionFileById(gid) === null);
|
|
233
|
+
|
|
234
|
+
// ── 2. seed a REAL dormant pi session (one-shot, no control socket survives) ──
|
|
235
|
+
const nonce = `${process.pid.toString(36)}${Date.now().toString(36)}`;
|
|
236
|
+
artifacts["nonce"] = nonce;
|
|
237
|
+
const seedName = buildSessionName({
|
|
238
|
+
sessionId: gid,
|
|
239
|
+
provider,
|
|
240
|
+
model,
|
|
241
|
+
rawTitle: "spawn resume live seed",
|
|
242
|
+
tags: ["entwurf"], // F1: resolveResumeLaunchIdentity reads requireEntwurf:true.
|
|
243
|
+
});
|
|
244
|
+
const seedArgs = [
|
|
245
|
+
"--mode",
|
|
246
|
+
"json",
|
|
247
|
+
"-p",
|
|
248
|
+
"--no-extensions",
|
|
249
|
+
"--approve",
|
|
250
|
+
"--session-id",
|
|
251
|
+
gid,
|
|
252
|
+
"--name",
|
|
253
|
+
seedName,
|
|
254
|
+
"--provider",
|
|
255
|
+
provider,
|
|
256
|
+
"--model",
|
|
257
|
+
model,
|
|
258
|
+
`Reply exactly SEED_READY_${nonce} and nothing else.`,
|
|
259
|
+
];
|
|
260
|
+
const seed = spawnSync("pi", seedArgs, { cwd: tmp, encoding: "utf8", timeout: SEED_TIMEOUT_MS, env: process.env });
|
|
261
|
+
seedStderrTail = `${seed.stdout ?? ""}\n${seed.stderr ?? ""}`.split("\n").slice(-12).join("\n");
|
|
262
|
+
ok("seed pi one-shot exited 0", seed.status === 0);
|
|
263
|
+
|
|
264
|
+
// ── 3. the seed must have written a resumable identity (F3 + GPT pin 5) ──
|
|
265
|
+
// Poll briefly: the JSONL flush/rename can lag the process exit.
|
|
266
|
+
let identity: ReturnType<typeof readSessionIdentity> = null;
|
|
267
|
+
{
|
|
268
|
+
const deadline = Date.now() + SEED_IDENTITY_POLL_MS;
|
|
269
|
+
while (Date.now() < deadline) {
|
|
270
|
+
const file = findSessionFileById(gid);
|
|
271
|
+
if (file) {
|
|
272
|
+
const id = readSessionIdentity(file, { requireEntwurf: true });
|
|
273
|
+
if (id?.modelId && id.provider && id.cwd) {
|
|
274
|
+
seedFile = file;
|
|
275
|
+
identity = id;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await sleep(POLL_MS);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
artifacts["seedFile"] = seedFile ?? "(none)";
|
|
283
|
+
ok("seed session file is resolvable by gid", seedFile !== null);
|
|
284
|
+
ok("seed recorded a resumable entwurf identity (requireEntwurf)", identity !== null);
|
|
285
|
+
ok("seed header cwd === temp cwd (cold-resume authority, #9)", identity?.cwd === tmp);
|
|
286
|
+
ok("seed recorded provider matches the target", identity?.provider === provider);
|
|
287
|
+
ok("seed recorded modelId matches the target", identity?.modelId === model);
|
|
288
|
+
ok("seed header id === gid", identity?.sessionId === gid);
|
|
289
|
+
|
|
290
|
+
// resume-time append boundary: count seed lines so the nonce poll scans ONLY the resume.
|
|
291
|
+
const seedLineCount = readFileSync(seedFile as string, "utf8")
|
|
292
|
+
.split("\n")
|
|
293
|
+
.filter((l) => l.trim()).length;
|
|
294
|
+
|
|
295
|
+
// dormant precondition: the one-shot left NO live control socket behind.
|
|
296
|
+
ok("seed left no live control socket (citizen is dormant)", !existsSync(sockPath));
|
|
297
|
+
|
|
298
|
+
// ── 4. drive the production owned-outcome resume → real spawn-bg child ──
|
|
299
|
+
const prodDeps = makeProductionEntwurfV2Deps({
|
|
300
|
+
senderProvider: () => smokeSender(gid, tmp),
|
|
301
|
+
sessionsDir,
|
|
302
|
+
mailboxDir,
|
|
303
|
+
lockDir,
|
|
304
|
+
controlSocketDir: REAL_CONTROL_DIR,
|
|
305
|
+
prefixRoots: [tmp], // F4: preflight allow for a resume into the temp cwd.
|
|
306
|
+
observeTimeoutMs: OBSERVE_TIMEOUT_MS,
|
|
307
|
+
seams: {
|
|
308
|
+
releaseLock: (claim: LockClaim, deps: { dir?: string }) => {
|
|
309
|
+
releaseCount++;
|
|
310
|
+
releasedGids.push(claim.gardenId);
|
|
311
|
+
return realReleaseLock(claim, deps);
|
|
312
|
+
},
|
|
313
|
+
spawnOverrides: {
|
|
314
|
+
resolveIdentity: (plan) => {
|
|
315
|
+
const launch = resolveResumeLaunchIdentity(plan);
|
|
316
|
+
return {
|
|
317
|
+
...launch,
|
|
318
|
+
explicitExtensionArgs: [...REPO_EXTENSION_ARGS, ...launch.explicitExtensionArgs],
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const userNeedle = `V2_SPAWN_RESUME_USER_${nonce}`;
|
|
326
|
+
const okNeedle = `V2_SPAWN_RESUME_OK_${nonce}`;
|
|
327
|
+
// `pollForNonce` scans the stringified entry regardless of role, so the USER prompt must NOT
|
|
328
|
+
// contain the contiguous okNeedle — otherwise the assistant poll passes the instant the user
|
|
329
|
+
// turn is appended (a false "model replied", GPT blocker 1). Ask the model to CONCATENATE the
|
|
330
|
+
// two parts with no separator so only a real assistant turn yields the contiguous okNeedle.
|
|
331
|
+
const resumeMessage =
|
|
332
|
+
`${userNeedle}\n` +
|
|
333
|
+
`Reply with exactly these two parts concatenated, no separator and nothing else: ` +
|
|
334
|
+
`the literal "V2_SPAWN_RESUME_OK_" then "${nonce}".`;
|
|
335
|
+
ok("resume prompt does not contain the contiguous assistant OK nonce", !resumeMessage.includes(okNeedle));
|
|
336
|
+
const result: EntwurfV2RunResult = await runEntwurfV2(
|
|
337
|
+
{ target: gid, intent: "owned-outcome", message: resumeMessage },
|
|
338
|
+
prodDeps,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// ── result PASS = executed / spawn-bg / socket-alive / released (F5 + GPT pin 4) ──
|
|
342
|
+
ok(
|
|
343
|
+
"owned-outcome on a dormant pi citizen executed a spawn-bg resume",
|
|
344
|
+
result.kind === "executed" && result.transport === "spawn-bg",
|
|
345
|
+
);
|
|
346
|
+
if (result.kind !== "executed" || result.outcome.transport !== "spawn-bg") {
|
|
347
|
+
throw new Error("spawn-resume-live: result was not an executed spawn-bg outcome (see asserts above).");
|
|
348
|
+
}
|
|
349
|
+
const sb = result.outcome.result;
|
|
350
|
+
// GPT blocker 2: capture any available pid BEFORE the socket-alive narrowing throws — a
|
|
351
|
+
// `lock-retained` outcome carries `diagnostic.pid` of a child the watcher killed but could
|
|
352
|
+
// not confirm dead, so a fail-path throw must still hand the finally a pid to reap.
|
|
353
|
+
const maybePid =
|
|
354
|
+
"pid" in sb && typeof sb.pid === "number"
|
|
355
|
+
? sb.pid
|
|
356
|
+
: sb.kind === "lock-retained" && typeof sb.diagnostic.pid === "number"
|
|
357
|
+
? sb.diagnostic.pid
|
|
358
|
+
: null;
|
|
359
|
+
if (maybePid !== null && maybePid > 0) {
|
|
360
|
+
childPid = maybePid;
|
|
361
|
+
artifacts["childPid"] = String(maybePid);
|
|
362
|
+
}
|
|
363
|
+
ok(
|
|
364
|
+
"spawn-bg observed socket-alive (resident lifecycle, not child-exited/lock-retained)",
|
|
365
|
+
sb.kind === "socket-alive",
|
|
366
|
+
);
|
|
367
|
+
if (sb.kind !== "socket-alive") {
|
|
368
|
+
throw new Error(`spawn-resume-live: spawn-bg result was ${sb.kind}, not socket-alive.`);
|
|
369
|
+
}
|
|
370
|
+
ok("spawn-bg released the lock on socket-alive", sb.released === true);
|
|
371
|
+
const pid = sb.pid;
|
|
372
|
+
ok("resident pid is a real live pid", typeof pid === "number" && pid > 0);
|
|
373
|
+
|
|
374
|
+
// release accounting (F2): exactly one release, for THIS gid, and no lock file remains.
|
|
375
|
+
ok("lock released exactly once", releaseCount === 1);
|
|
376
|
+
ok("the single release was for the target gid", releasedGids.length === 1 && releasedGids[0] === gid);
|
|
377
|
+
ok("no lock file remains for the target", !existsSync(lockPathFor(gid, lockDir)));
|
|
378
|
+
|
|
379
|
+
// resident "still standing" re-check (GPT pin 4): pid alive AND socket connectable-present.
|
|
380
|
+
ok("resident pid is still alive after the watcher returned", childPid !== null && pidAlive(childPid));
|
|
381
|
+
ok("resident control socket is present at the canonical path", existsSync(sockPath));
|
|
382
|
+
|
|
383
|
+
// ── 5. the child actually resumed AND did a model turn (the acceptance core) ──
|
|
384
|
+
const userSeen = await pollForNonce(seedFile as string, seedLineCount, userNeedle, USER_TURN_TIMEOUT_MS);
|
|
385
|
+
ok("resume USER nonce appended to the session (the turn was injected)", userSeen);
|
|
386
|
+
const okSeen = await pollForNonce(seedFile as string, seedLineCount, okNeedle, ASSISTANT_TURN_TIMEOUT_MS);
|
|
387
|
+
ok("resume ASSISTANT OK nonce appended (the model actually replied — real work)", okSeen);
|
|
388
|
+
|
|
389
|
+
succeeded = true;
|
|
390
|
+
console.log(
|
|
391
|
+
`\nsmoke-entwurf-v2-spawn-resume-live: ${passed} checks passed (real pi resume + model turn + lock release)`,
|
|
392
|
+
);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error("\n[smoke-entwurf-v2-spawn-resume-live] FAILED — diagnostic artifacts:");
|
|
395
|
+
for (const [k, v] of Object.entries(artifacts)) console.error(` ${k} = ${v}`);
|
|
396
|
+
console.error(` releaseCount = ${releaseCount} releasedGids = ${JSON.stringify(releasedGids)}`);
|
|
397
|
+
if (seedStderrTail.trim())
|
|
398
|
+
console.error(` seed pi stdout/stderr (tail):\n${seedStderrTail.replace(/^/gm, " ")}`);
|
|
399
|
+
throw err;
|
|
400
|
+
} finally {
|
|
401
|
+
// reap the detached resident + its real socket (always — even on a clean pass).
|
|
402
|
+
if (childPid !== null && gid) {
|
|
403
|
+
await terminateResident(childPid, path.join(REAL_CONTROL_DIR, `${gid}${SOCKET_SUFFIX}`)).catch(() => {});
|
|
404
|
+
}
|
|
405
|
+
if (gid) {
|
|
406
|
+
await fsp.rm(path.join(REAL_CONTROL_DIR, `${gid}${SOCKET_SUFFIX}`), { force: true }).catch(() => {});
|
|
407
|
+
}
|
|
408
|
+
if (succeeded) {
|
|
409
|
+
// delete ONLY the one seed session file we created (never the real sessions dir).
|
|
410
|
+
if (seedFile) {
|
|
411
|
+
await fsp.rm(seedFile, { force: true }).catch(() => {});
|
|
412
|
+
// pi nests the seed under a cwd-hash subdir keyed by our unique temp cwd, so removing
|
|
413
|
+
// it IF-empty leaves no litter and can never touch another session (rmdir refuses a
|
|
414
|
+
// non-empty dir — belt for a surprise sibling file).
|
|
415
|
+
await fsp.rmdir(path.dirname(seedFile)).catch(() => {});
|
|
416
|
+
}
|
|
417
|
+
await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
418
|
+
} else {
|
|
419
|
+
console.error(`[smoke-entwurf-v2-spawn-resume-live] temp world preserved for inspection: ${tmp}`);
|
|
420
|
+
if (seedFile) console.error(`[smoke-entwurf-v2-spawn-resume-live] seed session PRESERVED: ${seedFile}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
main().catch((err) => {
|
|
426
|
+
console.error(err);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# smoke-meta-async-drift — 1.0.0 meta-bridge step 1: drift sentinel + capability gate
|
|
4
|
+
# (#30 bbot review refinement #2; NEXT.md MVP step 1, "record authority FIRST, hook
|
|
5
|
+
# LAST" — this is the protector that freezes the green BEFORE any record/hook lands).
|
|
6
|
+
#
|
|
7
|
+
# WHY THIS EXISTS
|
|
8
|
+
# The whole Claude async-delivery path rides on UNDOCUMENTED Claude Code behavior:
|
|
9
|
+
# `asyncRewake` force-prepends `Stop hook feedback:` and ignores `rewakeMessage`;
|
|
10
|
+
# the payload channel is stderr-only; `watchPaths` can arm from only 3 hook events
|
|
11
|
+
# (SessionStart / CwdChanged / FileChanged). Half the Gotchas in
|
|
12
|
+
# scripts/raw-async-delivery/README.md are reverse-engineered. Claude ships ~weekly.
|
|
13
|
+
# So the path can break SILENTLY on any upgrade. This gate makes it SCREAM instead —
|
|
14
|
+
# direct lineage of the 0.8.x fail-fast tool-surface gates ("don't let a surface
|
|
15
|
+
# break quietly, make the gate cry out").
|
|
16
|
+
#
|
|
17
|
+
# TWO TIERS (mirrors smoke-compaction-policy: deterministic default + LIVE=1 add-on)
|
|
18
|
+
# DETERMINISTIC (default; free, offline, CI/pre-commit safe):
|
|
19
|
+
# A. VERSION LINE PINS — the three backends stay on the pinned MAJOR.MINOR
|
|
20
|
+
# lines. Patch drift is tolerated; minor/major drift screams.
|
|
21
|
+
# B. BINARY MARKERS — the undocumented-behavior identifiers must still be PRESENT in
|
|
22
|
+
# the installed Claude binary (binary cross-validation). A marker dropping to
|
|
23
|
+
# zero = the behavior was renamed/removed = the delivery path is dead = SCREAM.
|
|
24
|
+
# LIVE (LIVE=1; spawns one `claude -p`, metered from 2026-06-15):
|
|
25
|
+
# C. PLUGIN WATCH-ARM PROBE — delegate to repro-plugin-idle-wake.sh `probe`: the
|
|
26
|
+
# plugin SessionStart hook must fire at STARTUP and arm a per-session watchPath
|
|
27
|
+
# (a bare skill cannot — structural, see README). This is the end-to-end
|
|
28
|
+
# confirmation that arming still works on this build.
|
|
29
|
+
#
|
|
30
|
+
# PIN POLICY (2026-06-05): pins are MAJOR.MINOR lines, not exact patches. Claude
|
|
31
|
+
# ships ~weekly (observed 2.1.163 -> 2.1.165 same day, all 9 markers unchanged),
|
|
32
|
+
# so an exact-patch pin screams on every bump and the signal is lost. The
|
|
33
|
+
# binary-marker check (B) is the real net and runs against whatever patch is
|
|
34
|
+
# installed; the version check (A) only fires on a MINOR/MAJOR move, which is the
|
|
35
|
+
# genuine "re-verify markers + Gotchas + raw/LIVE probes" trigger. (bbot's
|
|
36
|
+
# "agy 0.136" was a conflation with codex-cli 0.136.0; agy's line is 1.0.)
|
|
37
|
+
#
|
|
38
|
+
# USAGE: ./run.sh smoke-meta-async-drift (LIVE=1 to add the plugin watch-arm probe)
|
|
39
|
+
set -euo pipefail
|
|
40
|
+
|
|
41
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
42
|
+
RAW_DIR="$HERE/raw-async-delivery"
|
|
43
|
+
|
|
44
|
+
# --- pinned MINOR lines (major.minor; PATCH is intentionally NOT pinned) --------
|
|
45
|
+
# Claude/codex/agy ship fast. A PATCH bump (e.g. 2.1.163 -> 2.1.165) almost never
|
|
46
|
+
# touches the undocumented delivery behavior — the binary-marker cross-validation
|
|
47
|
+
# in section B is the real safety net and runs against whatever patch is actually
|
|
48
|
+
# installed. Pinning the patch made the sentinel scream on every weekly bump =
|
|
49
|
+
# signal lost (observed 2026-06-05: 2.1.163 -> 2.1.165, all 9 markers unchanged).
|
|
50
|
+
# So pin the MINOR line; a MINOR/MAJOR move (2.1.x -> 2.2 / 3.x, 0.136 -> 0.137,
|
|
51
|
+
# 1.0 -> 1.1) is the real "re-verify the markers + Gotchas + raw/LIVE probes"
|
|
52
|
+
# trigger and DOES scream.
|
|
53
|
+
PIN_CLAUDE_MINOR="2.1"
|
|
54
|
+
PIN_CODEX_MINOR="0.136"
|
|
55
|
+
PIN_AGY_MINOR="1.0"
|
|
56
|
+
|
|
57
|
+
# --- undocumented-behavior identifiers that the delivery path rides on ---------
|
|
58
|
+
# Each MUST stay present in the installed Claude binary. Zero = drift = SCREAM.
|
|
59
|
+
MARKERS=(
|
|
60
|
+
asyncRewake # active-turn doorbell (Stop hook)
|
|
61
|
+
stop_hook_active # infinite-loop guard field
|
|
62
|
+
watchPaths # idle-wake arm channel
|
|
63
|
+
flushPendingAsyncRewakeHooks # engine turn-boundary flush (edge-bound delivery)
|
|
64
|
+
CLAUDE_CODE_STOP_HOOK_BLOCK_CAP # native re-wake cap
|
|
65
|
+
FileChanged # idle-wake event
|
|
66
|
+
rewakeMessage # the field asyncRewake IGNORES (doorbell-only proof)
|
|
67
|
+
hookSpecificOutput # the watchPaths emit envelope
|
|
68
|
+
CwdChanged # the 2nd of the 3 watch-arming events
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
pass=0 ; fail=0 ; scream=0
|
|
72
|
+
ok() { echo " PASS: $*"; pass=$((pass+1)); }
|
|
73
|
+
bad() { echo " FAIL: $*"; fail=$((fail+1)); }
|
|
74
|
+
cry() { echo " DRIFT!: $*"; scream=$((scream+1)); }
|
|
75
|
+
section(){ echo; echo "== $* =="; }
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------- A
|
|
78
|
+
section "A. VERSION LINE PINS (minor/major drift = undocumented-behavior assumptions may be stale)"
|
|
79
|
+
|
|
80
|
+
# major.minor of an X.Y.Z token ("2.1.165" -> "2.1", "0.136.0" -> "0.136").
|
|
81
|
+
minor_of() { printf '%s' "$1" | cut -d. -f1-2; }
|
|
82
|
+
extract_semver() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true; }
|
|
83
|
+
|
|
84
|
+
check_version() {
|
|
85
|
+
local name="$1" pin_minor="$2" raw="$3"
|
|
86
|
+
if [ -z "$raw" ]; then
|
|
87
|
+
echo " SKIP: $name not on PATH (cannot verify pinned minor ${pin_minor}.x)"; return
|
|
88
|
+
fi
|
|
89
|
+
# extract first dotted version token (handles "2.1.165 (Claude Code)", "codex-cli 0.136.0", "1.0.5")
|
|
90
|
+
local got got_minor
|
|
91
|
+
got="$(extract_semver "$raw")"
|
|
92
|
+
if [ -z "$got" ]; then
|
|
93
|
+
cry "$name version output did not contain an X.Y.Z token (raw: $raw) — cannot verify pinned minor ${pin_minor}.x"
|
|
94
|
+
return
|
|
95
|
+
fi
|
|
96
|
+
got_minor="$(minor_of "$got")"
|
|
97
|
+
if [ "$got_minor" = "$pin_minor" ]; then
|
|
98
|
+
ok "$name $got within pinned minor ${pin_minor}.x (patch drift ignored)"
|
|
99
|
+
else
|
|
100
|
+
cry "$name $got left pinned minor ${pin_minor}.x — re-verify binary markers + Gotchas + raw/LIVE probes on the new minor, then bump the pin"
|
|
101
|
+
fi
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
CLAUDE_RAW="$(claude --version 2>/dev/null || true)"
|
|
105
|
+
check_version claude "$PIN_CLAUDE_MINOR" "$CLAUDE_RAW"
|
|
106
|
+
check_version codex "$PIN_CODEX_MINOR" "$(codex --version 2>/dev/null || true)"
|
|
107
|
+
check_version agy "$PIN_AGY_MINOR" "$(agy --version 2>/dev/null || true)"
|
|
108
|
+
# Full patch version drives binary resolution in section B (the marker check must
|
|
109
|
+
# run against the ACTUAL installed patch, not a hardcoded one).
|
|
110
|
+
CLAUDE_FULL="$(extract_semver "$CLAUDE_RAW")"
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------- B
|
|
113
|
+
section "B. BINARY MARKERS (undocumented Claude delivery behavior still present?)"
|
|
114
|
+
|
|
115
|
+
# Resolve the installed Claude binary. `claude` on PATH is a launcher shim; the
|
|
116
|
+
# real bundle lives under ~/.local/share/claude/versions/<ver>. Use the ACTUAL
|
|
117
|
+
# installed patch (from `claude --version`); fall back to the newest on disk.
|
|
118
|
+
CLAUDE_BIN=""
|
|
119
|
+
CLAUDE_VER_DIR="$HOME/.local/share/claude/versions"
|
|
120
|
+
if [ -n "$CLAUDE_FULL" ] && [ -f "$CLAUDE_VER_DIR/$CLAUDE_FULL" ]; then
|
|
121
|
+
CLAUDE_BIN="$CLAUDE_VER_DIR/$CLAUDE_FULL"
|
|
122
|
+
elif [ -d "$CLAUDE_VER_DIR" ]; then
|
|
123
|
+
CLAUDE_BIN="$(ls -1t "$CLAUDE_VER_DIR" 2>/dev/null | head -1 | sed "s#^#$CLAUDE_VER_DIR/#")"
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
if [ -z "$CLAUDE_BIN" ] || [ ! -f "$CLAUDE_BIN" ]; then
|
|
127
|
+
echo " SKIP: Claude binary not found under $CLAUDE_VER_DIR — cannot cross-validate markers"
|
|
128
|
+
else
|
|
129
|
+
echo " binary: $CLAUDE_BIN"
|
|
130
|
+
for m in "${MARKERS[@]}"; do
|
|
131
|
+
n="$(grep -aoc "$m" "$CLAUDE_BIN" 2>/dev/null || true)"
|
|
132
|
+
n="${n:-0}"
|
|
133
|
+
if [ "$n" -gt 0 ]; then
|
|
134
|
+
ok "marker present: $m ($n)"
|
|
135
|
+
else
|
|
136
|
+
cry "marker GONE: $m — delivery path likely broken on this build"
|
|
137
|
+
fi
|
|
138
|
+
done
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------- C
|
|
142
|
+
section "C. PLUGIN WATCH-ARM PROBE (LIVE only)"
|
|
143
|
+
|
|
144
|
+
if [ "${LIVE:-0}" = "1" ]; then
|
|
145
|
+
echo " LIVE=1 → delegating to repro-plugin-idle-wake.sh probe (spawns one claude -p)"
|
|
146
|
+
if bash "$RAW_DIR/repro-plugin-idle-wake.sh" probe; then
|
|
147
|
+
ok "plugin SessionStart hook armed a per-session watchPath at startup"
|
|
148
|
+
else
|
|
149
|
+
bad "plugin watch-arm probe failed (SessionStart did not arm the watch)"
|
|
150
|
+
fi
|
|
151
|
+
else
|
|
152
|
+
echo " SKIP: set LIVE=1 to run the plugin watch-arm probe (spawns one metered claude -p)"
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------- summary
|
|
156
|
+
section "SUMMARY"
|
|
157
|
+
echo " pass=$pass fail=$fail drift=$scream"
|
|
158
|
+
if [ "$scream" -gt 0 ]; then
|
|
159
|
+
echo
|
|
160
|
+
echo " ***** DRIFT DETECTED *****"
|
|
161
|
+
echo " An undocumented behavior or a pinned version moved. The async-delivery path"
|
|
162
|
+
echo " is built on these; do NOT assume it still works. Re-run the raw probes in"
|
|
163
|
+
echo " scripts/raw-async-delivery/, re-read README §Gotchas, update DELIVERY.md, and"
|
|
164
|
+
echo " only then bump the pins in this gate."
|
|
165
|
+
exit 1
|
|
166
|
+
fi
|
|
167
|
+
if [ "$fail" -gt 0 ]; then
|
|
168
|
+
echo " FAIL: $fail check(s) failed."
|
|
169
|
+
exit 1
|
|
170
|
+
fi
|
|
171
|
+
echo " OK: meta-async drift sentinel green at pinned minor lines."
|