@deksden-com/dd-flow-cli 0.9.0-beta.95 → 0.9.0-beta.96

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.96
4
+
5
+ ### Patch Changes
6
+
7
+ - 4769ad6: Settle early lifecycle rejections against their native calls, reconcile retained provider capacity before continuing, and preserve causal outcomes across AGY, Grok, Droid, OpenCode, Codex, and ZCode adapters.
8
+
3
9
  ## 0.9.0-beta.95
4
10
 
5
11
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.95",
4
- "cli_commit": "4c8c5e8456d684a7887c8bfdd1afc24ae874ff6c",
5
- "built_at": "2026-09-22T06:50:54.211Z",
3
+ "cli_version": "0.9.0-beta.96",
4
+ "cli_commit": "cb6c669ff3f24115ac0a4d2c04c8877eb8683bc6",
5
+ "built_at": "2026-09-22T12:33:21.451Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
- "commit": "d1a6081ab15ab92ac917ff5d037121a40c709db1",
8
+ "commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
10
  "repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
11
11
  "memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
@@ -89,9 +89,25 @@ export async function runCli(args, io = defaultIo, env = process.env) {
89
89
  output = parseOutputOptions(args);
90
90
  }
91
91
  catch (error) {
92
- const message = error instanceof Error ? error.message : String(error);
93
- writeJson(io.stderr, { ok: false, error: { code: "usage", message } });
94
- return 2;
92
+ const rejection = isAppError(error) ? error : new AppError("usage", error instanceof Error ? error.message : String(error), 2);
93
+ Object.assign(rejection.details, { phase: "prepare", effect: "no_effect", recoverable: true, ...rejection.details });
94
+ try {
95
+ const command = quote(["dd-flow", ...args]), event = observedLifecycleNativeEvent(createRouterContext(env), command, env.DD_FLOW_DAEMON_ID);
96
+ if (event) {
97
+ const context = createContext({ ...env }, "hook");
98
+ try {
99
+ observeLifecycleCommand(context, command, event)({ error: rejection });
100
+ }
101
+ finally {
102
+ context.db.close?.();
103
+ }
104
+ }
105
+ }
106
+ catch (persistence) {
107
+ rejection.details.lifecycle_diagnostic_error = String(persistence);
108
+ }
109
+ writeJson(io.stderr, { ok: false, error: { code: rejection.code, message: rejection.message, details: rejection.details } });
110
+ return rejection.exitCode;
95
111
  }
96
112
  const progress = progressForCommand(output.args);
97
113
  let heartbeat;
@@ -284,7 +300,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
284
300
  }
285
301
  catch (caught) {
286
302
  const error = isAppError(caught) ? caught : new AppError("unexpected", caught instanceof Error ? caught.message : String(caught), 1, { effect: "unknown", cause: { code: caught?.code ?? null, sqlite_extended_code: caught?.errcode ?? null } });
287
- if (!settleHook && !invocationSettlement && env.DD_FLOW_DAEMON_ID) {
303
+ if (!settleHook && !invocationSettlement) {
288
304
  let earlyContext;
289
305
  try {
290
306
  const command = quote(["dd-flow", ...output.args]);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
3
3
  import { readFile } from "node:fs/promises";
4
- import { spawn } from "node:child_process";
4
+ import { invokeNativeHook } from "../lib/native-hook-command.mjs";
5
5
  import { doctor } from "../lib/dd-agy.mjs";
6
6
  import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-agy-daemon.mjs";
7
7
 
@@ -10,12 +10,14 @@ const prompt = async options => options["prompt-file"] ? await readFile(options[
10
10
  async function stdin() { let value = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) value += chunk; return value; }
11
11
  async function hook(options) {
12
12
  const payload = JSON.parse(await stdin() || "{}"), event = options.event;
13
- const identity = await callDaemon(options["state-dir"], "hook.observe", { event, payload });
13
+ const identity = await callDaemon(options["state-dir"], "hook.observe", { event, payload }, 10_000);
14
+ if (identity.stale) return event === "PreToolUse" ? { decision: "deny", reason: "agy_stale_hook" } : {};
14
15
  if (event === "PreToolUse" || event === "PostToolUse") {
15
16
  const phase = event === "PreToolUse" ? "before" : "after", args = payload.toolCall?.args ?? {};
16
- const normalized = { schema_id: "dd-flow/agy-tool-event@1", event_id: identity.event_id, phase, daemon_id: identity.daemon_id, conversation_id: payload.conversationId, parent_conversation_id: identity.parent_conversation_id, step_index: payload.stepIdx, tool: payload.toolCall?.name, input: args, workspace_paths: payload.workspacePaths, transcript_path: payload.transcriptPath, model: payload.modelName, profile: identity.profile, ...(phase === "after" ? { error: payload.error ?? "" } : {}) };
17
- const command = options["dd-flow-bin"] ?? identity.dd_flow_bin, target = /\.[cm]?js$/.test(command) ? { command: process.execPath, args: [command] } : { command, args: [] };
18
- await new Promise((resolve, reject) => { const child = spawn(target.command, [...target.args, "agy", "event", "handle", "--project-root", options["project-root"] ?? identity.project_root, "--json"], { env: { ...process.env, DD_FLOW_HOME: options["dd-flow-home"] ?? identity.dd_flow_home }, stdio: ["pipe", "ignore", "pipe"] }); let error = ""; child.stderr.setEncoding("utf8").on("data", chunk => error += chunk); child.on("error", reject); child.on("close", code => code === 0 ? resolve() : reject(new Error(error || "dd-flow rejected Antigravity hook"))); child.stdin.end(`${JSON.stringify(normalized)}\n`); });
17
+ const normalized = { schema_id: "dd-flow/agy-tool-event@1", event_id: identity.event_id, phase, daemon_id: identity.daemon_id, conversation_id: payload.conversationId, parent_conversation_id: identity.parent_conversation_id, execution_num: payload.executionNum, step_index: payload.stepIdx, tool: payload.toolCall?.name, input: args, workspace_paths: payload.workspacePaths, transcript_path: payload.transcriptPath, model: payload.modelName, profile: identity.profile, ...(phase === "after" ? { error: payload.error ?? "" } : {}) };
18
+ await invokeNativeHook({ bin: options["dd-flow-bin"] ?? identity.dd_flow_bin,
19
+ args: ["agy", "event", "handle", "--project-root", options["project-root"] ?? identity.project_root, "--json"],
20
+ payload: normalized, home: options["dd-flow-home"] ?? identity.dd_flow_home });
19
21
  }
20
22
  return event === "PreToolUse" ? { decision: "allow" } : {};
21
23
  }
@@ -284,7 +284,10 @@ export class Runtime {
284
284
  if (result.conversation_id && result.conversation_id !== this.init?.conversation_id) {
285
285
  await this.journal("foreign_terminal_observed", { conversation_id: result.conversation_id, result });
286
286
  const child = this.descendants.get(result.conversation_id);
287
- if (child) this.descendants.set(result.conversation_id, { ...child, status: result.status === "SUCCESS" ? "completed" : result.status === "ERROR" ? "failed" : "cancelled" });
287
+ if (child) {
288
+ this.descendants.set(result.conversation_id, { ...child, status: result.status === "SUCCESS" ? "completed" : result.status === "ERROR" ? "failed" : "cancelled" });
289
+ await this.completePendingTurn();
290
+ }
288
291
  return;
289
292
  }
290
293
  const current = this.active;
@@ -312,7 +315,19 @@ export class Runtime {
312
315
  : "agy_provider_failed";
313
316
  return current.reject(new DaemonError(code, message, ["agy_provider_quota_exhausted", "agy_provider_rate_limited"].includes(code), { provider_result: result, provider_error_scope: current.previousError && current.previousError === result.error ? "repeated_conversation_error" : "terminal_result", usage_ingest: this.usageIngest }));
314
317
  }
315
- if (terminalReceipt.settled) { this.active = null; current.resolve(terminalReceipt); }
318
+ await this.completePendingTurn();
319
+ }
320
+ async completePendingTurn() {
321
+ const current = this.active;
322
+ if (!current?.terminalResult || current.terminalResult !== this.lastResult) return;
323
+ await this.persist({ active_tree: !this.receipt().settled });
324
+ // Hooks can invalidate the tree while persistence is pending. Never
325
+ // publish a proof captured before that asynchronous boundary.
326
+ if (this.active !== current) return;
327
+ const receipt = this.receipt();
328
+ if (!receipt.settled) { await this.persist({ active_tree: true }); return; }
329
+ this.active = null;
330
+ current.resolve(receipt);
316
331
  }
317
332
  receipt(result = this.lastResult) {
318
333
  const assistantText = typeof result?.response === "string" ? result.response : typeof result?.text === "string" ? result.text : null;
@@ -365,10 +380,10 @@ export class Runtime {
365
380
  if (!child) throw new DaemonError("agy_child_identity_unconfirmed", "Antigravity hook has no confirmed native parent", true, { conversation_id: conversationId });
366
381
  return child;
367
382
  }
368
- async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.last_hook !== fingerprint) this.childActivity(conversationId); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...this.descendants.get(conversationId), tree_settled: true, settlement_evidence: "child_fully_idle_stop" }); const terminalPending = this.active?.terminalResult === this.lastResult; if (!child && !stale && event === "Stop" && payload.fullyIdle === true && (!this.active || terminalPending) && ["SUCCESS", "ERROR", "CANCELLED"].includes(this.lastResult?.status)) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" }); const settled = terminalPending ? this.receipt(this.lastResult) : null; const completed = settled?.settled ? this.active : null; if (completed) this.active = null; const parent = child?.parent_provider_session_id ?? null;
383
+ async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); if (stale) { await this.journal("stale_hook", { hook_event: event, conversation_id: conversationId, step_index: step }); return { stale: true }; } const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(event === "PreToolUse" ? { last_pre_hook: fingerprint } : {}), ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.last_pre_hook !== fingerprint) this.childActivity(conversationId); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...this.descendants.get(conversationId), tree_settled: true, settlement_evidence: "child_fully_idle_stop" }); const terminalPending = this.active?.terminalResult === this.lastResult; if (!child && !stale && event === "Stop" && payload.fullyIdle === true && (!this.active || terminalPending) && ["SUCCESS", "ERROR", "CANCELLED"].includes(this.lastResult?.status)) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" }); const parent = child?.parent_provider_session_id ?? null;
369
384
  const observed = payload.model ? observedProfile(payload) : {};
370
385
  await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: conversationId, parentSessionId: parent, requested: this.config, observed, source: "native.hook", evidence: payload.model ? "configured" : "unavailable", reason: payload.model ? null : "hook_omits_current_model" });
371
- const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, turn_generation: this.turnGeneration, fully_idle: payload.fullyIdle ?? null, termination_reason: payload.terminationReason ?? null, execution: payload.executionNum ?? null, step: payload.stepIdx ?? null, tool: payload.toolCall?.name ?? null, args: payload.toolCall?.args ?? null })).digest("hex"); await this.persist(completed ? { active_tree: false } : {}); await this.journal("hook", { stale, hook_event: event, event_id: eventId, conversation_id: conversationId, turn_generation: this.turnGeneration, execution_num: execution, step_index: step, termination_reason: payload.terminationReason ?? null, fully_idle: payload.fullyIdle ?? null }); completed?.resolve(settled); return { event_id: eventId, daemon_id: this.config.daemonId, parent_conversation_id: parent, profile: observed, requested_profile: { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode }, project_root: this.config.projectRoot, dd_flow_bin: this.config.ddFlowBin, dd_flow_home: this.config.ddFlowHome };
386
+ const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, fully_idle: payload.fullyIdle ?? null, termination_reason: payload.terminationReason ?? null, execution: payload.executionNum ?? null, step: payload.stepIdx ?? null, tool: payload.toolCall?.name ?? null, args: payload.toolCall?.args ?? null })).digest("hex"); await this.persist(); await this.journal("hook", { stale, hook_event: event, event_id: eventId, conversation_id: conversationId, turn_generation: this.turnGeneration, execution_num: execution, step_index: step, termination_reason: payload.terminationReason ?? null, fully_idle: payload.fullyIdle ?? null }); await this.completePendingTurn(); return { event_id: eventId, daemon_id: this.config.daemonId, parent_conversation_id: parent, profile: observed, requested_profile: { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode }, project_root: this.config.projectRoot, dd_flow_bin: this.config.ddFlowBin, dd_flow_home: this.config.ddFlowHome };
372
387
  }
373
388
  async finalizeProviderForControl() {
374
389
  const pending = this.providerFinalization;
@@ -324,11 +324,8 @@ export class DroidRuntime {
324
324
  catch (error) {
325
325
  receipt = { provider_session_id: binding.provider_session_id, adapter_session_id: binding.provider_session_id, settled: false, settlement_observation_error: errorRecord(error) };
326
326
  }
327
- // A terminal root turn is not enough to recover the operation while a
328
- // child Task or owned provider process is still active. Leave the
329
- // durable binding for the normal settlement observer instead of exposing
330
- // a misleading successful receipt.
331
- if (receipt.settled !== true) return null;
327
+ // Native outcome and tree settlement are independent. Retain the former
328
+ // even while children are running; settled:false keeps capacity reserved.
332
329
  const current = this.active;
333
330
  if (current?.operationId === operationId && current.turnId === binding.turn_id) {
334
331
  current.outcome = outcome; this.lastResult = outcome;
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from "node:crypto";
5
5
  import { chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
6
6
  import { spawn } from "node:child_process";
7
7
  import net from "node:net";
8
- import { durableDaemonDispatch } from "./daemon-operations.mjs";
8
+ import { durableDaemonDispatch as dispatchOperation } from "./daemon-operations.mjs";
9
9
  import { waitForSettlement } from "./session-settlement.mjs";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
@@ -17,6 +17,10 @@ import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmD
17
17
  const REQUEST_SCHEMA = "dd-grok/daemon-request@1";
18
18
  const RESPONSE_SCHEMA = "dd-grok/daemon-response@1";
19
19
  const STATE_SCHEMA = "dd-grok/daemon-state@1";
20
+ // Normalize raw socket callers too, before the shared layer reserves capacity.
21
+ export function durableDaemonDispatch(root, request, action, observe) {
22
+ return dispatchOperation(root, request.operation === "session.resume" ? { ...request, operation: "session.inspect" } : request, action, observe);
23
+ }
20
24
  class DaemonError extends Error { constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; } }
21
25
  function absolute(value, label) { if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`); return path.resolve(value); }
22
26
  function locations(stateDir) { const dir = absolute(stateDir, "--state-dir"); const localSocket = path.join(dir, "daemon.sock"); return { dir, socket: Buffer.byteLength(localSocket) < 100 ? localSocket : `/tmp/dd-grok-${createHash("sha256").update(dir).digest("hex").slice(0, 24)}.sock`, state: path.join(dir, "daemon.json"), log: path.join(dir, "daemon.log"), home: path.join(dir, "grok-home") }; }
@@ -142,6 +146,8 @@ async function materializeSessionArchive(paths, archiveDir) {
142
146
  }
143
147
 
144
148
  export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
149
+ // Grok resume observes an already-owned Session; it never starts a turn.
150
+ if (operation === "session.resume") operation = "session.inspect";
145
151
  const { socket } = locations(stateDir); const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
146
152
  return await new Promise((resolve, reject) => {
147
153
  const client = net.createConnection(socket); let buffer = ""; let settled = false;
@@ -233,13 +239,32 @@ export class Runtime {
233
239
  return running;
234
240
  }
235
241
  async requireSettled() { const running = await this.refreshTree(); if (running.length) throw new DaemonError("tree_not_settled", "daemon still owns a running Session tree", false, { sessions: running }); }
236
- async productive(name, task) { if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true); this.active = name; for (const session of this.sessions.values()) session.native_root_receipt = null; try { await this.persist({ active_tree: true, active_operation: name }); const result = await task(); this.track(result); const running = this.treeRunning(result); const session = this.sessions.get(result.provider_session_id); if (session && (["session.create", "session.fork"].includes(name) || ["end_turn", "cancelled"].includes(result.turn?.stopReason))) session.native_root_receipt = { kind: name === "session.prompt" ? result.turn.stopReason : "created", daemon_id: this.state.daemon_id }; await this.persist({ active_tree: running, active_operation: null }); return { ...result, descendants: this.topology(result.provider_session_id), settled: !running }; } catch (error) { await this.persist({ active_tree: true, active_operation: null }); throw error; } finally { this.active = null; } }
242
+ async productive(name, task, sessionId) {
243
+ if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true);
244
+ this.active = name;
245
+ // Only a new native turn invalidates its own root proof. Creation, fork
246
+ // and local archive export must not erase an unrelated Session's receipt.
247
+ if (name === "session.prompt" && this.sessions.has(sessionId)) this.sessions.get(sessionId).native_root_receipt = null;
248
+ try {
249
+ await this.persist({ active_tree: true, active_operation: name });
250
+ const result = await task();
251
+ this.track(result);
252
+ const session = this.sessions.get(result.provider_session_id);
253
+ if (session && (["session.create", "session.fork"].includes(name) || ["end_turn", "cancelled"].includes(result.turn?.stopReason))) session.native_root_receipt = { kind: name === "session.prompt" ? result.turn.stopReason : "created", daemon_id: this.state.daemon_id };
254
+ // Archive requires an idle tree before entering this exclusive section
255
+ // and performs no native work. Its file receipt is not tree evidence.
256
+ const running = name === "session.archive" ? false : this.treeRunning(result);
257
+ await this.persist({ active_tree: running, active_operation: null });
258
+ return { ...result, descendants: this.topology(result.provider_session_id), settled: !running };
259
+ } catch (error) { await this.persist({ active_tree: true, active_operation: null }); throw error; }
260
+ finally { this.active = null; }
261
+ }
237
262
  async dispatch(operation, params) {
238
263
  const assertDispatch = captureDispatchGuard(this);
239
264
  if (operation === "daemon.status") { let observationError; if (!this.active) try { await this.refreshTree(); } catch (error) { observationError = errorPayload(error); } return { observation_error: observationError ?? null, daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, config_isolation: this.state.config_isolation, shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status, auth_status: this.state.auth_status, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config: this.state.config }; }
240
- if (operation === "hook.resolve") { const sessionId = String(params.sessionId ?? ""); if (!sessionId) throw new DaemonError("hook_identity_missing", "hook has no sessionId"); let session = this.sessions.get(sessionId); if (!session) { const roots = [...this.sessions.values()].filter((item) => !item.parent_provider_session_id); if (roots.length !== 1) throw new DaemonError("hook_identity_unknown", "unknown hook Session has no unique root"); session = { provider_session_id: sessionId, adapter_session_id: sessionId, parent_provider_session_id: roots[0].provider_session_id, root_provider_session_id: roots[0].provider_session_id }; this.sessions.set(sessionId, session); await this.persist(); await inspectSessionWithBridge(this.bridge, this.options({ sessionId, liveSession: true })); } return { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} }; }
265
+ if (operation === "hook.resolve") { const sessionId = String(params.sessionId ?? ""); if (!sessionId) throw new DaemonError("hook_identity_missing", "hook has no sessionId"); let session = this.sessions.get(sessionId); if (!session) { const roots = [...this.sessions.values()].filter((item) => !item.parent_provider_session_id); if (roots.length !== 1) throw new DaemonError("hook_identity_unknown", "unknown hook Session has no unique root"); const observed = await inspectSessionWithBridge(this.bridge, this.options({ sessionId, liveSession: true })); this.track(observed); session = this.sessions.get(sessionId); if (!session || session.root_provider_session_id !== roots[0].provider_session_id) throw new DaemonError("hook_identity_unknown", "hook Session is not a member of the retained native tree"); await this.persist(); } return { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} }; }
241
266
  if (operation === "session.create") return await this.productive(operation, async () => { const result = await createSessionWithBridge(this.bridge, { ...this.options(params), onSessionCreated: async (session) => { this.track(session); this.loadedSessionId = session.provider_session_id; await this.persist(); } }, this.initialized); this.loadedSessionId = result.provider_session_id; return result; });
242
- if (operation === "session.prompt") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await promptSessionWithBridge(this.bridge, { ...this.options(params), assertDispatch }); this.loadedSessionId = params.sessionId; return result; }); }
267
+ if (operation === "session.prompt") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await promptSessionWithBridge(this.bridge, { ...this.options(params), assertDispatch }); this.loadedSessionId = params.sessionId; return result; }, params.sessionId); }
243
268
  if (operation === "session.fork") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await forkSessionWithBridge(this.bridge, this.options(params)); this.loadedSessionId = params.sessionId; return result; }); }
244
269
  if (operation === "session.inspect" || operation === "session.resume") { const result = await inspectSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); this.track(result); await this.persist(); return { ...result, settled: !this.active && !this.treeRunning(result), descendants: this.topology(result.provider_session_id) }; }
245
270
  if (operation === "session.cancel") { if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon"); cancelPendingDispatch(this); const result = await cancelSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); await this.persist({ active_tree: Boolean(this.active) || !result.settled }); return result; }
@@ -34,7 +34,8 @@ function errorPayload(error) { return { code: error.code ?? "operation_failed",
34
34
 
35
35
  async function writePlugin(paths, config) {
36
36
  const directory = path.join(paths.config, "opencode", "plugins"); await mkdir(directory, { recursive: true, mode: 0o700 });
37
- const source = `import { spawn } from "node:child_process";\nconst candidate = command => typeof command === "string" && command.includes("dd-flow");\nconst participating = new Set();\nconst invoke = async (payload) => await new Promise((resolve,reject)=>{ const bin=process.env.DD_OPENCODE_DD_FLOW_BIN; const js=/\\.[cm]?js$/.test(bin); const child=spawn(js?process.env.DD_OPENCODE_NODE_BIN:bin,[...(js?[bin]:[]),"opencode","event","handle","--project-root",process.env.DD_OPENCODE_PROJECT_ROOT,"--json"],{env:process.env,stdio:["pipe","pipe","pipe"]}); let out="",err=""; child.stdout.on("data",x=>out+=x); child.stderr.on("data",x=>err+=x); child.on("error",reject); child.on("close",code=>code===0?resolve(JSON.parse(out||"{}")):reject(new Error(err||"dd-flow hook failed"))); child.stdin.end(JSON.stringify(payload)+"\\n"); });\nexport const DdFlowPlugin=async({client,project,directory})=>{ const envelope=async(phase,input,extra={})=>{ const session=(await client.session.get({path:{id:input.sessionID}})).data; return {schema_id:"dd-flow/opencode-tool-event@1",event_id:[input.sessionID,input.callID,phase].join(":"),phase,daemon_id:process.env.DD_OPENCODE_DAEMON_ID,server_version:process.env.DD_OPENCODE_SERVER_VERSION,plugin_sha256:process.env.DD_OPENCODE_PLUGIN_SHA256,session:{provider_session_id:input.sessionID,parent_provider_session_id:session?.parentID??null,agent_id:process.env.DD_OPENCODE_AGENT,directory:session?.directory??directory,project_id:session?.projectID??project?.id},message_id:input.messageID??null,tool_call_id:input.callID,tool:input.tool,profile:{provider:process.env.DD_OPENCODE_PROVIDER,model:process.env.DD_OPENCODE_MODEL,variant:process.env.DD_OPENCODE_VARIANT,agent:process.env.DD_OPENCODE_AGENT},...extra}; }; return {"tool.execute.before":async(input,output)=>{ const command=output.args?.command; if(!candidate(command)) return; const result=await invoke(await envelope("before",input,{input:output.args})); if(result?.observed) participating.add(input.callID); const updated=result?.hookSpecificOutput?.updatedInput; if(updated) Object.assign(output.args,updated); },"tool.execute.after":async(input,output)=>{ if(!participating.delete(input.callID)) return; await invoke(await envelope("after",input,{input:input.args,outcome:{status:"completed",title:output.title}})); }}; };\n`;
37
+ const helper = new URL("./native-hook-command.mjs", import.meta.url).href;
38
+ const source = `import { invokeNativeHook } from ${JSON.stringify(helper)};\nconst candidate = command => typeof command === "string" && command.length > 0;\nconst participating = new Set();\nconst invoke = async payload => await invokeNativeHook({bin:process.env.DD_OPENCODE_DD_FLOW_BIN,args:["opencode","event","handle","--project-root",process.env.DD_OPENCODE_PROJECT_ROOT,"--json"],payload,home:process.env.DD_FLOW_HOME,timeoutMs:15000});\nexport const DdFlowPlugin=async({client,project,directory})=>{ const envelope=async(phase,input,extra={})=>{ const response=await client.session.get({path:{id:input.sessionID}}); if(response?.error||!response?.data) throw Object.assign(new Error("OpenCode Session identity is unavailable"),{code:"opencode_session_identity_unavailable"}); const session=response.data; return {schema_id:"dd-flow/opencode-tool-event@1",event_id:[input.sessionID,input.callID,phase].join(":"),phase,daemon_id:process.env.DD_OPENCODE_DAEMON_ID,server_version:process.env.DD_OPENCODE_SERVER_VERSION,plugin_sha256:process.env.DD_OPENCODE_PLUGIN_SHA256,session:{provider_session_id:input.sessionID,parent_provider_session_id:session.parentID??null,agent_id:process.env.DD_OPENCODE_AGENT,directory:session.directory??directory,project_id:session.projectID??project?.id},message_id:input.messageID??null,tool_call_id:input.callID,tool:input.tool,profile:{provider:process.env.DD_OPENCODE_PROVIDER,model:process.env.DD_OPENCODE_MODEL,variant:process.env.DD_OPENCODE_VARIANT,agent:process.env.DD_OPENCODE_AGENT},...extra}; }; return {"tool.execute.before":async(input,output)=>{ const command=output.args?.command; if(!candidate(command)) return; const result=await invoke(await envelope("before",input,{input:output.args})); if(result?.observed) participating.add(input.callID); const updated=result?.hookSpecificOutput?.updatedInput; if(updated) Object.assign(output.args,updated); },"tool.execute.after":async(input,output)=>{ if(!participating.delete(input.callID)) return; await invoke(await envelope("after",input,{input:input.args,outcome:{status:"completed",title:output.title}})); }}; };\n`;
38
39
  const checksum = createHash("sha256").update(source).digest("hex"); await writeFile(path.join(directory, "dd-flow.js"), source, { mode: 0o600 }); return checksum;
39
40
  }
40
41
  async function prepare(paths, config, authSource) {
@@ -84,7 +85,23 @@ export class Runtime {
84
85
  }
85
86
  async requireSettled(id) { const value = await this.describe(id); if (!value.settled) throw new DaemonError("tree_not_settled", "OpenCode Session is not idle", false, { session_id: id, status: value.status }); return value; }
86
87
  async reconcileUsage(observed) { const errors = []; try { await forwardUsage(this.state.config, observed.provider_session_id, observed.usage); } catch (error) { errors.push({ session_id: observed.provider_session_id, phase: "usage", ...errorPayload(error) }); } for (const child of observed.children.filter(item => (item.parentID ?? observed.provider_session_id) === observed.provider_session_id)) { try { errors.push(...await this.reconcileUsage(await this.describe(child.id))); } catch (error) { errors.push({ session_id: child.id, phase: "usage_child", ...errorPayload(error) }); } } return errors; }
87
- async productive(name, task) { if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true); this.active = name; try { await this.persist({ active_tree: true, active_operation: name }); const result = await task(); await this.persist({ active_tree: !result?.settled, active_operation: null }); await this.journal(name, result); return result; } catch (error) { await this.persist({ active_tree: true, active_operation: null }); await this.journal(`${name}.failed`, null); throw error; } finally { this.active = null; } }
88
+ async productive(name, task) {
89
+ if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true);
90
+ this.active = name; let result, completed = false;
91
+ try {
92
+ await this.persist({ active_tree: true, active_operation: name });
93
+ result = await task(); completed = true;
94
+ await this.persist({ active_tree: !result?.settled, active_operation: null });
95
+ await this.journal(name, result); return result;
96
+ } catch (cause) {
97
+ const error = completed ? new DaemonError("operation_observation_lost", "OpenCode returned but its durable observation could not be saved; do not resend", false, { result, cause: errorPayload(cause) }) : cause;
98
+ const failures = [];
99
+ try { await this.persist({ active_tree: true, active_operation: null }); } catch (secondary) { failures.push(errorPayload(secondary)); }
100
+ try { await this.journal(`${name}.failed`, null); } catch (secondary) { failures.push(errorPayload(secondary)); }
101
+ if (failures.length) error.details = { ...error.details, observation_errors: failures };
102
+ throw error;
103
+ } finally { this.active = null; }
104
+ }
88
105
  async cancelTree(id, seen = new Set()) {
89
106
  if (seen.has(id)) return [];
90
107
  seen.add(id);
@@ -170,6 +187,8 @@ export class Runtime {
170
187
  },
171
188
  ...(params.cancelTree ? { cancel: id => this.cancelTree(id) } : {})
172
189
  });
190
+ // The durable clean receipt must follow the owned physical stop barrier.
191
+ await stopProcessGroup(this.child);
173
192
  await this.persist({ active_tree: false, shutdown_state: "stopping" });
174
193
  return { stopped: true, clean: true, _shutdown: true };
175
194
  }
@@ -21,7 +21,7 @@ export class OpenCodeClient {
21
21
  } catch (error) {
22
22
  if (error instanceof OpenCodeError) throw error;
23
23
  // Aborting HTTP only loses the observer; it does not cancel the provider.
24
- throw new OpenCodeError(error.name === "AbortError" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname });
24
+ throw new OpenCodeError(error.name === "AbortError" || method === "POST" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname });
25
25
  } finally { clearTimeout(timer); }
26
26
  }
27
27
  health() { return this.request("GET", "/global/health", undefined, { directory: null, timeoutMs: 5_000 }); }
@@ -857,25 +857,34 @@ export async function promptSessionWithBridge(bridge, options) {
857
857
  const stopReason = typeof turn?.stopReason === "string" ? turn.stopReason : null;
858
858
  const completionEvidence = turn?._meta?.zcodeCompletionEvidence;
859
859
  if (completionEvidence === "inferred" || completionEvidence === "failed") {
860
- await bridge.flush();
860
+ let observationError = null;
861
+ try { await bridge.flush(); } catch (error) { observationError = { code: error.code ?? "zcode_post_turn_observation_failed", message: error.message ?? String(error) }; }
861
862
  const error = new Error(`ZCode completion is ${completionEvidence}, not a confirmed successful native turn`);
862
863
  error.code = completionEvidence === "inferred" ? "native_outcome_unknown" : "zcode_turn_not_completed";
863
864
  error.retryable = false;
864
- error.details = { completion_evidence: completionEvidence, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId };
865
+ error.details = { completion_evidence: completionEvidence, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...(observationError ? { observation_error: observationError } : {}) };
865
866
  throw error;
866
867
  }
867
868
  if (stopReason && stopReason !== "end_turn") {
868
869
  // A close can settle the pending prompt before its terminal event. Do not
869
870
  // collect live evidence first: ordinary inspection is allowed to recover
870
871
  // an evicted Session, which would undo the just-confirmed close.
871
- await bridge.flush();
872
+ let observationError = null;
873
+ try { await bridge.flush(); } catch (error) { observationError = { code: error.code ?? "zcode_post_turn_observation_failed", message: error.message ?? String(error) }; }
872
874
  const error = new Error(`ZCode ended the provider request with ${stopReason}, not end_turn`);
873
875
  error.code = stopReason === "cancelled" ? "zcode_turn_cancelled" : "zcode_turn_not_completed";
874
- error.details = { stop_reason: stopReason, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId };
876
+ error.details = { stop_reason: stopReason, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...(observationError ? { observation_error: observationError } : {}) };
877
+ throw error;
878
+ }
879
+ let evidence;
880
+ try { await bridge.flush(); evidence = await inspect(bridge, identity.adapterSessionId); }
881
+ catch (cause) {
882
+ const error = new Error("ZCode completed the native turn, but post-turn observation failed");
883
+ error.code = "native_outcome_observation_failed";
884
+ error.retryable = false;
885
+ error.details = { native_outcome: { stop_reason: stopReason ?? "end_turn", completion_evidence: completionEvidence ?? "native" }, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, observation_error: { code: cause.code ?? "zcode_post_turn_observation_failed", message: cause.message ?? String(cause) } };
875
886
  throw error;
876
887
  }
877
- await bridge.flush();
878
- const evidence = await inspect(bridge, identity.adapterSessionId);
879
888
  evidence.tool_calls = bridge.toolSummary(identity.adapterSessionId);
880
889
  const usage_ingest_error = await forwardUsageBestEffort(options, identity.providerSessionId, evidence.usage, evidence.tool_calls);
881
890
  if (initial_usage_error) evidence.initial_usage_error = initial_usage_error;
@@ -64,6 +64,13 @@ export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurre
64
64
  assertCurrent();
65
65
  if (result?.ok === true && result.admitted === true && result.process_id === record.id) return;
66
66
  if (result?.ok !== true || result.reason !== "provider_capacity" || result.process_id !== record.id) throw Object.assign(new Error("productive runtime admission was not confirmed"), { code: "process_ownership_unknown" });
67
+ const predecessor = result.blocking_turns?.find(turn => turn?.process_id === record.id
68
+ && turn?.session_id === dispatch["native-session-id"]
69
+ && turn?.operation_id !== dispatch["operation-id"]);
70
+ if (predecessor) throw Object.assign(new Error("the same managed Session has an unsettled productive predecessor; reconcile it before dispatching another Turn"), {
71
+ code: "settlement_reconciliation_required",
72
+ details: { session_id: dispatch["native-session-id"], predecessor_operation_id: predecessor.operation_id, predecessor_operation: predecessor.operation }
73
+ });
67
74
  // The daemon, not its disposable socket client, owns this queue. Recheck
68
75
  // the RUN fence and process lease on every admission attempt.
69
76
  await new Promise(resolve => setTimeout(resolve, 250));
@@ -12,7 +12,7 @@ export function assertNoCorrelatedRejection(context, scope) {
12
12
  const latest = new Map();
13
13
  for (const row of rows) {
14
14
  const value = JSON.parse(row.outcome_json);
15
- latest.set(value.operation, value);
15
+ latest.set(JSON.stringify([value.operation, value.session_id ?? null, value.work_id ?? null, value.attempt ?? null]), value);
16
16
  }
17
17
  for (const value of latest.values())
18
18
  if (value.error && !isRegisteredRepair(context, value.error.details.continuation, scope))
@@ -619,7 +619,8 @@ export function handleGrokEvent(context, input) {
619
619
  const daemonId = stringValue(ddGrok.daemonId);
620
620
  const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
621
621
  const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
622
- harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
622
+ harness: "grok-acp", providerSessionId, toolUseId: hook.tool_use_id ?? hook.toolUseId ?? null,
623
+ promptId: hook.prompt_id ?? hook.promptId ?? null, command, turn: hook.turn_id ?? hook.turnId ?? null
623
624
  })).digest("hex");
624
625
  assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
625
626
  const payload = {
@@ -740,6 +741,12 @@ export function handleAgyEvent(context, input) {
740
741
  const toolName = stringValue(event.tool);
741
742
  if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
742
743
  throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
744
+ const rawInput = objectRecord(event.input);
745
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
746
+ if (event.phase === "before" && command && (lifecycleFacts(command) || continuationCommand(command))
747
+ && (!Number.isInteger(event.execution_num) || !Number.isInteger(event.step_index))) {
748
+ throw new AppError("agy_native_call_identity_missing", "Participating Antigravity lifecycle calls require native execution and step identity", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
749
+ }
743
750
  const translated = {
744
751
  schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
745
752
  daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
@@ -874,7 +874,11 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
874
874
  return null;
875
875
  const parsed = parseLifecycleCommand(command);
876
876
  const eventKey = parsed.kind === "standalone" ? commandOption(parsed.invocation, "hook-event-id") : undefined;
877
- const matches = context.db.all("SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL", [daemonId]).filter(row => {
877
+ const matches = context.db.all(daemonId
878
+ ? "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL"
879
+ : eventKey
880
+ ? "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE event_key = ? AND status = 'observed' AND outcome_json IS NULL"
881
+ : "SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE harness IN ('antigravity-cli','codex-desktop') AND status = 'observed' AND outcome_json IS NULL", daemonId || eventKey ? [daemonId ?? eventKey] : []).filter(row => {
878
882
  if (eventKey && row.event_key !== eventKey)
879
883
  return false;
880
884
  try {
@@ -527,30 +527,40 @@ async function executeController(context, row, manifest, state, assertOwnership,
527
527
  if (failure)
528
528
  throw failure.reason;
529
529
  };
530
+ const reconcilePromptPredecessor = async (session) => {
531
+ const prior = state.last_receipt?.settlement;
532
+ if (!prior || typeof prior !== "object" || Array.isArray(prior) || !["pending", "blocked"].includes(String(prior.state)))
533
+ return;
534
+ const receipt = await call(session, ["session", "inspect", "--session-id", session.id, ...sessionArguments(context, manifest, session)], false);
535
+ state.last_receipt = receipt;
536
+ saveState(context, row, state);
537
+ const current = receipt.settlement;
538
+ if (current && typeof current === "object" && !Array.isArray(current) && ["pending", "blocked"].includes(String(current.state))) {
539
+ throw new AppError("settlement_reconciliation_required", "The managed Session still has an unsettled productive predecessor", 1, { session_id: session.id, settlement: current });
540
+ }
541
+ };
530
542
  const prompt = async (session, text, operationId) => {
543
+ await reconcilePromptPredecessor(session);
531
544
  const file = path.join(row.state_dir, `turn-${String(state.turns + 1).padStart(5, "0")}.md`);
532
545
  fs.writeFileSync(file, `${text}\n`, { flag: "wx", mode: 0o600 });
533
546
  state.turns += 1;
534
547
  saveState(context, row, state);
535
548
  let receipt;
549
+ const adapter = controllerAdapter(executionContext, row, manifest, session, ["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], assertOwner, operationId, cancellation.signal);
536
550
  try {
537
- const adapter = controllerAdapter(executionContext, row, manifest, session, ["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], assertOwner, operationId, cancellation.signal);
538
- try {
539
- receipt = await adapter;
540
- checkOutcomes();
541
- if (fatal)
542
- throw fatal;
543
- }
544
- catch (error) {
545
- if (!fatal && error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale", "controller_owner_mismatch"].includes(error.code))
546
- throw error;
547
- await stopAfterFailure(error);
548
- cancellation.abort();
549
- await adapter.catch(() => { });
550
- throw fatal ?? error;
551
- }
551
+ receipt = await adapter;
552
+ checkOutcomes();
553
+ if (fatal)
554
+ throw fatal;
555
+ }
556
+ catch (error) {
557
+ if (!fatal && error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale", "controller_owner_mismatch"].includes(error.code))
558
+ throw error;
559
+ await stopAfterFailure(error);
560
+ cancellation.abort();
561
+ await adapter.catch(() => { });
562
+ throw fatal ?? error;
552
563
  }
553
- finally { /* Adapter settlement is joined before returning. */ }
554
564
  state.last_receipt = receipt;
555
565
  saveState(context, row, state);
556
566
  return receipt;
@@ -328,10 +328,15 @@ export function reserveProviderTurn(context, record, input) {
328
328
  db.exec("COMMIT");
329
329
  return { admitted: true, budget_scope: budget.scope_id, reused: true };
330
330
  }
331
- const active = db.get("SELECT count(*) AS count FROM managed_resources WHERE resource_kind = 'provider-turn' AND json_extract(metadata_json, '$.scope_id') = ? AND json_extract(metadata_json, '$.harness') = ?", [budget.scope_id, harness]).count;
331
+ const activeTurns = db.all("SELECT process_id, metadata_json FROM managed_resources WHERE resource_kind = 'provider-turn' AND json_extract(metadata_json, '$.scope_id') = ? AND json_extract(metadata_json, '$.harness') = ? ORDER BY created_at, resource_key", [budget.scope_id, harness]);
332
+ const active = activeTurns.length;
332
333
  if (active >= limit) {
334
+ const blocking_turns = activeTurns.map(turn => {
335
+ const claim = JSON.parse(turn.metadata_json);
336
+ return { process_id: turn.process_id, operation_id: claim.operation_id ?? null, operation: claim.operation ?? null, session_id: claim.session_id ?? null };
337
+ });
333
338
  db.exec("COMMIT");
334
- return { admitted: false, reason: "provider_capacity", budget_scope: budget.scope_id, harness, limit, active };
339
+ return { admitted: false, reason: "provider_capacity", budget_scope: budget.scope_id, harness, limit, active, blocking_turns };
335
340
  }
336
341
  db.run("INSERT INTO managed_resources (resource_kind, resource_key, owner_id, lease_token, lease_expires_at, process_id, metadata_json, created_at, updated_at) VALUES ('provider-turn', ?, ?, ?, ?, ?, ?, ?, ?)", [key, record.owner_id, record.lease_token, record.lease_expires_at, record.id, JSON.stringify({ scope_id: budget.scope_id, harness, operation_id: input.operationId, operation: input.operation, session_id: input.sessionId ?? null, state_dir: metadata.state_dir }), now, now]);
337
342
  db.exec("COMMIT");
@@ -527,8 +527,9 @@ function coordinatorPrompt(context, input) {
527
527
  "",
528
528
  "<verification_contract>",
529
529
  "```json",
530
- JSON.stringify({ schema_id: "dd-flow/code-verification@2", verdict: "passed | needs_repair | blocked", summary: "Evidence-backed conclusion.", unresolved: [], deviations: [] }, null, 2),
530
+ JSON.stringify({ schema_id: "dd-flow/code-verification@2", verdict: "passed", summary: "Evidence-backed conclusion.", unresolved: [], deviations: [] }, null, 2),
531
531
  "```",
532
+ "Allowed verdict values are passed, needs_repair, and blocked. The example is valid JSON using passed; choose exactly one value.",
532
533
  "The CLI verifies graph coverage and executes all deterministic checks. Do not duplicate its report; record only your semantic conclusion and evidence.",
533
534
  "</verification_contract>",
534
535
  "",
@@ -481,7 +481,7 @@ function resultTemplate(obligations = []) {
481
481
  }
482
482
  function renderPrompt(input) {
483
483
  const obligationList = input.obligations.map((obligation) => `- ${obligation.id}: ${obligation.statement}`).join("\n");
484
- return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(), null, 2)}\n\`\`\``, "Allocate every supplied R-* and AC-* exactly once in obligation_ownership. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, "</output_contract>", ""].join("\n");
484
+ return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(input.obligations), null, 2)}\n\`\`\``, "obligation_id values and their one-per-obligation entries are prefilled by the CLI. Preserve those IDs exactly; change only member_keys when semantic ownership requires it. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, "</output_contract>", ""].join("\n");
485
485
  }
486
486
  function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, stageRoot) {
487
487
  const receiptPath = path.join(stageRoot, "workspace-route.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.95",
3
+ "version": "0.9.0-beta.96",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {