@deksden-com/dd-flow-cli 0.9.0-beta.52 → 0.9.0-beta.53

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.53
4
+
5
+ ### Patch Changes
6
+
7
+ - Admit managed ZCode lifecycle commands through durable invocation receipts, with exact native identity, bounded waits, retained outcomes and explicit validated retries. Preserve recovery generations, cooperative settlement, immutable recovery evidence and scoped snapshots. Keep Grok native hook responses and stable parent identity, and qualify the installed ZCode native event contract without requiring child hooks. Use Node 26 for the guarded release workflow.
8
+
3
9
  ## 0.9.0-beta.52
4
10
 
5
11
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.52",
4
- "cli_commit": "9f8e20f5aab016e2e7246b8939af9e4ad91e37dc",
5
- "built_at": "2026-09-12T14:24:25.044Z",
3
+ "cli_version": "0.9.0-beta.53",
4
+ "cli_commit": "db14065f43f320bc69dacbec425cd04eac8236ae",
5
+ "built_at": "2026-09-13T02:52:44.434Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.0",
8
8
  "commit": "ef349bf47cba1c987468e51d73a0dbadbd48dc1f",
package/dist/cli/help.js CHANGED
@@ -583,17 +583,27 @@ handler resolves the registered project from cwd. The option is only a manual
583
583
  override.
584
584
 
585
585
  Managed homes isolate dd-flow hooks while sharing selected Codex state from the source home.`
586
+ ],
587
+ [
588
+ "zcode hook",
589
+ `dd-flow zcode hook - handle a synchronous native ZCode PreToolUse callback
590
+
591
+ Usage:
592
+ dd-flow zcode hook handle --project-root <root> --json
593
+
594
+ The managed ZCode native hook supplies the callback on stdin. It records the
595
+ receipt before returning a rewritten tool input with --hook-event-id.`
586
596
  ],
587
597
  [
588
598
  "zcode event",
589
- `dd-flow zcode event - ingest a zcode-acp session/update notification
599
+ `dd-flow zcode event - ingest a zcode-acp session/update notification as evidence
590
600
 
591
601
  Usage:
592
602
  dd-flow zcode event handle --project-root <root> --json
593
603
  dd-flow zcode usage ingest --project-root <root> --json
594
604
 
595
- Both payloads are read from stdin. The event adapter retains lifecycle identity;
596
- the usage adapter retains cumulative provider counters for RUN accounting.`
605
+ Both payloads are read from stdin. Native hooks own lifecycle admission; ACP
606
+ events and usage retain secondary evidence for RUN accounting.`
597
607
  ],
598
608
  [
599
609
  "droid event",
@@ -1,5 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { quote } from "shell-quote";
4
+ import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, issueLifecycleInvocation, isRetryableLifecycleRejection, lifecycleInvocationScope, retryLifecycleInvocationCommand, settleLifecycleInvocation } from "../services/lifecycle-invocations.js";
3
5
  import { createContext, createRouterContext } from "../runtime/context.js";
4
6
  import { migrateStoreWriter } from "../storage/writer-migration.js";
5
7
  import { helpForArgs } from "./help.js";
@@ -11,7 +13,7 @@ import { blockPlanItem, completePlanItem, getPlanStatus, skipPlanItem, startPlan
11
13
  import { cancelMergeQueueJob, claimNextMergeJob, claimMergeJob, claimMergeBundle, completeMergeBundle, completeMergeJob, failMergeBundle, failMergeJob, getMergeBundleStatus, getMergeQueueStatus, noteMergeJob, waitNextMergeJob } from "../services/merge-queue.js";
12
14
  import { getMergeWorkerStatus, oneShotMergeClaim, startMergeWorker, stopProjectMergeWorker } from "../services/merge-worker.js";
13
15
  import { cleanupApply, cleanupScan } from "../services/cleanup.js";
14
- import { getCodexHomeStatus, getCodexHooksStatus, handleCodexHook, handleAgyEvent, handleDroidEvent, handleGrokEvent, handleOpenCodeEvent, handleZcodeEvent, isolatedDdFlowHomeFromHookStdin, claimBootstrapHookEvent, findRecentMatchingHookEvent, stageStartMatchKey, initCodexHome, installCodexHooks, planCodexHome, printCodexHomeEnv, printCodexHooks, removeCodexHome, removeCodexHooks } from "../services/hooks.js";
16
+ import { getCodexHomeStatus, getCodexHooksStatus, handleCodexHook, handleAgyEvent, handleDroidEvent, handleGrokEvent, handleOpenCodeEvent, handleZcodeHook, handleZcodeEvent, isolatedDdFlowHomeFromHookStdin, claimBootstrapHookEvent, findRecentMatchingHookEvent, stageStartMatchKey, initCodexHome, installCodexHooks, planCodexHome, printCodexHomeEnv, printCodexHooks, removeCodexHome, removeCodexHooks } from "../services/hooks.js";
15
17
  import { bootstrapWorktree, closeWorktree, createWorktreeRecord, getWorktreeStatus, planWorktree } from "../services/worktrees.js";
16
18
  import { acquireLaneLock, cancelLaneWaiter, checkLaneWorkspace, getLaneWaiters, getLaneStatus, heartbeatLaneLock, releaseLaneLock, setLaneWorkspace, waitAcquireLaneLock, waitForLaneLock } from "../services/lanes.js";
17
19
  import { getProjectConfigStatus, setProjectConfigValue } from "../services/config.js";
@@ -73,6 +75,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
73
75
  const output = parseOutputOptions(args);
74
76
  const progress = progressForCommand(output.args);
75
77
  let heartbeat;
78
+ let invocationSettlement;
76
79
  try {
77
80
  if (output.args.length === 1 && output.args[0] === "--version") {
78
81
  const payload = getCliVersionReport();
@@ -115,9 +118,37 @@ export async function runCli(args, io = defaultIo, env = process.env) {
115
118
  }
116
119
  const dispatchContext = routed?.inProcess
117
120
  ? createContext({ ...env, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_ENGINE_HOME: "in-process", DD_FLOW_ROUTED_FROM: getCliVersionReport().cli.version }, databaseModeForCommand(output.args, classification))
118
- : createContext(env, databaseModeForCommand(output.args, classification));
121
+ : createContext({ ...env }, databaseModeForCommand(output.args, classification));
119
122
  preflightCliCompatibility(dispatchContext, output.args, env, scopeProjectRoot);
120
- const result = await dispatch(output.args, dispatchContext, io, scopeProjectRoot, classification, output);
123
+ dispatchContext.env.DD_FLOW_INVOCATION_READONLY = classification.mode === "read_only_diagnostics" ? "1" : undefined;
124
+ let dispatchArgs = output.args;
125
+ let retainedOutcome;
126
+ const invocationId = optionalOption(parseArgs(output.args.slice(2)), "invocation-id");
127
+ if (invocationId) {
128
+ const command = quote(["dd-flow", ...output.args]);
129
+ const admission = await awaitLifecycleInvocation(dispatchContext, { id: invocationId, command,
130
+ assertCurrent: scope => assertLifecycleInvocationCurrent(dispatchContext, scope, command) });
131
+ if (admission.replay)
132
+ retainedOutcome = admission.outcome;
133
+ else {
134
+ invocationSettlement = { context: dispatchContext, id: invocationId };
135
+ dispatchArgs = [...output.args, "--hook-event-id", admission.eventKey];
136
+ dispatchContext.env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(lifecycleInvocationScope(dispatchContext, invocationId));
137
+ dispatchContext.env.DD_FLOW_CURRENT_INVOCATION = invocationId;
138
+ }
139
+ }
140
+ if (retainedOutcome?.error) {
141
+ const error = retainedOutcome.error;
142
+ throw new AppError(error.code, error.message, error.exitCode, error.details);
143
+ }
144
+ const result = retainedOutcome ? retainedOutcome.result : await dispatch(dispatchArgs, dispatchContext, io, scopeProjectRoot, classification, output);
145
+ if (invocationSettlement) {
146
+ const completed = invocationSettlement;
147
+ invocationSettlement = undefined;
148
+ // A failure to persist an already completed result is an unknown reply,
149
+ // not a failed operation eligible for a new productive retry.
150
+ settleLifecycleInvocation(completed.context, completed.id, { result });
151
+ }
121
152
  const refreshWarnings = refreshDashboardsAfterMutation(dispatchContext, output.args, result, scopeProjectRoot);
122
153
  const resultWithWarnings = addWarnings(result, refreshWarnings);
123
154
  if (heartbeat)
@@ -136,6 +167,27 @@ export async function runCli(args, io = defaultIo, env = process.env) {
136
167
  catch (error) {
137
168
  if (heartbeat)
138
169
  clearInterval(heartbeat);
170
+ if (invocationSettlement) {
171
+ // Optional retry preparation must never prevent recording the primary
172
+ // operation rejection. Otherwise a known failure becomes an unknown run.
173
+ if (isAppError(error) && isRetryableLifecycleRejection(error.code)) {
174
+ try {
175
+ error.details.retry_command = retryLifecycleInvocationCommand(invocationSettlement.context, invocationSettlement.id);
176
+ error.details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
177
+ }
178
+ catch (retryError) {
179
+ error.details.retry_preparation_error = String(retryError);
180
+ }
181
+ }
182
+ try {
183
+ settleLifecycleInvocation(invocationSettlement.context, invocationSettlement.id, { error: isAppError(error)
184
+ ? { code: error.code, message: error.message, exitCode: error.exitCode, details: error.details }
185
+ : { code: "unexpected", message: String(error), exitCode: 1, details: {} } });
186
+ }
187
+ catch (persistenceError) {
188
+ io.stderr.write(`Invocation settlement persistence failed: ${String(persistenceError)}\n`);
189
+ }
190
+ }
139
191
  const payload = isAppError(error)
140
192
  ? { ok: false, error: { code: error.code, message: error.message, details: error.details } }
141
193
  : { ok: false, error: { code: "unexpected", message: String(error) } };
@@ -520,6 +572,13 @@ function shortSha(value) {
520
572
  async function dispatch(args, context, io, scopeProjectRoot = null, classification = classifyCliOperation(args, context.env), output = parseOutputOptions(args)) {
521
573
  const [family, command, ...rest] = args;
522
574
  const parsed = parseArgs(rest);
575
+ if (family === "zcode" && command === "invocation" && parsed.positional[0] === "prepare") {
576
+ const payload = JSON.parse(await readStdin(io.stdin));
577
+ if (typeof payload.command !== "string" || !payload.scope || typeof payload.scope !== "object")
578
+ throw new AppError("usage", "Expected command and managed scope on stdin", 2);
579
+ assertLifecycleInvocationCurrent(context, payload.scope, payload.command);
580
+ return { ok: true, ...issueLifecycleInvocation(context, payload.command, payload.scope) };
581
+ }
523
582
  if (family === "storage" && command === "migrate-writer") {
524
583
  if (parsed.positional.length || [...parsed.options.keys()].some(key => !["database", "backup", "offline"].includes(key)) || [...parsed.options.values()].some(values => values.length !== 1))
525
584
  throw new AppError("usage", "Usage: dd-flow storage migrate-writer --database ABSOLUTE_DB --backup NEW_ABSOLUTE_FILE --offline", 2);
@@ -1146,6 +1205,9 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1146
1205
  if (family === "codex" && command === "hook") {
1147
1206
  return dispatchCodexHook(context, parsed, await readStdin(io.stdin));
1148
1207
  }
1208
+ if (family === "zcode" && command === "hook") {
1209
+ return dispatchZcodeHook(context, parsed, await readStdin(io.stdin));
1210
+ }
1149
1211
  if (family === "zcode" && command === "event") {
1150
1212
  return dispatchZcodeEvent(context, parsed, await readStdin(io.stdin));
1151
1213
  }
@@ -2249,6 +2311,15 @@ function dispatchZcodeEvent(context, parsed, stdin) {
2249
2311
  }
2250
2312
  throw new AppError("usage", `Unknown zcode event action: ${action}`, 2);
2251
2313
  }
2314
+ function dispatchZcodeHook(context, parsed, stdin) {
2315
+ if (requiredPosition(parsed, 0, "zcode hook action") !== "handle")
2316
+ throw new AppError("usage", "Unknown zcode hook action", 2);
2317
+ const result = handleZcodeHook(context, { projectRoot: requiredOption(parsed, "project-root"), stdin });
2318
+ if (result && typeof result === "object" && !Array.isArray(result) && "hookSpecificOutput" in result) {
2319
+ return { hookSpecificOutput: result.hookSpecificOutput };
2320
+ }
2321
+ return {};
2322
+ }
2252
2323
  function dispatchGrokEvent(context, parsed, stdin) {
2253
2324
  const action = requiredPosition(parsed, 0, "grok event action");
2254
2325
  if (action === "handle") {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { durableDaemonStart, inspectDaemonOperation } from "../lib/daemon-operations.mjs";
3
- import { spawn } from "node:child_process";
3
+ import { invokeNativeHook } from "../lib/native-hook-command.mjs";
4
4
  import { cancelSession, createSession, doctor, forkSession, inspectSession, promptFromFile, promptSession } from "../lib/dd-grok.mjs";
5
5
  import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-grok-daemon.mjs";
6
6
 
@@ -9,9 +9,8 @@ function common(options) { return { bin: options["grok-bin"], journal: options.j
9
9
  async function readStdin() { let value = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) value += chunk; return value; }
10
10
  async function hook(options) {
11
11
  const stdin = await readStdin(); const payload = JSON.parse(stdin || "{}"); const identity = await callDaemon(options["state-dir"], "hook.resolve", { sessionId: payload.sessionId ?? payload.session_id });
12
- const enriched = { ...payload, _meta: { ...(payload._meta ?? {}), ddGrok: identity } }; const args = ["grok", "event", "handle", "--project-root", options["project-root"], "--json"]; const command = options["dd-flow-bin"]; const target = /\.[cm]?js$/.test(command) ? { command: process.execPath, args: [command, ...args] } : { command, args };
13
- await new Promise((resolve, reject) => { const child = spawn(target.command, target.args, { env: { ...process.env, DD_FLOW_HOME: options["dd-flow-home"] }, stdio: ["pipe", "ignore", "pipe"] }); let stderr = ""; child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); child.on("error", reject); child.on("close", (code) => code === 0 ? resolve() : reject(new Error(stderr || "dd-flow rejected Grok hook"))); child.stdin.end(`${JSON.stringify(enriched)}\n`); });
14
- return { decision: "allow" };
12
+ const enriched = { ...payload, _meta: { ...(payload._meta ?? {}), ddGrok: identity } }; const args = ["grok", "event", "handle", "--project-root", options["project-root"], "--json"]; const command = options["dd-flow-bin"];
13
+ return invokeNativeHook({ bin: command, args, home: options["dd-flow-home"], payload: enriched });
15
14
  }
16
15
 
17
16
  try {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { durableDaemonStart, inspectDaemonOperation } from "../lib/daemon-operations.mjs";
3
+ import { invokeNativeHook } from "../lib/native-hook-command.mjs";
3
4
  import { cancelChild, cancelSession, createSession, doctor, forkSession, inspectSession, promptFromFile, promptSession } from "../lib/dd-zcode.mjs";
4
5
  import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-zcode-daemon.mjs";
5
6
 
@@ -29,6 +30,15 @@ function common(options) {
29
30
  };
30
31
  }
31
32
 
33
+ async function readStdin() { let value = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) value += chunk; return value; }
34
+ async function hook(options) {
35
+ const payload = JSON.parse((await readStdin()) || "{}");
36
+ const identity = await callDaemon(options["state-dir"], "hook.resolve", { sessionId: payload.session_id ?? payload.sessionId });
37
+ const command = options["dd-flow-bin"]; const args = ["zcode", "hook", "handle", "--project-root", options["project-root"], "--json"];
38
+
39
+ return invokeNativeHook({ bin: command, args, home: options["dd-flow-home"], payload: { ...payload, _meta: { ...(payload._meta ?? {}), ddZcode: identity } } });
40
+ }
41
+
32
42
  try {
33
43
  const { positional, options } = parse(process.argv.slice(2)); const [family, command] = positional; let result;
34
44
  const answers = options["answers-file"] ? JSON.parse(await promptFromFile(options["answers-file"])) : undefined;
@@ -39,6 +49,7 @@ try {
39
49
  else if (family === "daemon" && command === "status") result = await callDaemon(shared.stateDir, "daemon.status");
40
50
  else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(shared.stateDir, options["operation-id"]);
41
51
  else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: shared.stateDir, cancelTree: options["cancel-tree"], timeoutMs: shared.timeoutMs });
52
+ else if (family === "hook" && command === "handle") { process.stdout.write(`${JSON.stringify(await hook(options))}\n`); process.exit(0); }
42
53
  else if (family === "session") {
43
54
  const params = { ...shared, answers, ...(command === "create" || command === "prompt" ? { prompt: options["prompt-file"] ? await promptFromFile(options["prompt-file"]) : options.prompt } : {}), ...(command === "fork" ? { target: JSON.parse(options["target-json"] ?? "null") } : {}) };
44
55
  // promptSessionWithBridge owns the meaningful deadline: it fails when its
@@ -56,8 +67,9 @@ try {
56
67
  else if (command === "cancel-child") result = await cancelChild(params);
57
68
  else if (command === "fork") result = await forkSession(params);
58
69
  else throw new Error(`unknown session command: ${command}`);
59
- } else throw new Error("usage: dd-zcode doctor | daemon start|status|stop | session create|prompt|inspect|status|resume|cancel|cancel-child|fork [options] --json");
70
+ } else throw new Error("usage: dd-zcode doctor | daemon start|status|stop | hook handle | session create|prompt|inspect|status|resume|cancel|cancel-child|fork [options] --json");
60
71
  process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`);
61
72
  } catch (error) {
73
+ if (process.argv[2] === "hook") { process.stdout.write(`${JSON.stringify({ decision: "deny", reason: error.message })}\n`); process.exit(2); }
62
74
  process.stderr.write(`${JSON.stringify({ ok: false, error: error.message, ...(error.code ? { code: error.code, retryable: error.retryable === true, details: error.details } : {}) })}\n`); process.exit(1);
63
75
  }
@@ -73,25 +73,10 @@ async function writeHook(paths, config, entryPath) {
73
73
  }
74
74
 
75
75
  async function observeAcpToolCall(config, identity, message) {
76
- if (!config.ddFlowBin) return;
77
- const update = message?.params?.update;
78
- const sessionId = message?.params?.sessionId;
79
- const command = update?.rawInput?.command ?? update?.rawInput?.cmd;
80
- if (update?.sessionUpdate !== "tool_call" || !sessionId || typeof command !== "string" || !command.includes("dd-flow")) return;
81
- const eventId = update?._meta?.eventId ?? `${sessionId}:${update.toolCallId ?? createHash("sha256").update(command).digest("hex")}`;
82
- const payload = {
83
- hook_event_name: "PreToolUse",
84
- tool_name: update?._meta?.["x.ai/tool"]?.name ?? "run_terminal_command",
85
- tool_input: { command }, session_id: sessionId, event_id: eventId,
86
- _meta: { ddGrok: identity }
87
- };
88
- const target = executable(config.ddFlowBin, ["grok", "event", "handle", "--project-root", config.projectRoot, "--json"]);
89
- await new Promise((resolve, reject) => {
90
- const child = spawn(target.command, target.args, { env: { ...process.env, DD_FLOW_HOME: config.ddFlowHome }, stdio: ["pipe", "ignore", "pipe"] }); let stderr = "";
91
- child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); child.on("error", reject);
92
- child.on("close", (code) => code === 0 ? resolve() : reject(new DaemonError("grok_hook_failed", stderr || "dd-flow rejected Grok ACP tool event")));
93
- child.stdin.end(`${JSON.stringify(payload)}\n`);
94
- });
76
+ // Grok's synchronous native hook owns admission. This ACP mirror is
77
+ // observational only; forwarding it used to create a second receipt after
78
+ // the command had already run.
79
+ void config; void identity; void message;
95
80
  }
96
81
 
97
82
  async function copyAuth(paths, source) {
@@ -151,6 +151,9 @@ export async function startDaemon(options) {
151
151
  }
152
152
  await removeSocket(paths.socket, paths.dir);
153
153
  const versions = await doctor({ bin: config.bin, zcodePath: config.zcodePath });
154
+ if (hasFlowContext && versions.compatible !== true) {
155
+ throw new DaemonError("zcode_lifecycle_unqualified", "ZCode native event identity is not qualified for managed CLI admission; inspect lifecycle_qualification before starting E2E.", false, { observed_runtime: versions.observed_runtime, lifecycle_qualification: versions.lifecycle_qualification });
156
+ }
154
157
  const resourceProcess = await registerDaemonProcess(config, {
155
158
  kind: "zcode-daemon", owner: `zcode:${path.basename(paths.dir)}`, operation: `zcode-daemon:${path.basename(paths.dir)}`,
156
159
  stdout: paths.log, stderr: paths.log,
@@ -229,11 +232,18 @@ export class DaemonRuntime {
229
232
  }
230
233
 
231
234
  track(result, topology = result?.evidence?.subagents) {
232
- if (result?.provider_session_id) { const existing = this.sessions.get(result.provider_session_id); this.sessions.set(result.provider_session_id, {
235
+ if (result?.provider_session_id) { const existing = this.sessions.get(result.provider_session_id); const suppliedParent = result.parent_provider_session_id;
236
+ if (existing && suppliedParent !== undefined && suppliedParent !== existing.parent_provider_session_id) {
237
+ throw new DaemonError("session_ancestry_conflict", "ZCode Session parent changed after it was recorded", false, { session_id: result.provider_session_id, expected_parent: existing.parent_provider_session_id, actual_parent: suppliedParent });
238
+ }
239
+ const parent = suppliedParent ?? existing?.parent_provider_session_id ?? null;
240
+ const parentRoot = parent ? this.sessions.get(parent)?.root_provider_session_id ?? null : null;
241
+ this.sessions.set(result.provider_session_id, {
233
242
  provider_session_id: result.provider_session_id,
234
- adapter_session_id: result.adapter_session_id ?? result.provider_session_id,
235
- parent_provider_session_id: result.parent_provider_session_id ?? null,
236
- topology: topology ?? null,
243
+ adapter_session_id: result.adapter_session_id ?? existing?.adapter_session_id ?? result.provider_session_id,
244
+ parent_provider_session_id: parent,
245
+ root_provider_session_id: existing?.root_provider_session_id ?? (parent ? parentRoot : result.provider_session_id),
246
+ topology: topology ?? existing?.topology ?? null,
237
247
  completed_turns: existing?.completed_turns ?? 0,
238
248
  }); }
239
249
  if (result?.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
@@ -289,6 +299,12 @@ export class DaemonRuntime {
289
299
  active_tree: this.state.active_tree, active_operation: this.activeProductive,
290
300
  sessions: [...this.sessions.values()], config: this.state.config,
291
301
  };
302
+ if (operation === "hook.resolve") {
303
+ const sessionId = String(params.sessionId ?? "");
304
+ const session = this.sessions.get(sessionId);
305
+ if (!session?.root_provider_session_id) throw new DaemonError("hook_identity_unknown", "ZCode hook Session is not a tracked controlled Session", false, { session_id: sessionId });
306
+ 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) ?? {} };
307
+ }
292
308
  if (operation === "session.create") return await this.productive(operation, async () => await createSessionWithBridge(this.bridge, this.options(params), this.initialized));
293
309
  if (operation === "session.prompt") {
294
310
  const result = await this.productive(operation, async () => { await this.requireSettled(params); return await promptSessionWithBridge(this.bridge, this.options(params)); });
@@ -1,5 +1,7 @@
1
1
  import { observedTimeout } from "./observation-clock.mjs";
2
+ import { invokeNativeHook } from "./native-hook-command.mjs";
2
3
  import { spawn } from "node:child_process";
4
+ import { createHash } from "node:crypto";
3
5
  import { appendFile, mkdir, readFile } from "node:fs/promises";
4
6
  import path from "node:path";
5
7
  import readline from "node:readline";
@@ -53,6 +55,7 @@ class Journal {
53
55
  if (!this.file) return;
54
56
  const line = JSON.stringify({ order: ++this.order, observed_at: now(), kind, payload });
55
57
  this.pending = this.pending.then(() => appendFile(this.file, `${line}\n`));
58
+ return this.pending;
56
59
  }
57
60
  async flush() { await this.pending; }
58
61
  }
@@ -64,6 +67,7 @@ export class AcpBridge {
64
67
  this.pending = new Map();
65
68
  this.sessionActivity = new Map();
66
69
  this.notifications = Promise.resolve();
70
+ this.lifecycleNotifications = new Set();
67
71
  this.notificationError = null;
68
72
  this.bridgeStderr = "";
69
73
  this.toolCalls = new Map();
@@ -127,6 +131,16 @@ export class AcpBridge {
127
131
  this.assistantText.set(sessionId, `${this.assistantText.get(sessionId) ?? ""}${message.params.update.content.text ?? ""}`);
128
132
  }
129
133
  this.observeToolCall(message);
134
+ if (message.method === "session/update" && this.options.onLifecycleNotification) {
135
+ const observer = this.options.onLifecycleNotification;
136
+ const pending = Promise.resolve().then(() => observer(message)).catch(async error => {
137
+ this.notificationError ??= error;
138
+ await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
139
+ session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
140
+ });
141
+ this.lifecycleNotifications.add(pending);
142
+ void pending.then(() => this.lifecycleNotifications.delete(pending), () => this.lifecycleNotifications.delete(pending));
143
+ }
130
144
  if (message.id !== undefined && message.method) { void this.answer(message).catch((error) => this.rejectAll(error)); return; }
131
145
  if (message.id !== undefined) {
132
146
  const pending = this.pending.get(message.id);
@@ -148,7 +162,14 @@ export class AcpBridge {
148
162
  }
149
163
  if (message.method) this.notifications = this.notifications
150
164
  .then(async () => { await this.observeModelUpdate(message); await this.options.onNotification?.(message); })
151
- .catch((error) => { this.notificationError ??= error; if (error.code === "profile_integrity_violation") this.rejectAll(error); });
165
+ .catch(async (error) => {
166
+ const failure = { code: error.code ?? "notification_observation_failed", message: error.message, session_id: message.params?.sessionId ?? null, tool_call_id: message.params?.update?.toolCallId ?? null, event_time: now(), delivery_stage: "acp_evidence", completeness: "incomplete" };
167
+ try { await this.journal.write("notification_failure", failure); }
168
+ catch (persistenceError) { process.stderr.write(`${JSON.stringify({ ...failure, persistence_error: persistenceError.message })}\n`); }
169
+ // Evidence loss does not retroactively revoke a committed native hook.
170
+ // Permission/profile integrity is a separate, still-fatal contract.
171
+ if (error.code === "profile_integrity_violation") { this.notificationError ??= error; this.rejectAll(error); }
172
+ });
152
173
  }
153
174
 
154
175
  observeToolCall(message) {
@@ -249,7 +270,7 @@ export class AcpBridge {
249
270
  notify(method, params = {}) { this.send({ jsonrpc: "2.0", method, params }); }
250
271
  send(message) { this.journal.write("outbound", message); this.child.stdin.write(`${JSON.stringify(message)}\n`); }
251
272
  rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); pending.reject(error); } this.pending.clear(); }
252
- async flush() { await this.notifications; await this.journal.flush(); if (this.notificationError) throw this.notificationError; }
273
+ async flush() { await Promise.all([...this.lifecycleNotifications]); await this.notifications; await this.journal.flush(); if (this.notificationError) throw this.notificationError; }
253
274
  async close() {
254
275
  let flushError = null;
255
276
  try { await this.flush(); } catch (error) { flushError = error; }
@@ -437,8 +458,6 @@ async function withBridge(options, operation) {
437
458
  }
438
459
 
439
460
  function flowForwarder(options, rootProviderSessionId, bridge) {
440
- if (!options.ddFlowBin) return undefined;
441
- const projectRoot = absolute(options.projectRoot, "--project-root");
442
461
  return async (notification) => {
443
462
  if (notification.method !== "session/update") return;
444
463
  const update = notification.params?.update;
@@ -447,10 +466,52 @@ function flowForwarder(options, rootProviderSessionId, bridge) {
447
466
  const childId = notification.params?.update?._meta?.zcodeRuntime?.childSessionId;
448
467
  const observed = bridge?.modelProfiles?.get(childId ?? rootProviderSessionId) ?? {};
449
468
  if (childId && !observed.model) await observeModel({ journal: options.journal, harness: "zcode-acp", sessionId: childId, parentSessionId: rootProviderSessionId, requested: profile(options), observed, source: "zcode.child_tool_event", evidence: "unavailable" });
450
- const payload = zcodeLifecycleEnvelope(notification, { ...options, observedProfile: observed }, rootProviderSessionId);
451
- const stdout = await runWithStdin(options.ddFlowBin, ["zcode", "event", "handle", "--project-root", projectRoot, "--json"], `${JSON.stringify(payload)}\n`, options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {});
452
- const receipt = stdout ? JSON.parse(stdout) : {};
453
- if (receipt.ok === false) fail(`dd-flow rejected ZCode event: ${JSON.stringify(receipt.error ?? receipt)}`);
469
+ // Native PreToolUse has already admitted (or denied) the command. ACP is
470
+ // intentionally only journal/model evidence: its serialized delivery can
471
+ // lag behind execution and must never mint a second lifecycle receipt.
472
+ };
473
+ }
474
+
475
+ /** Preserve native tool ownership independently of slow secondary evidence.
476
+ * Rehydration uses this same session's retained inbound journal, not model text. */
477
+ export async function zcodeInvocationObserver(options, rootProviderSessionId, send = invokeNativeHook) {
478
+ if (!options.ddFlowBin) return async () => {};
479
+ const owners = new Map();
480
+ const remember = message => {
481
+ const update = message.params?.update;
482
+ if (message.method !== "session/update" || update?.sessionUpdate !== "tool_call") return null;
483
+ if (options.adapterSessionId && message.params?.sessionId !== options.adapterSessionId) return null;
484
+ const native = update._meta?.zcodeRuntime ?? {};
485
+ if (native.source === "subagent" && !native.childSessionId) throw Object.assign(new Error("Native subagent event has no physical child identity"), { code: "zcode_child_identity_missing" });
486
+ const session = native.childSessionId ?? rootProviderSessionId;
487
+ const key = update.toolCallId;
488
+ if (!key || typeof session !== "string") return null;
489
+ if (owners.has(key) && owners.get(key) !== session) throw Object.assign(new Error("Native tool id has conflicting Session ownership"), { code: "zcode_tool_identity_conflict" });
490
+ owners.set(key, session);
491
+ return { update, native, session };
492
+ };
493
+ if (options.journal) {
494
+ try {
495
+ const contents = await readFile(options.journal, "utf8");
496
+ for (const line of contents.split("\n").filter(Boolean)) {
497
+ const event = JSON.parse(line);
498
+ if (event.kind === "inbound") remember(event.payload);
499
+ }
500
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
501
+ }
502
+ return async message => {
503
+ const facts = remember(message);
504
+ if (!facts || !options.ddFlowBin) return;
505
+ const { update, native, session } = facts;
506
+ const command = update.rawInput?.command ?? update.rawInput?.cmd;
507
+ const toolName = update._meta?.claudeCode?.toolName;
508
+ if (!["Bash", "bash", "execute"].includes(toolName) || typeof command !== "string" || !/(?:^|\s)--invocation-id\s/.test(command)) return;
509
+ const parent = session === rootProviderSessionId ? null : owners.get(native.parentToolCallId);
510
+ if (session !== rootProviderSessionId && !parent) throw Object.assign(new Error("Native child has no confirmed immediate parent tool owner"), { code: "zcode_parent_identity_missing" });
511
+ await send({ bin: options.ddFlowBin, home: options.ddFlowHome,
512
+ args: ["zcode", "event", "handle", "--project-root", options.projectRoot ?? options.cwd, "--json"],
513
+ payload: { ...message, _meta: { ddZcode: { daemonId: options.daemonId, rootProviderSessionId, parentProviderSessionId: parent } } },
514
+ });
454
515
  };
455
516
  }
456
517
 
@@ -480,13 +541,26 @@ async function forwardUsage(options, providerSessionId, usage, toolCalls) {
480
541
  await runWithStdin(options.ddFlowBin, ["zcode", "usage", "ingest", "--project-root", projectRoot, "--json"], `${JSON.stringify(payload)}\n`, options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {});
481
542
  }
482
543
 
544
+ export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContract) {
545
+ // Behavioural evidence: dd-eval production-invocations probe, 2026-09-13.
546
+ // Pin both providers of native event identity, not a string in the bundle.
547
+ const qualified = sha256 === "e9f1868c0fdb863537ed910ee3828b9be96b8c2fd805473f63b439e1113266b8"
548
+ && bridgeCommit === "43f654bccdbb1aa4f4fb4617f7315c4336dbcde0" && harnessContract === ZCODE_HARNESS_CONTRACT;
549
+ return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
550
+ capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
551
+ native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
552
+ }
553
+
483
554
  export async function doctor(options = {}) {
484
555
  const zcodeAcp = await commandOutput(options.bin, ["--version"]);
485
556
  const zcodeAcpCommit = await commandOutput(options.bin, ["--dd-harness-commit"]);
486
557
  const ddHarness = await commandOutput(options.bin, ["--dd-harness-version"]);
487
558
  const zcodePath = options.zcodePath ?? process.env.ZCODE_PATH ?? "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs";
488
559
  const zcode = await commandOutput(zcodePath, ["--version"]);
489
- return { compatible: true, observed_runtime: { zcode, zcode_acp: zcodeAcp, zcode_acp_commit: zcodeAcpCommit, dd_harness_contract: ddHarness || ZCODE_HARNESS_CONTRACT } };
560
+ const native = await readFile(zcodePath);
561
+ const sha256 = createHash("sha256").update(native).digest("hex");
562
+ const qualification = zcodeLifecycleQualification(sha256, zcodeAcpCommit, ddHarness);
563
+ return { compatible: qualification.status === "qualified", lifecycle_qualification: qualification, observed_runtime: { zcode, zcode_acp: zcodeAcp, zcode_acp_commit: zcodeAcpCommit, dd_harness_contract: ddHarness } };
490
564
  }
491
565
 
492
566
  export async function createSession(options) {
@@ -527,6 +601,8 @@ export async function promptSessionWithBridge(bridge, options) {
527
601
  let forward;
528
602
  bridge.configure({ ...options, onNotification: async (event) => { await forward?.(event); await options.onNotification?.(event); } });
529
603
  const identity = await controlledIdentity(bridge, { ...options, cwd }); forward = flowForwarder(options, identity.providerSessionId, bridge);
604
+ await bridge.journal.flush();
605
+ bridge.configure({ onLifecycleNotification: await zcodeInvocationObserver({ ...options, adapterSessionId: identity.adapterSessionId }, identity.providerSessionId) });
530
606
  const profile_receipt = await applyProfile(bridge, identity.adapterSessionId, requested, options.assertDispatch);
531
607
  options.assertDispatch?.();
532
608
  await forwardUsage(options, identity.providerSessionId, await bridge.request("zcode/session/usage", { sessionId: identity.adapterSessionId }), bridge.toolSummary(identity.adapterSessionId));
@@ -0,0 +1,33 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /** Preserve the native hook protocol across the CLI process boundary. */
4
+ export async function invokeNativeHook({ bin, args, payload, home, timeoutMs = 15_000 }) {
5
+ const script = /\.[cm]?js$/.test(bin);
6
+ const output = await new Promise((resolve, reject) => {
7
+ const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
8
+ env: { ...process.env, DD_FLOW_HOME: home }, stdio: ["pipe", "pipe", "pipe"]
9
+ });
10
+ let stdout = "", stderr = "", failure;
11
+ const stop = error => { failure ??= error; child.kill("SIGKILL"); };
12
+ const timer = setTimeout(() => stop(Object.assign(new Error("Native hook admission timed out"), { code: "native_hook_timeout" })), timeoutMs);
13
+ child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; if (stdout.length > 1_048_576) stop(new Error("Native hook response exceeds protocol limit")); });
14
+ child.stderr.setEncoding("utf8").on("data", chunk => { stderr = (stderr + chunk).slice(-65_536); });
15
+ child.stdin.on("error", error => { failure ??= error; });
16
+ child.on("error", error => { clearTimeout(timer); reject(error); });
17
+ child.on("close", code => {
18
+ clearTimeout(timer);
19
+ if (failure) return reject(failure);
20
+ if (code !== 0) {
21
+ let record;
22
+ try { record = JSON.parse(stderr.trim()).error; } catch { /* Non-JSON stderr remains diagnostic evidence. */ }
23
+ return reject(Object.assign(new Error(record?.message ?? (stderr.trim() || "Native hook admission failed")), { code: record?.code ?? "native_hook_failed", details: record?.details }));
24
+ }
25
+ resolve(stdout);
26
+ });
27
+ child.stdin.end(`${JSON.stringify(payload)}\n`);
28
+ });
29
+ let result;
30
+ try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid native hook JSON response"), { code: "native_hook_response_invalid" }); }
31
+ if (!result || typeof result !== "object" || Array.isArray(result) || result.ok === false) throw Object.assign(new Error("Native hook did not return a successful protocol object"), { code: "native_hook_response_invalid" });
32
+ return result;
33
+ }
@@ -104,6 +104,8 @@ function isRouterNative(args) {
104
104
  if (first === "storage" && args[1] === "migrate-writer")
105
105
  return true;
106
106
  return (first === "codex" && args[1] === "hook" && args[2] === "handle")
107
+ || (first === "zcode" && args[1] === "hook" && args[2] === "handle")
108
+ || (first === "zcode" && args[1] === "invocation" && args[2] === "prepare")
107
109
  || (first === "zcode" && args[1] === "event" && args[2] === "handle")
108
110
  || (first === "zcode" && args[1] === "usage" && args[2] === "ingest")
109
111
  || (first === "grok" && args[1] === "event" && args[2] === "handle")
@@ -391,7 +391,7 @@ function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
391
391
  throw new AppError("snapshot_control_requires_recovery", "A fenced RUN requires explicit recovery, not a stage-entry fork", 1);
392
392
  if (context.db.get("SELECT 1 FROM merge_requests WHERE status IN ('dispatching','active','waiting_user','action_required','recovery_required') LIMIT 1"))
393
393
  throw new AppError("snapshot_execution_active", "A stage-entry fork cannot discard an unresolved MERGE execution", 1);
394
- const detachedTables = ["run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers", "run_controls", "run_controller_events", "run_controller_operations", "run_controllers", "run_recovery_bindings", "work_recovery_segments", "run_recovery_guards", "work_launches", "merge_servers", "merge_sessions", "lane_locks", "lane_waiters"].filter(hasTable);
394
+ const detachedTables = ["lifecycle_invocations", "run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers", "run_controls", "run_controller_events", "run_controller_operations", "run_controllers", "run_recovery_bindings", "work_recovery_segments", "run_recovery_guards", "work_launches", "merge_servers", "merge_sessions", "lane_locks", "lane_waiters"].filter(hasTable);
395
395
  const runTables = Object.fromEntries([...detachedTables, "runs", "merge_requests", "sessions", "work_sessions"].map(table => [table, context.db.all(`SELECT * FROM ${table}`)]));
396
396
  const resourceFile = path.join(context.ddFlowHome, "runtime.sqlite");
397
397
  const resources = fs.existsSync(resourceFile) ? new DatabaseSync(resourceFile) : null;
@@ -10,6 +10,7 @@ import { resolveExecutionPolicy, runtimeHarness, validateExecutionOverride } fro
10
10
  import { assertRunMutationAllowed } from "./run-recovery.js";
11
11
  import { assertWorkLaunchReady, workStartCommand } from "./work-registry.js";
12
12
  import { initCodexHome } from "./hooks.js";
13
+ import { managedInvocationContext } from "./lifecycle-invocations.js";
13
14
  /**
14
15
  * Creates an external provider root for one already-created Work. The launch
15
16
  * is not a Work claim: only the worker's trusted `work start` hook binds it.
@@ -48,7 +49,7 @@ export async function launchExternalWork(context, input) {
48
49
  fs.mkdirSync(root, { recursive: true });
49
50
  const now = context.now();
50
51
  const flowExecutable = prepareHarnessFlowExecutable(context, root);
51
- const env = { ...context.env, DD_FLOW_BIN: flowExecutable };
52
+ const env = { ...context.env, DD_FLOW_BIN: flowExecutable, DD_FLOW_INVOCATION_SCOPE: undefined, DD_FLOW_CURRENT_INVOCATION: undefined };
52
53
  // An external Codex root is a fresh thread, so it does not inherit the
53
54
  // coordinator's managed PreToolUse hook. Its first `work start` must emit
54
55
  // that hook event to bind the Work to the physical session.
@@ -57,7 +58,7 @@ export async function launchExternalWork(context, input) {
57
58
  initCodexHome(context, { projectRoot: run.project_root, profile: `external-${operationId}`, targetHome: codexHome, ...(context.env.CODEX_HOME ? { sourceHome: context.env.CODEX_HOME } : {}) });
58
59
  env.CODEX_HOME = codexHome;
59
60
  }
60
- const runtimeContext = { ...context, env };
61
+ let runtimeContext = { ...context, env };
61
62
  const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${work.run_id}:work:${work.work_id}`, role: "worker", project_id: work.project_id, run_id: work.run_id, work_id: work.work_id, operation_id: operationId, state_dir: root, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, work.project_id, work.run_id) };
62
63
  const executable = resolveHarnessCommand(context.ddFlowHome, runtimeHarness(resolved.worker), "adapter");
63
64
  context.db.run("INSERT INTO work_launches (operation_id, work_id, profile_json, profile_hash, status, state_dir, journal_path, provider_session_id, adapter_receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'launching', ?, ?, NULL, NULL, ?, ?)", [operationId, work.work_id, JSON.stringify({ ...resolved, runtime_adapter: { executable, owner: runtimeOwner } }), resolved.profile_hash, root, journal, now, now]);
@@ -73,6 +74,8 @@ export async function launchExternalWork(context, input) {
73
74
  throw new AppError("harness_adapter_invalid", "Harness adapter did not return a typed Session id", 1, { work_id: work.work_id, harness: runtimeHarness(resolved.worker) });
74
75
  // Retain the physical root before any subsequent guard or prompt can fail.
75
76
  context.db.run("UPDATE work_launches SET provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ create: created }), context.now(), operationId]);
77
+ runtimeContext = managedInvocationContext(runtimeContext, { harness: runtimeHarness(resolved.worker), stateDir: root, sessionId,
78
+ projectRoot: run.project_root, runId: work.run_id, generation });
76
79
  assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
77
80
  assertWorkLaunchReady(context, work.work_id);
78
81
  const promptFile = path.join(root, "launch.md");