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

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,17 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.54
4
+
5
+ ### Patch Changes
6
+
7
+ - Preserve current lifecycle command input, atomically settle retry authority, select current resume commands, and stop ZCode productive admission immediately on fatal identity errors. Reject ambiguous result inputs and keep diagnostic commands read-only.
8
+
9
+ ## 0.9.0-beta.53
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.9.0-beta.52
4
16
 
5
17
  ### 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.54",
4
+ "cli_commit": "eb2cb9746a9ee386d40f63791f5dbe0847bf5017",
5
+ "built_at": "2026-09-13T12:29:37.211Z",
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, lifecycleInvocationScope, settleLifecycleInvocation, settleLifecycleRejection } 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";
@@ -70,9 +72,18 @@ const defaultIo = {
70
72
  };
71
73
  const flagValue = "__dd_flow_flag__";
72
74
  export async function runCli(args, io = defaultIo, env = process.env) {
73
- const output = parseOutputOptions(args);
75
+ let output;
76
+ try {
77
+ output = parseOutputOptions(args);
78
+ }
79
+ catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ writeJson(io.stderr, { ok: false, error: { code: "usage", message } });
82
+ return 2;
83
+ }
74
84
  const progress = progressForCommand(output.args);
75
85
  let heartbeat;
86
+ let invocationSettlement;
76
87
  try {
77
88
  if (output.args.length === 1 && output.args[0] === "--version") {
78
89
  const payload = getCliVersionReport();
@@ -87,6 +98,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
87
98
  io.stdout.write(`${help}\n`);
88
99
  return 0;
89
100
  }
101
+ assertSingletonOptions(output.args);
90
102
  if (io.stdin?.isTTY && ["work", "stage"].includes(output.args[0] ?? "") && output.args[1] === "finish"
91
103
  && hasOption(parseArgs(output.args.slice(2)), "result-stdin")) {
92
104
  throw new AppError("usage", "--result-stdin requires non-interactive input. Pipe or redirect the complete JSON into this command, or use --result-file; interactive terminals can truncate long JSON and wait indefinitely for EOF.", 2);
@@ -110,14 +122,44 @@ export async function runCli(args, io = defaultIo, env = process.env) {
110
122
  if (routed?.routed && !routed.inProcess) {
111
123
  if (heartbeat)
112
124
  clearInterval(heartbeat);
113
- emitHumanProgress(io, output, progress, "done");
125
+ emitHumanProgress(io, output, progress, routed.exitCode === 0 ? "done" : "failed");
126
+ if (output.progressJsonl && routed.exitCode !== 0)
127
+ writeProgressJsonl(io, "failed", "Routed engine failed", { exit_code: routed.exitCode });
114
128
  return routed.exitCode;
115
129
  }
116
130
  const dispatchContext = routed?.inProcess
117
131
  ? 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));
132
+ : createContext({ ...env }, databaseModeForCommand(output.args, classification));
119
133
  preflightCliCompatibility(dispatchContext, output.args, env, scopeProjectRoot);
120
- const result = await dispatch(output.args, dispatchContext, io, scopeProjectRoot, classification, output);
134
+ dispatchContext.env.DD_FLOW_INVOCATION_READONLY = classification.mode === "read_only_diagnostics" ? "1" : undefined;
135
+ let dispatchArgs = output.args;
136
+ let retainedOutcome;
137
+ const invocationId = optionalOption(parseArgs(output.args.slice(2)), "invocation-id");
138
+ if (invocationId) {
139
+ const command = quote(["dd-flow", ...output.args]);
140
+ const admission = await awaitLifecycleInvocation(dispatchContext, { id: invocationId, command,
141
+ assertCurrent: scope => assertLifecycleInvocationCurrent(dispatchContext, scope, command) });
142
+ if (admission.replay)
143
+ retainedOutcome = admission.outcome;
144
+ else {
145
+ invocationSettlement = { context: dispatchContext, id: invocationId };
146
+ dispatchArgs = [...output.args, "--hook-event-id", admission.eventKey];
147
+ dispatchContext.env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(lifecycleInvocationScope(dispatchContext, invocationId));
148
+ dispatchContext.env.DD_FLOW_CURRENT_INVOCATION = invocationId;
149
+ }
150
+ }
151
+ if (retainedOutcome?.error) {
152
+ const error = retainedOutcome.error;
153
+ throw new AppError(error.code, error.message, error.exitCode, error.details);
154
+ }
155
+ const result = retainedOutcome ? retainedOutcome.result : await dispatch(dispatchArgs, dispatchContext, io, scopeProjectRoot, classification, output);
156
+ if (invocationSettlement) {
157
+ const completed = invocationSettlement;
158
+ invocationSettlement = undefined;
159
+ // A failure to persist an already completed result is an unknown reply,
160
+ // not a failed operation eligible for a new productive retry.
161
+ settleLifecycleInvocation(completed.context, completed.id, { result });
162
+ }
121
163
  const refreshWarnings = refreshDashboardsAfterMutation(dispatchContext, output.args, result, scopeProjectRoot);
122
164
  const resultWithWarnings = addWarnings(result, refreshWarnings);
123
165
  if (heartbeat)
@@ -136,6 +178,17 @@ export async function runCli(args, io = defaultIo, env = process.env) {
136
178
  catch (error) {
137
179
  if (heartbeat)
138
180
  clearInterval(heartbeat);
181
+ if (invocationSettlement) {
182
+ try {
183
+ if (isAppError(error))
184
+ settleLifecycleRejection(invocationSettlement.context, invocationSettlement.id, { code: error.code, message: error.message, exitCode: error.exitCode, details: error.details });
185
+ else
186
+ settleLifecycleInvocation(invocationSettlement.context, invocationSettlement.id, { error: { code: "unexpected", message: String(error), exitCode: 1, details: {} } });
187
+ }
188
+ catch (persistenceError) {
189
+ io.stderr.write(`Invocation settlement persistence failed: ${String(persistenceError)}\n`);
190
+ }
191
+ }
139
192
  const payload = isAppError(error)
140
193
  ? { ok: false, error: { code: error.code, message: error.message, details: error.details } }
141
194
  : { ok: false, error: { code: "unexpected", message: String(error) } };
@@ -520,6 +573,13 @@ function shortSha(value) {
520
573
  async function dispatch(args, context, io, scopeProjectRoot = null, classification = classifyCliOperation(args, context.env), output = parseOutputOptions(args)) {
521
574
  const [family, command, ...rest] = args;
522
575
  const parsed = parseArgs(rest);
576
+ if (family === "zcode" && command === "invocation" && parsed.positional[0] === "prepare") {
577
+ const payload = JSON.parse(await readStdin(io.stdin));
578
+ if (typeof payload.command !== "string" || !payload.scope || typeof payload.scope !== "object")
579
+ throw new AppError("usage", "Expected command and managed scope on stdin", 2);
580
+ assertLifecycleInvocationCurrent(context, payload.scope, payload.command);
581
+ return { ok: true, ...issueLifecycleInvocation(context, payload.command, payload.scope) };
582
+ }
523
583
  if (family === "storage" && command === "migrate-writer") {
524
584
  if (parsed.positional.length || [...parsed.options.keys()].some(key => !["database", "backup", "offline"].includes(key)) || [...parsed.options.values()].some(values => values.length !== 1))
525
585
  throw new AppError("usage", "Usage: dd-flow storage migrate-writer --database ABSOLUTE_DB --backup NEW_ABSOLUTE_FILE --offline", 2);
@@ -1146,6 +1206,9 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1146
1206
  if (family === "codex" && command === "hook") {
1147
1207
  return dispatchCodexHook(context, parsed, await readStdin(io.stdin));
1148
1208
  }
1209
+ if (family === "zcode" && command === "hook") {
1210
+ return dispatchZcodeHook(context, parsed, await readStdin(io.stdin));
1211
+ }
1149
1212
  if (family === "zcode" && command === "event") {
1150
1213
  return dispatchZcodeEvent(context, parsed, await readStdin(io.stdin));
1151
1214
  }
@@ -2249,6 +2312,15 @@ function dispatchZcodeEvent(context, parsed, stdin) {
2249
2312
  }
2250
2313
  throw new AppError("usage", `Unknown zcode event action: ${action}`, 2);
2251
2314
  }
2315
+ function dispatchZcodeHook(context, parsed, stdin) {
2316
+ if (requiredPosition(parsed, 0, "zcode hook action") !== "handle")
2317
+ throw new AppError("usage", "Unknown zcode hook action", 2);
2318
+ const result = handleZcodeHook(context, { projectRoot: requiredOption(parsed, "project-root"), stdin });
2319
+ if (result && typeof result === "object" && !Array.isArray(result) && "hookSpecificOutput" in result) {
2320
+ return { hookSpecificOutput: result.hookSpecificOutput };
2321
+ }
2322
+ return {};
2323
+ }
2252
2324
  function dispatchGrokEvent(context, parsed, stdin) {
2253
2325
  const action = requiredPosition(parsed, 0, "grok event action");
2254
2326
  if (action === "handle") {
@@ -2371,6 +2443,21 @@ function parseFlowFlagOverrides(values) {
2371
2443
  function parseArgs(args) {
2372
2444
  return parseCommandArgs(args);
2373
2445
  }
2446
+ /** Most CLI options name one authority, path or target. Silently taking the
2447
+ * last duplicate can widen a mutation after an operator typo. Keep only the
2448
+ * options consumed as intentional arrays repeatable. */
2449
+ function assertSingletonOptions(args) {
2450
+ const repeatable = new Set(["alias", "flag", "hide", "check-ref", "evidence", "from-check", "from-finding", "from-unresolved", "on", "origin-work"]);
2451
+ const parsed = parseCommandArgs(args);
2452
+ if (args[0] === "stage" && args[1] === "finish"
2453
+ && ["result-file", "semantic-file", "data", "result-stdin"].filter(name => parsed.options.has(name)).length > 1) {
2454
+ throw new AppError("usage", "Stage finish requires exactly one result input; result-file, semantic-file, data and result-stdin are mutually exclusive", 2);
2455
+ }
2456
+ for (const [name, values] of parsed.options) {
2457
+ if (!repeatable.has(name) && values.length > 1)
2458
+ throw new AppError("usage", `--${name} may be supplied only once`, 2);
2459
+ }
2460
+ }
2374
2461
  function requiredPosition(parsed, index, label) {
2375
2462
  const value = parsed.positional[index];
2376
2463
  if (!value) {
@@ -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";
@@ -9,6 +11,9 @@ import { checkObservedProfile, observeModel, observedFields } from "./model-obse
9
11
  const ZCODE_HARNESS_CONTRACT = "dd-zcode-harness@1";
10
12
 
11
13
  function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
14
+ function fatalLifecycleError(error) {
15
+ return ["invocation_unknown", "invocation_identity_mismatch", "invocation_identity_conflict", "invocation_receipt_missing", "invocation_command_mismatch", "invocation_scope_mismatch", "invocation_generation_stale", "invocation_expired", "zcode_parent_identity_missing", "zcode_tool_identity_conflict"].includes(error?.code);
16
+ }
12
17
  function absolute(value, label) { if (!value || !path.isAbsolute(value)) fail(`${label} must be an absolute path`); return path.resolve(value); }
13
18
  function text(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
14
19
  function now() { return new Date().toISOString(); }
@@ -53,6 +58,7 @@ class Journal {
53
58
  if (!this.file) return;
54
59
  const line = JSON.stringify({ order: ++this.order, observed_at: now(), kind, payload });
55
60
  this.pending = this.pending.then(() => appendFile(this.file, `${line}\n`));
61
+ return this.pending;
56
62
  }
57
63
  async flush() { await this.pending; }
58
64
  }
@@ -64,6 +70,7 @@ export class AcpBridge {
64
70
  this.pending = new Map();
65
71
  this.sessionActivity = new Map();
66
72
  this.notifications = Promise.resolve();
73
+ this.lifecycleNotifications = new Set();
67
74
  this.notificationError = null;
68
75
  this.bridgeStderr = "";
69
76
  this.toolCalls = new Map();
@@ -127,6 +134,24 @@ export class AcpBridge {
127
134
  this.assistantText.set(sessionId, `${this.assistantText.get(sessionId) ?? ""}${message.params.update.content.text ?? ""}`);
128
135
  }
129
136
  this.observeToolCall(message);
137
+ if (message.method === "session/update" && this.options.onLifecycleNotification) {
138
+ const observer = this.options.onLifecycleNotification;
139
+ const pending = Promise.resolve().then(() => observer(message)).catch(async error => {
140
+ if (!this.notificationError || (fatalLifecycleError(error) && !fatalLifecycleError(this.notificationError))) this.notificationError = error;
141
+ // A missing/foreign authority is not a recoverable CLI validation
142
+ // result. Stop admitting productive requests immediately; the managed
143
+ // controller still owns physical stop/drain and evidence capture.
144
+ if (fatalLifecycleError(error)) this.rejectAll(error);
145
+ try {
146
+ await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
147
+ session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
148
+ } catch (persistenceError) {
149
+ process.stderr.write(`${JSON.stringify({ code: error.code, message: error.message, persistence_error: persistenceError.message })}\n`);
150
+ }
151
+ });
152
+ this.lifecycleNotifications.add(pending);
153
+ void pending.then(() => this.lifecycleNotifications.delete(pending), () => this.lifecycleNotifications.delete(pending));
154
+ }
130
155
  if (message.id !== undefined && message.method) { void this.answer(message).catch((error) => this.rejectAll(error)); return; }
131
156
  if (message.id !== undefined) {
132
157
  const pending = this.pending.get(message.id);
@@ -148,7 +173,14 @@ export class AcpBridge {
148
173
  }
149
174
  if (message.method) this.notifications = this.notifications
150
175
  .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); });
176
+ .catch(async (error) => {
177
+ 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" };
178
+ try { await this.journal.write("notification_failure", failure); }
179
+ catch (persistenceError) { process.stderr.write(`${JSON.stringify({ ...failure, persistence_error: persistenceError.message })}\n`); }
180
+ // Evidence loss does not retroactively revoke a committed native hook.
181
+ // Permission/profile integrity is a separate, still-fatal contract.
182
+ if (error.code === "profile_integrity_violation") { this.notificationError ??= error; this.rejectAll(error); }
183
+ });
152
184
  }
153
185
 
154
186
  observeToolCall(message) {
@@ -203,7 +235,7 @@ export class AcpBridge {
203
235
  }
204
236
 
205
237
  request(method, params = {}, timeoutMs = 30_000, { idleSessionId = null, idleTimeoutMs = null } = {}) {
206
- if (this.notificationError?.code === "profile_integrity_violation" && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
238
+ if ((this.notificationError?.code === "profile_integrity_violation" || fatalLifecycleError(this.notificationError)) && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
207
239
  const id = this.nextId++;
208
240
  return new Promise((resolve, reject) => {
209
241
  const finish = (error) => {
@@ -249,7 +281,11 @@ export class AcpBridge {
249
281
  notify(method, params = {}) { this.send({ jsonrpc: "2.0", method, params }); }
250
282
  send(message) { this.journal.write("outbound", message); this.child.stdin.write(`${JSON.stringify(message)}\n`); }
251
283
  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; }
284
+ async flush() {
285
+ try { await Promise.all([...this.lifecycleNotifications]); await this.notifications; await this.journal.flush(); }
286
+ catch (error) { throw this.notificationError ?? error; }
287
+ if (this.notificationError) throw this.notificationError;
288
+ }
253
289
  async close() {
254
290
  let flushError = null;
255
291
  try { await this.flush(); } catch (error) { flushError = error; }
@@ -437,8 +473,6 @@ async function withBridge(options, operation) {
437
473
  }
438
474
 
439
475
  function flowForwarder(options, rootProviderSessionId, bridge) {
440
- if (!options.ddFlowBin) return undefined;
441
- const projectRoot = absolute(options.projectRoot, "--project-root");
442
476
  return async (notification) => {
443
477
  if (notification.method !== "session/update") return;
444
478
  const update = notification.params?.update;
@@ -447,10 +481,52 @@ function flowForwarder(options, rootProviderSessionId, bridge) {
447
481
  const childId = notification.params?.update?._meta?.zcodeRuntime?.childSessionId;
448
482
  const observed = bridge?.modelProfiles?.get(childId ?? rootProviderSessionId) ?? {};
449
483
  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)}`);
484
+ // Native PreToolUse has already admitted (or denied) the command. ACP is
485
+ // intentionally only journal/model evidence: its serialized delivery can
486
+ // lag behind execution and must never mint a second lifecycle receipt.
487
+ };
488
+ }
489
+
490
+ /** Preserve native tool ownership independently of slow secondary evidence.
491
+ * Rehydration uses this same session's retained inbound journal, not model text. */
492
+ export async function zcodeInvocationObserver(options, rootProviderSessionId, send = invokeNativeHook) {
493
+ if (!options.ddFlowBin) return async () => {};
494
+ const owners = new Map();
495
+ const remember = message => {
496
+ const update = message.params?.update;
497
+ if (message.method !== "session/update" || update?.sessionUpdate !== "tool_call") return null;
498
+ if (options.adapterSessionId && message.params?.sessionId !== options.adapterSessionId) return null;
499
+ const native = update._meta?.zcodeRuntime ?? {};
500
+ if (native.source === "subagent" && !native.childSessionId) throw Object.assign(new Error("Native subagent event has no physical child identity"), { code: "zcode_child_identity_missing" });
501
+ const session = native.childSessionId ?? rootProviderSessionId;
502
+ const key = update.toolCallId;
503
+ if (!key || typeof session !== "string") return null;
504
+ 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" });
505
+ owners.set(key, session);
506
+ return { update, native, session };
507
+ };
508
+ if (options.journal) {
509
+ try {
510
+ const contents = await readFile(options.journal, "utf8");
511
+ for (const line of contents.split("\n").filter(Boolean)) {
512
+ const event = JSON.parse(line);
513
+ if (event.kind === "inbound") remember(event.payload);
514
+ }
515
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
516
+ }
517
+ return async message => {
518
+ const facts = remember(message);
519
+ if (!facts || !options.ddFlowBin) return;
520
+ const { update, native, session } = facts;
521
+ const command = update.rawInput?.command ?? update.rawInput?.cmd;
522
+ const toolName = update._meta?.claudeCode?.toolName;
523
+ if (!["Bash", "bash", "execute"].includes(toolName) || typeof command !== "string" || !/(?:^|\s)--invocation-id\s/.test(command)) return;
524
+ const parent = session === rootProviderSessionId ? null : owners.get(native.parentToolCallId);
525
+ if (session !== rootProviderSessionId && !parent) throw Object.assign(new Error("Native child has no confirmed immediate parent tool owner"), { code: "zcode_parent_identity_missing" });
526
+ await send({ bin: options.ddFlowBin, home: options.ddFlowHome,
527
+ args: ["zcode", "event", "handle", "--project-root", options.projectRoot ?? options.cwd, "--json"],
528
+ payload: { ...message, _meta: { ddZcode: { daemonId: options.daemonId, rootProviderSessionId, parentProviderSessionId: parent } } },
529
+ });
454
530
  };
455
531
  }
456
532
 
@@ -480,13 +556,26 @@ async function forwardUsage(options, providerSessionId, usage, toolCalls) {
480
556
  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
557
  }
482
558
 
559
+ export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContract) {
560
+ // Behavioural evidence: dd-eval production-invocations probe, 2026-09-13.
561
+ // Pin both providers of native event identity, not a string in the bundle.
562
+ const qualified = sha256 === "e9f1868c0fdb863537ed910ee3828b9be96b8c2fd805473f63b439e1113266b8"
563
+ && bridgeCommit === "43f654bccdbb1aa4f4fb4617f7315c4336dbcde0" && harnessContract === ZCODE_HARNESS_CONTRACT;
564
+ return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
565
+ capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
566
+ native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
567
+ }
568
+
483
569
  export async function doctor(options = {}) {
484
570
  const zcodeAcp = await commandOutput(options.bin, ["--version"]);
485
571
  const zcodeAcpCommit = await commandOutput(options.bin, ["--dd-harness-commit"]);
486
572
  const ddHarness = await commandOutput(options.bin, ["--dd-harness-version"]);
487
573
  const zcodePath = options.zcodePath ?? process.env.ZCODE_PATH ?? "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs";
488
574
  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 } };
575
+ const native = await readFile(zcodePath);
576
+ const sha256 = createHash("sha256").update(native).digest("hex");
577
+ const qualification = zcodeLifecycleQualification(sha256, zcodeAcpCommit, ddHarness);
578
+ return { compatible: qualification.status === "qualified", lifecycle_qualification: qualification, observed_runtime: { zcode, zcode_acp: zcodeAcp, zcode_acp_commit: zcodeAcpCommit, dd_harness_contract: ddHarness } };
490
579
  }
491
580
 
492
581
  export async function createSession(options) {
@@ -527,6 +616,8 @@ export async function promptSessionWithBridge(bridge, options) {
527
616
  let forward;
528
617
  bridge.configure({ ...options, onNotification: async (event) => { await forward?.(event); await options.onNotification?.(event); } });
529
618
  const identity = await controlledIdentity(bridge, { ...options, cwd }); forward = flowForwarder(options, identity.providerSessionId, bridge);
619
+ await bridge.journal.flush();
620
+ bridge.configure({ onLifecycleNotification: await zcodeInvocationObserver({ ...options, adapterSessionId: identity.adapterSessionId }, identity.providerSessionId) });
530
621
  const profile_receipt = await applyProfile(bridge, identity.adapterSessionId, requested, options.assertDispatch);
531
622
  options.assertDispatch?.();
532
623
  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
+ }