@dzhechkov/harness-core 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.dz-manifest.json +339 -75
  2. package/README.md +47 -4
  3. package/dist/codex-hooks-assets.d.ts.map +1 -1
  4. package/dist/codex-hooks-assets.js +39 -2
  5. package/dist/codex-hooks-assets.js.map +1 -1
  6. package/dist/codex-hooks-verify.d.ts +23 -2
  7. package/dist/codex-hooks-verify.d.ts.map +1 -1
  8. package/dist/codex-hooks-verify.js +29 -0
  9. package/dist/codex-hooks-verify.js.map +1 -1
  10. package/dist/codex-hooks.d.ts +90 -7
  11. package/dist/codex-hooks.d.ts.map +1 -1
  12. package/dist/codex-hooks.js +171 -21
  13. package/dist/codex-hooks.js.map +1 -1
  14. package/dist/feature-adr-routing.d.ts +22 -0
  15. package/dist/feature-adr-routing.d.ts.map +1 -1
  16. package/dist/feature-adr-routing.js +45 -0
  17. package/dist/feature-adr-routing.js.map +1 -1
  18. package/dist/index.d.ts +11 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/loop-blobs.generated.d.ts +1 -1
  23. package/dist/loop-blobs.generated.d.ts.map +1 -1
  24. package/dist/loop-blobs.generated.js +12 -3
  25. package/dist/loop-blobs.generated.js.map +1 -1
  26. package/dist/loop-plan.d.ts +70 -0
  27. package/dist/loop-plan.d.ts.map +1 -1
  28. package/dist/loop-plan.js +103 -0
  29. package/dist/loop-plan.js.map +1 -1
  30. package/dist/loop-render.d.ts.map +1 -1
  31. package/dist/loop-render.js +38 -130
  32. package/dist/loop-render.js.map +1 -1
  33. package/dist/loop-run-semantics.d.ts +130 -0
  34. package/dist/loop-run-semantics.d.ts.map +1 -0
  35. package/dist/loop-run-semantics.js +257 -0
  36. package/dist/loop-run-semantics.js.map +1 -0
  37. package/dist/loop-trace.d.ts +106 -5
  38. package/dist/loop-trace.d.ts.map +1 -1
  39. package/dist/loop-trace.js +151 -18
  40. package/dist/loop-trace.js.map +1 -1
  41. package/dist/managed-hooks.d.ts +10 -0
  42. package/dist/managed-hooks.d.ts.map +1 -1
  43. package/dist/managed-hooks.js +17 -5
  44. package/dist/managed-hooks.js.map +1 -1
  45. package/dist/named-lock.d.ts +57 -0
  46. package/dist/named-lock.d.ts.map +1 -0
  47. package/dist/named-lock.js +247 -0
  48. package/dist/named-lock.js.map +1 -0
  49. package/dist/operations.d.ts +81 -5
  50. package/dist/operations.d.ts.map +1 -1
  51. package/dist/operations.js +356 -38
  52. package/dist/operations.js.map +1 -1
  53. package/dist/parity.d.ts +70 -2
  54. package/dist/parity.d.ts.map +1 -1
  55. package/dist/parity.js +133 -2
  56. package/dist/parity.js.map +1 -1
  57. package/dist/qe-bridge.d.ts +291 -0
  58. package/dist/qe-bridge.d.ts.map +1 -0
  59. package/dist/qe-bridge.js +538 -0
  60. package/dist/qe-bridge.js.map +1 -0
  61. package/dist/score.d.ts.map +1 -1
  62. package/dist/score.js +43 -9
  63. package/dist/score.js.map +1 -1
  64. package/dist/trace-corroborate.d.ts +48 -0
  65. package/dist/trace-corroborate.d.ts.map +1 -0
  66. package/dist/trace-corroborate.js +172 -0
  67. package/dist/trace-corroborate.js.map +1 -0
  68. package/dist/workflow-run-dispatch.d.ts +230 -0
  69. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  70. package/dist/workflow-run-dispatch.js +363 -0
  71. package/dist/workflow-run-dispatch.js.map +1 -0
  72. package/dist/workflow-run.d.ts +513 -0
  73. package/dist/workflow-run.d.ts.map +1 -0
  74. package/dist/workflow-run.js +1377 -0
  75. package/dist/workflow-run.js.map +1 -0
  76. package/package.json +2 -2
  77. package/sbom.json +740 -80
  78. package/src/codex-hooks-assets.ts +39 -2
  79. package/src/codex-hooks-verify.ts +55 -2
  80. package/src/codex-hooks.ts +172 -20
  81. package/src/feature-adr-routing.ts +55 -0
  82. package/src/index.ts +46 -1
  83. package/src/loop-blobs.generated.ts +12 -3
  84. package/src/loop-plan.ts +185 -0
  85. package/src/loop-render.ts +38 -128
  86. package/src/loop-run-semantics.ts +278 -0
  87. package/src/loop-trace.ts +207 -16
  88. package/src/managed-hooks.ts +26 -5
  89. package/src/named-lock.ts +277 -0
  90. package/src/operations.ts +441 -40
  91. package/src/parity.ts +177 -2
  92. package/src/qe-bridge.ts +737 -0
  93. package/src/score.ts +50 -9
  94. package/src/trace-corroborate.ts +205 -0
  95. package/src/workflow-run-dispatch.ts +459 -0
  96. package/src/workflow-run.ts +1773 -0
@@ -0,0 +1,459 @@
1
+ /**
2
+ * `workflow-run-dispatch` — the DISPATCHER SEAM of `dz workflow run` (ADR-002 O1).
3
+ *
4
+ * Why a seam at all: the two runtimes disagree about almost everything that matters (stdin open vs
5
+ * closed, envelope shape, where a deliverable appears, what a clean exit means), and the scheduler
6
+ * must not know any of it. What it knows is: ask for a dispatch, get back a typed outcome whose
7
+ * failure reason is a member of ONE list.
8
+ *
9
+ * Everything below the contract is CONVENTION — and every convention here is a MEASURED lesson, not
10
+ * a preference. Each one is named at its definition with the observation that produced it, because
11
+ * a convention whose reason is lost is the next thing somebody "cleans up".
12
+ */
13
+
14
+ import type { Deliverable } from './loop-plan.js';
15
+ import { claudeProbeArgs, claudeReviewArgs, extractClaudeResult, interpretClaudeProbe, type BridgeFamily } from './qe-bridge.js';
16
+ import type { WfRunReason } from './workflow-run.js';
17
+
18
+ /** The dispatch REQUEST — every field resolved by the scheduler, so an adapter never re-decides. */
19
+ export interface DispatchRequest {
20
+ stepId: string;
21
+ /** The fanout member key, or null for a top-level step. */
22
+ itemKey: string | null;
23
+ /** 1-based; INCLUDES the initial attempt. */
24
+ attempt: number;
25
+ /** Fully assembled (USER prompt + shared contract lines + item binding), ingress-defanged. */
26
+ prompt: string;
27
+ /** Resolved at preflight (AM-8) — TOTAL, never inferred inside the adapter. */
28
+ family: BridgeFamily;
29
+ /** The PROBED id, never the requested spec: an allowlist says a name is spellable, only a probe
30
+ * says it answers. */
31
+ resolvedModelId: string;
32
+ deliverable: Deliverable;
33
+ /** Declared reads — the input paths a file-mode step is asked to OPEN. They travel so the adapter
34
+ * (and the scheduler's dispatch-time containment re-check) can hold them to the SAME realpath /
35
+ * symlinked-ancestor discipline as writes: a read that escapes the root is not safer than a write
36
+ * that does (Step-8 re-QE R3-A). */
37
+ expectedReads: string[];
38
+ /** Declared writes. The scheduler owns the landed barrier (it snapshots the baseline); the
39
+ * adapter only needs to know the step is in file mode. */
40
+ expectedWrites: string[];
41
+ timeoutMs: number;
42
+ /** Target tree for file mode; ignored by a return-value claude dispatch (isolated temp cwd). */
43
+ cwd: string;
44
+ }
45
+
46
+ export interface DispatchFailure {
47
+ reason: WfRunReason;
48
+ detail: string;
49
+ }
50
+
51
+ export interface DispatchResult {
52
+ /** Pinned to the trace settle vocabulary (`loop-trace.ts` settle outcomes) — one word, two planes. */
53
+ outcome: 'ok' | 'null' | 'error';
54
+ /** Envelope-extracted text; null on null/error. */
55
+ text: string | null;
56
+ family: BridgeFamily;
57
+ modelUsed: string | null;
58
+ wallMs: number;
59
+ /** null when the runtime did not report a count — NEVER 0, never estimated (ADR-004 C4). */
60
+ tokensIn: number | null;
61
+ tokensOut: number | null;
62
+ tokensSource: 'claude-envelope' | 'codex-stderr' | null;
63
+ failure?: DispatchFailure;
64
+ }
65
+
66
+ export interface ProbeOutcome {
67
+ /** The id that ANSWERED, or null when no candidate did. */
68
+ id: string | null;
69
+ wallMs: number;
70
+ detail: string;
71
+ }
72
+
73
+ export interface Dispatcher {
74
+ /** Once per run per family; the result is cached by the SCHEDULER and persisted into run-state
75
+ * (AM-8), so a resume compares against the id that actually ran. */
76
+ probe(candidates: string[]): Promise<ProbeOutcome>;
77
+ dispatch(req: DispatchRequest): Promise<DispatchResult>;
78
+ }
79
+
80
+ /**
81
+ * The 4 reasons an ADAPTER may produce — the producer PARTITION of the one 22-member
82
+ * `WF_RUN_REASONS` list (K3). There is no second taxonomy: the type stays `WfRunReason`, and this
83
+ * array is the data a test uses to assert who is allowed to produce what. The other 18 members have
84
+ * preflight, scheduler or state-plane producers.
85
+ */
86
+ export const DISPATCH_REASONS = ['dispatch-timeout', 'dispatch-dead', 'prompt-over-ceiling', 'probe-failed'] as const;
87
+ export type DispatchReason = (typeof DISPATCH_REASONS)[number];
88
+
89
+ /** Measured 2026-08-12: a codex `exec` at xhigh effort needs this much wall clock at the far end of
90
+ * its distribution. Used as the scheduler's DEFAULT per-stage timeout when the operator gives none. */
91
+ export const CODEX_EXEC_XHIGH_TIMEOUT_MS = 560_000;
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // The impure child seam BOTH adapters ride (implemented in harness-cli by generalizing
95
+ // `runClaudeBridge`: deadline timer + SIGTERM, settled flag, env scrub, injectable spawnImpl).
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ export interface ChildRun {
99
+ stdout: string;
100
+ stderr: string;
101
+ exitCode: number | null;
102
+ timedOut: boolean;
103
+ spawnError: string | null;
104
+ }
105
+
106
+ export type ChildRunner = (
107
+ bin: string,
108
+ argv: string[],
109
+ opts: { stdinText: string | null; timeoutMs: number; cwd: string; detached: boolean },
110
+ ) => Promise<ChildRun>;
111
+
112
+ /**
113
+ * W1 — the timeout/dead discriminator, decided on the WRAPPER'S OWN FIELDS and nothing else.
114
+ *
115
+ * The distinction is load-bearing because the two have different retry semantics and different
116
+ * operator meanings: a timeout says "it was still working", a dead dispatch says "nothing came
117
+ * back". Deciding it from stderr text would make the verdict depend on a runtime's phrasing.
118
+ *
119
+ * • `timedOut === true` ⇒ `timeout` — the deadline timer FIRED, full stop;
120
+ * • a spawn error, or `exitCode === null` WITHOUT a timeout ⇒ `dead` (the process never ran, or
121
+ * vanished);
122
+ * • anything else ⇒ `exited`, and the FAMILY parser decides. A clean exit with no parseable
123
+ * envelope is ALSO dead — the "spawned but mute" case — but that call belongs to the parser
124
+ * that knows what an envelope looks like, not to this function.
125
+ */
126
+ export function classifyChildRun(run: ChildRun):
127
+ | { kind: 'timeout' }
128
+ | { kind: 'dead'; detail: string }
129
+ | { kind: 'exited'; exitCode: number } {
130
+ if (run.timedOut === true) return { kind: 'timeout' };
131
+ if (run.spawnError !== null && run.spawnError !== undefined && run.spawnError !== '') {
132
+ return { kind: 'dead', detail: run.spawnError };
133
+ }
134
+ if (run.exitCode === null || run.exitCode === undefined) {
135
+ return { kind: 'dead', detail: 'the child exited with no code and the deadline never fired — it vanished' };
136
+ }
137
+ return { kind: 'exited', exitCode: run.exitCode };
138
+ }
139
+
140
+ // ── codexExec conventions (each one MEASURED — 02_research §5; re-measured this session) ────────
141
+
142
+ /**
143
+ * Prepended to a RETURN-VALUE codex dispatch. Codex `exec` is an agent with a workspace, not a
144
+ * completion endpoint: without this it will happily start reading files to answer a question whose
145
+ * whole answer is in the prompt. Paired with `--sandbox read-only`, which makes the instruction
146
+ * enforceable rather than advisory.
147
+ */
148
+ export const CODEX_SCOPING_PREFIX =
149
+ 'Answer directly from this prompt text alone; no commands, no files, no tools.';
150
+
151
+ /**
152
+ * The REAL prompt ceiling for `codex exec`.
153
+ *
154
+ * The folk value 1200 is refuted history: it came from an era when the prompt travelled through a
155
+ * fire-and-forget wrapper. Over-ceiling ⇒ a LOUD `prompt-over-ceiling`, never truncation — a
156
+ * truncated prompt produces a confident answer to a question nobody asked.
157
+ */
158
+ export const CODEX_EXEC_PROMPT_CEILING_CHARS = 24_000;
159
+
160
+ /**
161
+ * argv for one codex dispatch. The prompt travels as ONE argv element (no shell, no quoting), and
162
+ * `stdinText` is ALWAYS null for codex — MEASURED this session: with stdin left open, codex-cli
163
+ * 0.148.0 prints `Reading additional input from stdin...` and waits. `< /dev/null` is not a style
164
+ * choice; it is the difference between a 5.7 s answer and a hang.
165
+ *
166
+ * Return-value mode gets the scoping prefix AND `--sandbox read-only`. File mode gets NEITHER: the
167
+ * step's whole deliverable is a file it must be able to write.
168
+ */
169
+ export function codexExecArgv(modelId: string, prompt: string, deliverable: Deliverable): string[] {
170
+ const returnValue = (deliverable ?? 'return-value') !== 'file';
171
+ const text = returnValue ? CODEX_SCOPING_PREFIX + '\n\n' + prompt : prompt;
172
+ return ['exec', '-m', modelId, ...(returnValue ? ['--sandbox', 'read-only'] : []), text];
173
+ }
174
+
175
+ /** The liveness probe: an allowlist says an id is SPELLABLE, only a probe says it ANSWERS. */
176
+ export function codexProbeArgv(candidateId: string): string[] {
177
+ return ['exec', '-m', candidateId, '--sandbox', 'read-only', 'Reply with exactly: OK'];
178
+ }
179
+
180
+ /** Word-bounded `OK` on a clean exit — the `interpretClaudeProbe` twin. */
181
+ export function interpretCodexProbe(out: { stdout: string; exitCode: number | null }): boolean {
182
+ if (out.exitCode !== 0) return false;
183
+ return /\bOK\b/.test(String(out.stdout ?? ''));
184
+ }
185
+
186
+ /**
187
+ * Best-effort token extraction from codex stderr — null when absent, NEVER 0 and never estimated.
188
+ *
189
+ * MEASURED (codex-cli 0.148.0, this session): the trailer is a TOTAL only —
190
+ * `tokens used\n9,820` — with no input/output split. `wf-budget-1` has no field for a total, and
191
+ * attributing a total to either half would be a fabrication, so this returns BOTH nulls for that
192
+ * shape and the row's `tokensSource` stays null. The split branch below exists because some
193
+ * builds/configs do print one; it is tested, not assumed. (Named consequence: codex runs report no
194
+ * token counts today. That is the honest state, not a bug to paper over — see the manifest.)
195
+ */
196
+ export function extractCodexTokens(stderr: string): { tokensIn: number | null; tokensOut: number | null } {
197
+ const text = String(stderr ?? '');
198
+ const num = (raw: string | undefined): number | null => {
199
+ if (raw === undefined) return null;
200
+ const n = Number(raw.replace(/[,_\s]/g, ''));
201
+ return Number.isFinite(n) && n >= 0 ? n : null;
202
+ };
203
+ // Two accepted spellings per half: `input tokens: N` / `input: N tokens` and `tokens in: N`.
204
+ const inMatch = /\binput\b[^\n\d]{0,20}([\d,_]+)|\btokens?\s+in\b[^\n\d]{0,10}([\d,_]+)/i.exec(text);
205
+ const outMatch = /\boutput\b[^\n\d]{0,20}([\d,_]+)|\btokens?\s+out\b[^\n\d]{0,10}([\d,_]+)/i.exec(text);
206
+ const tokensIn = inMatch === null ? null : num(inMatch[1] ?? inMatch[2]);
207
+ const tokensOut = outMatch === null ? null : num(outMatch[1] ?? outMatch[2]);
208
+ return { tokensIn, tokensOut };
209
+ }
210
+
211
+ /**
212
+ * The claude `--output-format json` USAGE fields, pinned to the LIVE envelope shape.
213
+ *
214
+ * MEASURED this session (`claude -p --output-format json`, sonnet):
215
+ * `usage.input_tokens = 2`, `usage.output_tokens = 4`, alongside `cache_read_input_tokens` and
216
+ * `cache_creation_input_tokens`. Only the two plain counters are reported — cache tokens are a
217
+ * SEPARATE dimension `wf-budget-1` has no field for, and silently folding them into `tokensIn`
218
+ * would inflate every cached run's cost picture. Added to the architecture's export list; see the
219
+ * manifest.
220
+ */
221
+ export function extractClaudeUsage(stdout: string): { tokensIn: number | null; tokensOut: number | null } {
222
+ const env = extractClaudeResult(String(stdout ?? ''));
223
+ if (!env.ok) return { tokensIn: null, tokensOut: null };
224
+ // re-scan for the envelope object itself: extractClaudeResult hands back only the text
225
+ const lines = String(stdout ?? '').split(/\r?\n/);
226
+ const candidates = [String(stdout ?? '').trim(), ...lines.map((l) => l.trim())].filter((c) => c.startsWith('{') && c.endsWith('}'));
227
+ let usage: Record<string, unknown> | null = null;
228
+ for (const c of candidates) {
229
+ let obj: unknown;
230
+ try {
231
+ obj = JSON.parse(c);
232
+ } catch {
233
+ continue;
234
+ }
235
+ if (obj === null || typeof obj !== 'object') continue;
236
+ const u = (obj as Record<string, unknown>)['usage'];
237
+ if (u !== null && typeof u === 'object' && !Array.isArray(u)) usage = u as Record<string, unknown>;
238
+ }
239
+ if (usage === null) return { tokensIn: null, tokensOut: null };
240
+ const pick = (k: string): number | null => {
241
+ const v = usage?.[k];
242
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
243
+ };
244
+ return { tokensIn: pick('input_tokens'), tokensOut: pick('output_tokens') };
245
+ }
246
+
247
+ // ── the adapter factories (pure over the injected ChildRunner) ───────────────────────────────────
248
+
249
+ function failed(family: BridgeFamily, model: string | null, wallMs: number, reason: DispatchReason, detail: string, outcome: 'null' | 'error' = 'null'): DispatchResult {
250
+ return {
251
+ outcome,
252
+ text: null,
253
+ family,
254
+ modelUsed: model,
255
+ wallMs,
256
+ tokensIn: null,
257
+ tokensOut: null,
258
+ tokensSource: null,
259
+ failure: { reason, detail },
260
+ };
261
+ }
262
+
263
+ /**
264
+ * The codex adapter. Conventions, each a Confirmation-1 assertion:
265
+ * stdin ALWAYS closed; the prompt as one argv element; scoping prefix + `--sandbox read-only` on
266
+ * return-value and NEITHER on file mode; `detached: true` on every spawn (AM-10).
267
+ */
268
+ export function makeCodexExecDispatcher(run: ChildRunner, opts?: { bin?: string; ceilingChars?: number; isolatedCwd?: () => string; monotonicMs?: () => number }): Dispatcher {
269
+ const bin = opts?.bin ?? 'codex';
270
+ const ceiling = opts?.ceilingChars ?? CODEX_EXEC_PROMPT_CEILING_CHARS;
271
+ // A PROBE has no target tree. the current working directory is the CLI’s business — core never reaches for it,
272
+ // which is what keeps the purity grep over this package honest.
273
+ // The adapter CLOCKS ITSELF, through an INJECTED monotonic source. Core may not reach for a real
274
+ // clock (that is what makes every property in this feature reproducible), so the default is a
275
+ // fixed 0 and the CLI injects the real one; the scheduler's own delta fills in when it is 0. A
276
+ // live probe MEASURED the un-clocked version reporting `wallMs: 0` on a 4.4 s real dispatch —
277
+ // per-dispatch wall clock is the one thing budget.jsonl exists to carry, so a 0 there is a lie.
278
+ const clock = opts?.monotonicMs ?? ((): number => 0);
279
+ const isolatedCwd = opts?.isolatedCwd ?? ((): string => '.');
280
+ return {
281
+ probe: async (candidates) => {
282
+ const t0 = clock();
283
+ const list = candidates.length > 0 ? candidates : ['gpt-5.5'];
284
+ const started: string[] = [];
285
+ for (const id of list) {
286
+ const r = await run(bin, codexProbeArgv(id), { stdinText: null, timeoutMs: 120_000, cwd: isolatedCwd(), detached: true });
287
+ started.push(id);
288
+ if (interpretCodexProbe(r)) return { id, wallMs: clock() - t0, detail: `codex answered on ${id}` };
289
+ }
290
+ return { id: null, wallMs: clock() - t0, detail: `no codex candidate answered a probe (tried: ${started.join(', ')}) — an allowlist says an id is spellable, only a probe says it answers` };
291
+ },
292
+ dispatch: async (req) => {
293
+ const t0 = clock();
294
+ if (req.prompt.length > ceiling) {
295
+ return failed('openai', req.resolvedModelId, 0, 'prompt-over-ceiling',
296
+ `assembled prompt is ${req.prompt.length} chars, over the ${ceiling}-char codex exec ceiling — refusing LOUDLY rather than truncating, because a truncated prompt produces a confident answer to a question nobody asked`,
297
+ 'error');
298
+ }
299
+ const r = await run(bin, codexExecArgv(req.resolvedModelId, req.prompt, req.deliverable), {
300
+ stdinText: null, // MEASURED: codex waits on an open stdin
301
+ timeoutMs: req.timeoutMs,
302
+ cwd: req.cwd,
303
+ detached: true,
304
+ });
305
+ const cls = classifyChildRun(r);
306
+ if (cls.kind === 'timeout') return failed('openai', req.resolvedModelId, req.timeoutMs, 'dispatch-timeout', `the ${req.timeoutMs}ms deadline fired on step ${req.stepId}`);
307
+ if (cls.kind === 'dead') return failed('openai', req.resolvedModelId, clock() - t0, 'dispatch-dead', cls.detail);
308
+ const text = String(r.stdout ?? '').trim();
309
+ if (cls.exitCode !== 0 || text === '') {
310
+ return failed('openai', req.resolvedModelId, clock() - t0, 'dispatch-dead',
311
+ `codex exited ${cls.exitCode} with ${text === '' ? 'NO stdout' : String(text.length) + ' chars of stdout'} — a clean exit with nothing to read is the spawned-but-mute case, not an empty success`);
312
+ }
313
+ const tokens = extractCodexTokens(r.stderr);
314
+ return {
315
+ outcome: 'ok',
316
+ text,
317
+ family: 'openai',
318
+ modelUsed: req.resolvedModelId,
319
+ wallMs: clock() - t0,
320
+ tokensIn: tokens.tokensIn,
321
+ tokensOut: tokens.tokensOut,
322
+ tokensSource: tokens.tokensIn === null && tokens.tokensOut === null ? null : 'codex-stderr',
323
+ };
324
+ },
325
+ };
326
+ }
327
+
328
+ /**
329
+ * The claude adapter. Return-value mode is the `dz qe-bridge` ISOLATION discipline verbatim:
330
+ * `claudeReviewArgs(model)` (which carries `CLAUDE_ISOLATION_ARGS`: `--output-format json`,
331
+ * `--safe-mode`, `--strict-mcp-config`, `--tools ''`, `--no-session-persistence`), an EMPTY temp
332
+ * cwd so no project state leaks in, the prompt on STDIN (no ARG_MAX ceiling, no shell), and a
333
+ * LAST-anchored envelope parse so anything a customization printed first is structurally outside
334
+ * the reviewed text.
335
+ *
336
+ * File mode drops `--tools ''` and `--safe-mode` (the step's deliverable is a file it must write)
337
+ * and runs in `req.cwd`, but keeps `--output-format json` and the envelope parse — and the
338
+ * scheduler's landed barrier still has the last word.
339
+ */
340
+ export function makeClaudePDispatcher(run: ChildRunner, opts?: { bin?: string; isolatedCwd?: () => string; monotonicMs?: () => number }): Dispatcher {
341
+ const bin = opts?.bin ?? 'claude';
342
+ const clock = opts?.monotonicMs ?? ((): number => 0);
343
+ const isolatedCwd = opts?.isolatedCwd ?? ((): string => '.');
344
+ return {
345
+ probe: async (candidates) => {
346
+ const t0 = clock();
347
+ const list = candidates.length > 0 ? candidates : ['sonnet'];
348
+ const tried: string[] = [];
349
+ for (const id of list) {
350
+ const argv = claudeProbeArgs(id);
351
+ tried.push(id);
352
+ if (argv === null) continue; // an unsafe id is not spellable, let alone answerable
353
+ const r = await run(bin, argv, { stdinText: null, timeoutMs: 120_000, cwd: isolatedCwd(), detached: true });
354
+ if (interpretClaudeProbe({ stdout: r.stdout, exitCode: r.exitCode ?? 1 })) return { id, wallMs: clock() - t0, detail: `claude answered on ${id}` };
355
+ }
356
+ return { id: null, wallMs: clock() - t0, detail: `no claude candidate answered a probe (tried: ${tried.join(', ')})` };
357
+ },
358
+ dispatch: async (req) => {
359
+ const t0 = clock();
360
+ const fileMode = req.deliverable === 'file';
361
+ const argv = fileMode ? claudeFileArgs(req.resolvedModelId) : claudeReviewArgs(req.resolvedModelId);
362
+ if (argv === null) {
363
+ return failed('claude', req.resolvedModelId, clock() - t0, 'dispatch-dead', `model id ${JSON.stringify(req.resolvedModelId)} is not a safe claude id`);
364
+ }
365
+ const r = await run(bin, argv, {
366
+ stdinText: req.prompt, // MEASURED: `printf … | claude -p` answers, so there is no ARG_MAX ceiling
367
+ timeoutMs: req.timeoutMs,
368
+ cwd: fileMode ? req.cwd : isolatedCwd(),
369
+ detached: true,
370
+ });
371
+ const cls = classifyChildRun(r);
372
+ if (cls.kind === 'timeout') return failed('claude', req.resolvedModelId, req.timeoutMs, 'dispatch-timeout', `the ${req.timeoutMs}ms deadline fired on step ${req.stepId}`);
373
+ if (cls.kind === 'dead') return failed('claude', req.resolvedModelId, clock() - t0, 'dispatch-dead', cls.detail);
374
+ // The EXIT CODE and the ENVELOPE must agree (Step-8 HIGH-10). A parseable success envelope
375
+ // from a process that exited nonzero is a CONTRADICTION, not a success: the runtime told us
376
+ // twice and the two answers differ, so believing the friendlier one is how a failed dispatch
377
+ // becomes a green step. The codex adapter already required exit 0; this one did not.
378
+ if (cls.exitCode !== 0) {
379
+ return failed('claude', req.resolvedModelId, clock() - t0, 'dispatch-dead',
380
+ `claude exited ${cls.exitCode} — a nonzero exit is a failed dispatch even when stdout carries a parseable success envelope; the two disagree and the exit code is the runtime's own verdict`);
381
+ }
382
+ const env = extractClaudeResult(r.stdout);
383
+ if (!env.ok) {
384
+ return failed('claude', req.resolvedModelId, clock() - t0, 'dispatch-dead',
385
+ `claude exited ${cls.exitCode} but the reply is not readable as a result envelope: ${env.detail}`);
386
+ }
387
+ const usage = extractClaudeUsage(r.stdout);
388
+ return {
389
+ outcome: 'ok',
390
+ text: env.text,
391
+ family: 'claude',
392
+ modelUsed: req.resolvedModelId,
393
+ wallMs: clock() - t0,
394
+ tokensIn: usage.tokensIn,
395
+ tokensOut: usage.tokensOut,
396
+ tokensSource: usage.tokensIn === null && usage.tokensOut === null ? null : 'claude-envelope',
397
+ };
398
+ },
399
+ };
400
+ }
401
+
402
+ /**
403
+ * FILE-mode claude argv: the isolation set MINUS the two flags that would make writing impossible
404
+ * (`--tools ''` and `--safe-mode`). Everything that makes the reply READABLE stays — the envelope
405
+ * is how the runner tells an answer from a banner, in either mode.
406
+ */
407
+ export function claudeFileArgs(model: string): string[] | null {
408
+ const review = claudeReviewArgs(model);
409
+ if (review === null) return null;
410
+ const drop = new Set(['--safe-mode']);
411
+ const out: string[] = [];
412
+ for (let i = 0; i < review.length; i++) {
413
+ const a = review[i] as string;
414
+ if (drop.has(a)) continue;
415
+ if (a === '--tools' && review[i + 1] === '') {
416
+ i++; // skip the empty allowlist value too
417
+ continue;
418
+ }
419
+ out.push(a);
420
+ }
421
+ return out;
422
+ }
423
+
424
+ // ── ingress defang (ADR-002 Confirmation-3 — the defangSignoffEchoes pattern, retargeted) ────────
425
+
426
+ /** The neutralization marker. Visible on purpose: an operator reading a prompt must be able to SEE
427
+ * that a quoted verdict was defanged rather than wonder why a reply looks odd. */
428
+ const GATE_QUOTED_MARKER = '[quoted-gate-verdict]';
429
+
430
+ /**
431
+ * Neutralize anchored `GATE: PASS|FAIL` lines inside UPSTREAM text before it is spliced into a
432
+ * DOWNSTREAM prompt.
433
+ *
434
+ * Why this exists as a second defence, when `gateVerdict` is already LAST-anchored: the two attacks
435
+ * are different. The egress parser stops a single reply from smuggling a verdict past its own
436
+ * terminal line. This stops a reply that legitimately CONTAINS a verdict — an upstream gate's own
437
+ * answer — from becoming the terminal line of a DOWNSTREAM step's reply once the model quotes its
438
+ * input back. Without it, "please review the previous verdict" is a working exploit against a plan
439
+ * that never did anything wrong.
440
+ *
441
+ * NEUTRALIZATION, not deletion: the words survive so the downstream model can still read what the
442
+ * upstream said. Only the ANCHORED grammar is broken. Idempotent — defanging twice is defanging
443
+ * once, so a value that travels through three steps is not progressively mangled.
444
+ */
445
+ export function defangGateEchoes(text: string): string {
446
+ return String(text ?? '').replace(GATE_ECHO_RE, (_m, lead: string, verdict: string) => `${lead}${GATE_QUOTED_MARKER} ${verdict}`);
447
+ }
448
+
449
+ /**
450
+ * ONE grammar for both halves (Step-8 MEDIUM-12).
451
+ *
452
+ * The parser (`gateVerdict`) matches `/^\s*GATE:\s*(PASS|FAIL)\s*$/` — JavaScript `\s`, which
453
+ * includes NBSP, the various Unicode spaces, and `\r`. The defanger used `[ \t]`, ASCII only. The
454
+ * gap was demonstrable: an NBSP-prefixed `GATE: PASS` was PARSED as a verdict and NOT defanged, so
455
+ * an upstream reply could still mint a downstream verdict by prefixing one non-breaking space.
456
+ * `[^\S\n]` is exactly "`\s` except the line separator" — the same character class the parser sees
457
+ * once the reply has been split into lines.
458
+ */
459
+ const GATE_ECHO_RE = /^([^\S\n]*(?:[>*~-][^\S\n]*)*)(?:\*{0,2}#{0,4}[^\S\n]*)?GATE[^\S\n]*[:=][^\S\n]*(PASS|FAIL)[^\S\n]*$/gm;