@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.
@@ -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")
@@ -129,7 +131,11 @@ function isReadOnlyDiagnostic(family, command, action) {
129
131
  if (family === "plan")
130
132
  return command === "status";
131
133
  if (family === "run")
132
- return command === "status" || command === "list" || (["drive", "control"].includes(command ?? "") && action === "status");
134
+ return command === "status" || command === "list" || (command === "recovery" && action === "inspect") || (["drive", "control"].includes(command ?? "") && action === "status");
135
+ if (family === "work")
136
+ return ["status", "ls", "show"].includes(command ?? "");
137
+ if (family === "stage")
138
+ return command === "fanout" && action === "status";
133
139
  if (family === "lane") {
134
140
  return (command === "status" ||
135
141
  command === "waiters" ||
@@ -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");
@@ -9,7 +9,8 @@ import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
10
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
11
11
  import { assertRecoveryHookCurrent } from "./run-recovery.js";
12
- import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
12
+ import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
13
+ import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
13
14
  import { registerProject, requireProjectByRoot } from "./projects.js";
14
15
  import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
15
16
  const defaultProfileName = "default";
@@ -286,7 +287,7 @@ export function handleCodexHook(context, input) {
286
287
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
287
288
  // Resume legitimately receives an answer through stdin. It is matched by
288
289
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
289
- if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
290
+ if (lifecycle.analysis.kind === "compound") {
290
291
  return {
291
292
  ok: false,
292
293
  observed: false,
@@ -409,118 +410,112 @@ function codexNativeParentId(payload, childId) {
409
410
  return null;
410
411
  }
411
412
  }
412
- /** Convert a zcode-acp Bash tool notification into the same trusted lifecycle receipt used by Codex hooks. */
413
+ /** ACP is asynchronous evidence. Only an explicitly issued invocation may use
414
+ * it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
413
415
  export function handleZcodeEvent(context, input) {
414
416
  const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
415
- if (notification.method !== "session/update")
417
+ const params = objectRecord(notification.params), update = objectRecord(params.update);
418
+ if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
416
419
  return { ok: true, observed: false, reason: "event_not_participating" };
417
- const params = objectRecord(notification.params);
418
- const update = objectRecord(params.update);
419
- if (update.sessionUpdate !== "tool_call")
420
- return { ok: true, observed: false, reason: "event_not_participating" };
421
- const updateMeta = objectRecord(update._meta);
422
- const claudeCode = objectRecord(updateMeta.claudeCode);
423
- const runtime = objectRecord(updateMeta.zcodeRuntime);
424
- const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
425
- const rawInput = objectRecord(update.rawInput);
420
+ const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
421
+ const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
422
+ if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
423
+ return { ok: true, observed: false, reason: "non_bash_tool" };
426
424
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
427
- if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && update.kind !== "execute")) {
425
+ const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
426
+ if (parsed.kind === "none" || !commandOption(parsed.invocation, "invocation-id"))
427
+ return { ok: true, observed: false, reason: "acp_evidence_only" };
428
+ const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
429
+ const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
430
+ return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
431
+ session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
432
+ agent_id: native.agentId, _meta: { ddZcode: controlled }
433
+ }) }, commandOption(parsed.invocation, "invocation-id"));
434
+ }
435
+ /**
436
+ * Accept the synchronous native ZCode PreToolUse callback. ACP notifications
437
+ * are retained as evidence, but cannot be an admission signal: they arrive
438
+ * after the tool call and may be serialized behind unrelated work.
439
+ */
440
+ export function handleZcodeHook(context, input) {
441
+ return recordZcodeLifecycle(context, input);
442
+ }
443
+ function recordZcodeLifecycle(context, input, invocationId) {
444
+ const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
445
+ const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
446
+ if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
447
+ return { ok: true, observed: false, reason: "event_not_participating" };
448
+ const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
449
+ const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
450
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
451
+ if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
428
452
  return { ok: true, observed: false, reason: "non_bash_tool" };
429
- }
430
453
  const lifecycle = lifecycleFacts(command);
431
454
  if (!lifecycle)
432
455
  return { ok: true, observed: false, reason: "event_not_participating" };
433
- if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
434
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
435
- standalone_command: lifecycle.invocation.command
436
- });
456
+ if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
457
+ return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
458
+ if (lifecycle.analysis.kind === "compound") {
459
+ throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, { standalone_command: lifecycle.invocation.command });
437
460
  }
438
- const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
439
461
  const expectedRoot = resolveProjectRoot(input.projectRoot);
440
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
441
- throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
442
- expected: expectedRoot,
443
- actual: commandProjectRoot
444
- });
462
+ if (lifecycle.commandProjectRoot && resolveProjectRoot(lifecycle.commandProjectRoot) !== expectedRoot) {
463
+ throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: lifecycle.commandProjectRoot });
445
464
  }
446
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
447
- if (bootstrapStageStart)
465
+ const meta = objectRecord(hook._meta);
466
+ const ddZcode = objectRecord(meta.ddZcode);
467
+ const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
468
+ const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
469
+ const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
470
+ const daemonId = stringValue(ddZcode.daemonId);
471
+ if (!providerSessionId || !rootProviderSessionId || !daemonId) {
472
+ throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
473
+ }
474
+ if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
475
+ throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
476
+ }
477
+ const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
478
+ if (!toolUseId)
479
+ throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
480
+ const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
481
+ const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
482
+ if (!invocationId && rewritten === command && commandOption(lifecycle.invocation, "hook-event-id") !== eventKey)
483
+ throw new AppError("zcode_hook_rewrite_unsupported", "ZCode requires a direct lifecycle command whose exact receipt can be delivered", 1);
484
+ if (lifecycle.bootstrapStageStart)
448
485
  registerProject(context, { root: expectedRoot });
449
486
  const project = requireProjectByRoot(context, expectedRoot);
450
- const envelopeMeta = objectRecord(notification._meta);
451
- const ddZcode = objectRecord(envelopeMeta.ddZcode);
452
- const observedProfile = objectRecord(ddZcode.observedProfile);
453
- const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
454
- const daemonId = stringValue(ddZcode.daemonId);
455
- if (!rootProviderSessionId)
456
- throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
457
- const childProviderSessionId = stringValue(runtime.childSessionId);
458
- const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
459
487
  const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
460
- const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
461
- const agentId = stringValue(runtime.agentId);
462
- const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
463
- harness: "zcode-acp", providerSessionId, command
464
- })).digest("hex");
465
- assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
466
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
467
- ...flowPayload,
468
- harness: "zcode-acp",
469
- provider_session_id: providerSessionId,
470
- agent_id: agentId ?? null,
471
- parent_session_id: parentSessionId
472
- }, sessionId) : undefined;
473
- const payload = {
474
- session_id: providerSessionId,
475
- agent_id: agentId,
476
- cwd: expectedRoot,
477
- tool_name: toolName ?? "Bash",
478
- tool_input: rawInput,
479
- command,
480
- provider: stringValue(observedProfile.provider),
481
- model: stringValue(observedProfile.model),
482
- reasoning: stringValue(observedProfile.reasoning),
483
- mode: stringValue(observedProfile.mode),
484
- agent_type: stringValue(runtime.source)
488
+ const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
489
+ const observedProfile = objectRecord(ddZcode.observedProfile);
490
+ const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
491
+ const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
492
+ const persist = () => {
493
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
494
+ const observedSession = lifecycle.flowPayload ? bindObservedFlowSession(context, project, {
495
+ ...lifecycle.flowPayload, harness: "zcode-acp", provider_session_id: providerSessionId,
496
+ parent_session_id: parentSessionId, provider: stringValue(observedProfile.provider) ?? null,
497
+ model: stringValue(observedProfile.model) ?? null, reasoning: stringValue(observedProfile.reasoning) ?? null,
498
+ mode: stringValue(observedProfile.mode) ?? null, agent_id: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null
499
+ }, sessionId) : undefined;
500
+ const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
501
+ command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
502
+ const inserted = recordHookEvent(context, {
503
+ projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "zcode-acp", providerSessionId, parentSessionId,
504
+ daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
505
+ eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
506
+ transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
507
+ });
508
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
509
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "zcode-acp", session: nativeSessionIdentity("zcode-acp", providerSessionId), ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", parentProviderSessionId) } : {}), daemon_id: daemonId };
510
+ return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
485
511
  };
486
- const inserted = recordHookEvent(context, {
487
- projectId: project.id,
488
- protocolId: observedSession?.protocol_id ?? null,
489
- harness: "zcode-acp",
490
- providerSessionId,
491
- parentSessionId,
492
- daemonId: daemonId ?? null,
493
- sessionId,
494
- agentId: agentId ?? null,
495
- turnId: null,
496
- eventName: "PreToolUse",
497
- toolName: toolName ?? "Bash",
498
- status: "observed",
499
- payload,
500
- eventKey,
501
- matchKey,
502
- transcriptPath: null,
503
- cwd: expectedRoot
512
+ if (!invocationId)
513
+ return persist();
514
+ const receipt = observeLifecycleInvocation(context, {
515
+ id: invocationId, command, identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
516
+ recordReceipt: () => { persist(); return eventKey; }
504
517
  });
505
- recordFlowSessionObservation(context, {
506
- projectId: project.id,
507
- sessionId,
508
- runId: observedSession?.run_id ?? null,
509
- protocolId: observedSession?.protocol_id ?? null,
510
- cwd: expectedRoot,
511
- toolName: toolName ?? "Bash",
512
- eventKey
513
- });
514
- return {
515
- ok: true,
516
- observed: inserted,
517
- duplicate: !inserted,
518
- event_key: eventKey,
519
- harness: "zcode-acp",
520
- session: nativeSessionIdentity("zcode-acp", providerSessionId),
521
- ...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
522
- daemon_id: daemonId ?? null
523
- };
518
+ return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: invocationId };
524
519
  }
525
520
  /** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
526
521
  export function handleGrokEvent(context, input) {
@@ -538,7 +533,7 @@ export function handleGrokEvent(context, input) {
538
533
  const lifecycle = lifecycleFacts(command);
539
534
  if (!lifecycle)
540
535
  return { ok: true, observed: false, reason: "event_not_participating" };
541
- if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
536
+ if (lifecycle.analysis.kind === "compound") {
542
537
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
543
538
  standalone_command: lifecycle.invocation.command
544
539
  });
@@ -653,7 +648,7 @@ function handleControlledToolEvent(context, input, event, harness) {
653
648
  const lifecycle = lifecycleFacts(command);
654
649
  if (!lifecycle)
655
650
  return { ok: true, observed: false, reason: "event_not_participating" };
656
- if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
651
+ if (lifecycle.analysis.kind === "compound") {
657
652
  throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
658
653
  }
659
654
  const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
@@ -741,11 +736,58 @@ function lifecycleCommandFromPayload(payload) {
741
736
  const command = commandFromPayload(payload);
742
737
  return command ? unwrapShellCommand(command) : undefined;
743
738
  }
744
- function commandWithHookEvent(command, eventKey) {
739
+ /**
740
+ * Adds the receipt to the command that the shell will execute. Heredoc input
741
+ * belongs to that command too, but an option appended after its delimiter is a
742
+ * second shell command rather than an argument, so insert it before `<<`.
743
+ */
744
+ export function commandWithHookEvent(command, eventKey) {
745
745
  const analysis = parseLifecycleCommand(command);
746
- if (analysis.kind !== "standalone" || !analysis.invocation.rewriteSafe || commandHasOption(analysis.invocation, "hook-event-id"))
746
+ if (analysis.kind !== "standalone")
747
+ return command;
748
+ if (commandHasOption(analysis.invocation, "hook-event-id")) {
749
+ if (commandOption(analysis.invocation, "hook-event-id") !== eventKey)
750
+ throw new AppError("hook_event_mismatch", "The command specifies a receipt from another tool invocation", 1);
747
751
  return command;
748
- return `${command.trim()} --hook-event-id ${JSON.stringify(eventKey)}`;
752
+ }
753
+ const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
754
+ if (analysis.invocation.rewriteSafe) {
755
+ const index = shellSuffixOffset(command);
756
+ return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
757
+ }
758
+ if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
759
+ const index = firstHeredoc(command);
760
+ if (index >= 0)
761
+ return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
762
+ }
763
+ return command;
764
+ }
765
+ function firstHeredoc(command) {
766
+ let quoteState = null;
767
+ let escaped = false;
768
+ for (let index = 0; index < command.length - 1; index += 1) {
769
+ const char = command[index];
770
+ if (escaped) {
771
+ escaped = false;
772
+ continue;
773
+ }
774
+ if (char === "\\" && quoteState !== "'") {
775
+ escaped = true;
776
+ continue;
777
+ }
778
+ if (quoteState) {
779
+ if (char === quoteState)
780
+ quoteState = null;
781
+ continue;
782
+ }
783
+ if (char === "'" || char === '"') {
784
+ quoteState = char;
785
+ continue;
786
+ }
787
+ if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
788
+ return index;
789
+ }
790
+ return -1;
749
791
  }
750
792
  /**
751
793
  * Recognise the small shell surface used by generated dd-flow lifecycle
@@ -794,8 +836,11 @@ function lifecycleFacts(command) {
794
836
  // not a second authority. Everything else remains standalone-only.
795
837
  function allowsStdinLifecycleCompound(facts) {
796
838
  const operation = facts.invocation.operation;
797
- return (operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
798
- || ((operation === "stage_pause" || operation === "stage_resume") && facts.invocation.heredoc);
839
+ return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
840
+ && ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
841
+ || (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
842
+ || (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
843
+ || (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
799
844
  }
800
845
  function lifecycleMatchKey(facts, projectRoot) {
801
846
  const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
@@ -920,17 +965,19 @@ export function findRecentMatchingHookEvent(context, input) {
920
965
  const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
921
966
  const find = () => context.db.all(`SELECT id, event_key FROM hook_events
922
967
  WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
923
- ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter]);
968
+ AND harness IN ('codex-desktop', 'antigravity-cli')
969
+ AND (? IS NULL OR session_id = ?)
970
+ ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
924
971
  let events = find();
925
- // Some harnesses publish nested-agent tool events concurrently with command
926
- // execution. Give the trusted event a short bounded window to reach SQLite.
972
+ // Only the qualified non-rewriting transports may correlate the original
973
+ // command: Codex Desktop and Antigravity. New harnesses fail closed by default.
927
974
  const deadline = Date.now() + 250;
928
975
  while (events.length === 0 && Date.now() < deadline) {
929
976
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
930
977
  events = find();
931
978
  }
932
979
  if (events.length === 0 || !events[0]?.event_key) {
933
- throw new AppError(input.errorCode, `${input.operation} requires one matching harness lifecycle event. Stay in this Session and retry the exact standalone lifecycle command; do not create a new Session.`, 1);
980
+ throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery and updatedInput; managed ZCode requires its issued --invocation-id.`, 1);
934
981
  }
935
982
  if (events.length > 1) {
936
983
  throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; provide its exact hook event ID`, 1, { match_key: input.matchKey, event_keys: events.map((event) => event.event_key) });
@@ -1009,10 +1056,8 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
1009
1056
  /** Claims the resume command observed in the Session that received the user answer. */
1010
1057
  export function claimStageResumeHookEvent(context, input) {
1011
1058
  const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
1012
- const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
1013
- const event = input.eventKey
1014
- ? context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey])
1015
- : context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND session_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ? ORDER BY id DESC LIMIT 1", [input.projectId, input.sessionId ?? "", matchKey, freshAfter]);
1059
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
1060
+ const event = context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
1016
1061
  if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
1017
1062
  throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
1018
1063
  }
@@ -32,7 +32,7 @@ function parseLifecycleCommandInner(command, wrapped) {
32
32
  const heredoc = stripHeredocBodies(command);
33
33
  let entries;
34
34
  try {
35
- entries = parse(heredoc.command, (name) => `$${name}`);
35
+ entries = parse(withoutFileDescriptors(heredoc.command), (name) => `$${name}`);
36
36
  }
37
37
  catch {
38
38
  // A command that cannot be tokenized cannot create a trusted claim. The
@@ -56,11 +56,11 @@ function parseLifecycleCommandInner(command, wrapped) {
56
56
  if (!found.length)
57
57
  return { kind: "none" };
58
58
  const first = found[0];
59
- if (found.length !== 1 || composed || first.nestedCompound) {
59
+ if (found.length !== 1 || composed || first.nestedCompound || (heredoc.present && !heredoc.safe)) {
60
60
  return {
61
61
  kind: "compound",
62
62
  invocation: { ...first.invocation, rewriteSafe: false },
63
- reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : "shell_composition"
63
+ reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : heredoc.present && !heredoc.safe ? "unsafe_heredoc" : "shell_composition"
64
64
  };
65
65
  }
66
66
  return { kind: "standalone", invocation: first.invocation };
@@ -170,6 +170,9 @@ function wrappedInvocationFromSegment(segment) {
170
170
  return parseLifecycleCommandInner(payload, true);
171
171
  }
172
172
  function lifecycleOperation(argv) {
173
+ // The CLI handles help before dispatch, wherever the flag occurs.
174
+ if (argv.some(arg => arg === "--help" || arg === "-h"))
175
+ return null;
173
176
  if (argv[0] === "run" && argv[1] === "recovery" && argv[2] === "accept")
174
177
  return "recovery_accept";
175
178
  const key = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
@@ -223,6 +226,58 @@ export function commandPosition(invocation, index) {
223
226
  function assignment(word) {
224
227
  return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
225
228
  }
229
+ /** Source offsets matter: shell-quote loses the difference between `2>&1`
230
+ * and the literal argument `2 >&1`. Only inspect unquoted shell syntax. */
231
+ function shellSyntaxOffsets(command) {
232
+ const offsets = [];
233
+ let quoted = null, escaped = false;
234
+ for (let i = 0; i < command.length; i++) {
235
+ const char = command[i];
236
+ if (escaped) {
237
+ escaped = false;
238
+ continue;
239
+ }
240
+ if (char === "\\" && quoted !== "'") {
241
+ escaped = true;
242
+ continue;
243
+ }
244
+ if (quoted) {
245
+ if (char === quoted)
246
+ quoted = null;
247
+ continue;
248
+ }
249
+ if (char === "'" || char === '"') {
250
+ quoted = char;
251
+ continue;
252
+ }
253
+ if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
254
+ offsets.push(i);
255
+ break;
256
+ }
257
+ if (char === "<" || char === ">")
258
+ offsets.push(i);
259
+ }
260
+ return offsets;
261
+ }
262
+ function descriptorStart(command, index) {
263
+ let start = index;
264
+ while (start > 0 && /[0-9]/.test(command[start - 1]))
265
+ start--;
266
+ return start === 0 || /[\s;&|]/.test(command[start - 1]) ? start : index;
267
+ }
268
+ export function shellSuffixOffset(command) {
269
+ const offset = shellSyntaxOffsets(command)[0];
270
+ return offset === undefined ? command.length : descriptorStart(command, offset);
271
+ }
272
+ function withoutFileDescriptors(command) {
273
+ for (const index of shellSyntaxOffsets(command).reverse()) {
274
+ if (command[index] === "#")
275
+ continue;
276
+ const start = descriptorStart(command, index);
277
+ command = command.slice(0, start) + command.slice(index);
278
+ }
279
+ return command;
280
+ }
226
281
  function addAssignment(env, word) {
227
282
  const separator = word.indexOf("=");
228
283
  env[word.slice(0, separator)] = word.slice(separator + 1);
@@ -233,6 +288,7 @@ function stripHeredocBodies(command) {
233
288
  const pending = [];
234
289
  let active;
235
290
  let present = false;
291
+ let count = 0, quoted = true;
236
292
  for (const line of lines) {
237
293
  if (active) {
238
294
  const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
@@ -243,13 +299,15 @@ function stripHeredocBodies(command) {
243
299
  if (line.trim())
244
300
  output.push(line);
245
301
  const found = heredocDelimiters(line);
302
+ count += found.length;
303
+ quoted &&= found.every(item => item.quoted);
246
304
  if (found.length) {
247
305
  present = true;
248
306
  active = found[0];
249
307
  pending.push(...found.slice(1));
250
308
  }
251
309
  }
252
- return { command: output.join(" ; "), present };
310
+ return { command: output.join(" ; "), present, safe: count === 1 && quoted && !active && pending.length === 0 };
253
311
  }
254
312
  function heredocDelimiters(line) {
255
313
  const result = [];
@@ -292,7 +350,7 @@ function heredocDelimiters(line) {
292
350
  index += 1;
293
351
  const delimiter = line.slice(start, index);
294
352
  if (delimiter)
295
- result.push({ delimiter, stripTabs });
353
+ result.push({ delimiter, stripTabs, quoted: delimiterQuote !== null });
296
354
  }
297
355
  return result;
298
356
  }