@deksden-com/dd-flow-cli 0.9.0-beta.17 → 0.9.0-beta.19

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.
@@ -285,7 +285,7 @@ export function handleCodexHook(context, input) {
285
285
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
286
286
  // Resume legitimately receives an answer through stdin. It is matched by
287
287
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
288
- if (lifecycle.analysis.kind === "compound") {
288
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
289
289
  return {
290
290
  ok: false,
291
291
  observed: false,
@@ -299,14 +299,25 @@ export function handleCodexHook(context, input) {
299
299
  }
300
300
  };
301
301
  }
302
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
302
+ const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
303
303
  const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
304
304
  // A bootstrap stage is the first stateful command for a new materialized project.
305
305
  // Its PreToolUse event arrives before stage start can register that project.
306
306
  if (bootstrapStageStart && commandProjectRoot) {
307
307
  registerProject(context, { root: commandProjectRoot });
308
308
  }
309
- const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
309
+ let project;
310
+ try {
311
+ project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
312
+ }
313
+ catch (error) {
314
+ // A terminal command outside a registered flow gets no trusted receipt;
315
+ // the CLI will fail closed if it is actually executed. This keeps hooks
316
+ // inert for unrelated shell-parser probes and stale project directories.
317
+ if (stageFinish || stagePause || workFinish || workFail)
318
+ return { ok: true, observed: false, reason: "project_not_registered" };
319
+ throw error;
320
+ }
310
321
  if (!project)
311
322
  return { ok: true, observed: false, reason: "unrelated_cwd" };
312
323
  // Codex retains the root session_id for a thread-spawned child, while the
@@ -364,7 +375,7 @@ export function handleCodexHook(context, input) {
364
375
  session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
365
376
  ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
366
377
  protocol_id: protocolId,
367
- ...(providerSessionId && (flowPayload || stageStart || stageResume || workStart)
378
+ ...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
368
379
  ? {
369
380
  hookSpecificOutput: {
370
381
  hookEventName: "PreToolUse",
@@ -414,7 +425,7 @@ export function handleZcodeEvent(context, input) {
414
425
  const lifecycle = lifecycleFacts(command);
415
426
  if (!lifecycle)
416
427
  return { ok: true, observed: false, reason: "event_not_participating" };
417
- if (lifecycle.analysis.kind === "compound") {
428
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
418
429
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
419
430
  standalone_command: lifecycle.invocation.command
420
431
  });
@@ -521,12 +532,12 @@ export function handleGrokEvent(context, input) {
521
532
  const lifecycle = lifecycleFacts(command);
522
533
  if (!lifecycle)
523
534
  return { ok: true, observed: false, reason: "event_not_participating" };
524
- if (lifecycle.analysis.kind === "compound") {
535
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
525
536
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
526
537
  standalone_command: lifecycle.invocation.command
527
538
  });
528
539
  }
529
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
540
+ const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
530
541
  const expectedRoot = resolveProjectRoot(input.projectRoot);
531
542
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
532
543
  throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
@@ -570,20 +581,32 @@ export function handleGrokEvent(context, input) {
570
581
  });
571
582
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
572
583
  ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
573
- return (flowPayload || stageStart || stageResume || workStart)
584
+ return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
574
585
  ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
575
586
  : result;
576
587
  }
577
588
  /** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
578
589
  export function handleOpenCodeEvent(context, input) {
579
590
  const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
580
- const agy = event.source_harness === "antigravity-cli";
581
- const harness = agy ? "antigravity-cli" : "opencode-server";
582
591
  if (event.schema_id !== "dd-flow/opencode-tool-event@1")
583
592
  throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
593
+ return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
594
+ }
595
+ /** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
596
+ export function handleDroidEvent(context, input) {
597
+ const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
598
+ if (event.schema_id !== "dd-flow/droid-tool-event@1")
599
+ throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
600
+ return handleControlledToolEvent(context, input, event, "droid-cli");
601
+ }
602
+ function handleControlledToolEvent(context, input, event, harness) {
603
+ const agy = harness === "antigravity-cli";
604
+ const droid = harness === "droid-cli";
605
+ const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
606
+ const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
584
607
  const phase = stringValue(event.phase);
585
608
  if (phase !== "before" && phase !== "after")
586
- throw new AppError("unsupported_opencode_event_schema", "OpenCode event phase must be before or after", 1);
609
+ throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
587
610
  const session = objectRecord(event.session);
588
611
  const profile = objectRecord(event.profile);
589
612
  const rawInput = objectRecord(event.input);
@@ -591,16 +614,18 @@ export function handleOpenCodeEvent(context, input) {
591
614
  const nativeParentId = stringValue(session.parent_provider_session_id);
592
615
  const daemonId = stringValue(event.daemon_id);
593
616
  const toolCallId = stringValue(event.tool_call_id);
594
- const eventId = stringValue(event.event_id) ?? toolCallId;
617
+ const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
595
618
  const toolName = stringValue(event.tool);
596
619
  const directory = stringValue(session.directory);
597
- if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
598
- throw new AppError("opencode_session_identity_invalid", "OpenCode event is missing trusted physical identity", 1);
620
+ if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
621
+ throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
599
622
  }
600
623
  const expectedRoot = resolveProjectRoot(input.projectRoot);
601
624
  if (resolveProjectRoot(directory) !== expectedRoot) {
602
- throw new AppError(agy ? "agy_directory_mismatch" : "opencode_directory_mismatch", `${agy ? "Antigravity" : "OpenCode"} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
625
+ throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
603
626
  }
627
+ if (droid && nativeParentId === providerSessionId)
628
+ throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
604
629
  const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
605
630
  const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
606
631
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
@@ -611,43 +636,49 @@ export function handleOpenCodeEvent(context, input) {
611
636
  projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
612
637
  daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
613
638
  eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
614
- eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
639
+ eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
615
640
  });
616
641
  return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
617
642
  }
618
- if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
643
+ if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
619
644
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
620
645
  }
621
646
  const lifecycle = lifecycleFacts(command);
622
647
  if (!lifecycle)
623
648
  return { ok: true, observed: false, reason: "event_not_participating" };
624
- if (lifecycle.analysis.kind === "compound") {
625
- throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
649
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
650
+ throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
626
651
  }
627
652
  const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
628
653
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
629
- throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
654
+ throw new AppError("project_mismatch", `${label} lifecycle command project root does not match the controlled workspace`, 1, { expected: expectedRoot, actual: commandProjectRoot });
630
655
  }
631
656
  const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
632
657
  if (bootstrapStageStart)
633
658
  registerProject(context, { root: expectedRoot });
634
659
  const project = requireProjectByRoot(context, expectedRoot);
660
+ if (droid) {
661
+ const existing = context.db.get("SELECT harness, provider_session_id, parent_session_id, daemon_id, match_key, tool_name FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, baseKey]);
662
+ if (existing && (existing.harness !== harness || existing.provider_session_id !== providerSessionId || existing.parent_session_id !== parentSessionId || existing.daemon_id !== daemonId || existing.match_key !== matchKey || existing.tool_name !== toolName)) {
663
+ throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
664
+ }
665
+ }
635
666
  const agentId = stringValue(session.agent_id);
636
667
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
637
668
  ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
638
669
  parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
639
- reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
640
- metadata: { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
670
+ reasoning: stringValue(droid ? profile.reasoning : profile.variant) ?? null, mode: stringValue(droid ? profile.mode : profile.agent) ?? null, agent_type: stringValue(droid ? profile.agent_type : profile.agent) ?? null,
671
+ metadata: droid ? { droid_version: stringValue(event.runtime_version) ?? null } : { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
641
672
  }, sessionId) : undefined;
642
673
  const payload = {
643
674
  session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
644
- provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
645
- mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
675
+ provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
676
+ mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
646
677
  };
647
678
  const inserted = recordHookEvent(context, {
648
679
  projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
649
680
  daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
650
- status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
681
+ status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
651
682
  });
652
683
  recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
653
684
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
@@ -727,25 +758,39 @@ function lifecycleFacts(command) {
727
758
  return null;
728
759
  const invocation = analysis.invocation;
729
760
  const stageStart = invocation.operation === "stage_start";
761
+ const stageFinish = invocation.operation === "stage_finish";
762
+ const stagePause = invocation.operation === "stage_pause";
730
763
  const stageResume = invocation.operation === "stage_resume";
731
764
  const workStart = invocation.operation === "work_start";
765
+ const workFinish = invocation.operation === "work_finish";
766
+ const workFail = invocation.operation === "work_fail";
732
767
  return {
733
768
  analysis,
734
769
  invocation,
735
770
  flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
736
771
  stageStart,
772
+ stageFinish,
773
+ stagePause,
737
774
  stageResume,
738
- workStart,
775
+ workStart, workFinish, workFail,
739
776
  bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
740
777
  commandProjectRoot: commandOption(invocation, "project-root"),
741
- stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
742
- runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
743
- workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
778
+ stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
779
+ runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
780
+ workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
744
781
  contextSha256: commandOption(invocation, "context-sha256")
745
782
  };
746
783
  }
784
+ // Worker completion and the two HITL commands intentionally take their
785
+ // payload from stdin. Their generated pipe/heredoc is one lifecycle command,
786
+ // not a second authority. Everything else remains standalone-only.
787
+ function allowsStdinLifecycleCompound(facts) {
788
+ const operation = facts.invocation.operation;
789
+ return (operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
790
+ || ((operation === "stage_pause" || operation === "stage_resume") && facts.invocation.heredoc);
791
+ }
747
792
  function lifecycleMatchKey(facts, projectRoot) {
748
- const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
793
+ const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
749
794
  if (bootstrapStageStart)
750
795
  return bootstrapMatchKey({
751
796
  projectRoot,
@@ -755,12 +800,58 @@ function lifecycleMatchKey(facts, projectRoot) {
755
800
  });
756
801
  if (stageStart && runId && stageName)
757
802
  return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
803
+ if (stageFinish && runId && stageName)
804
+ return stageLifecycleMatchKey("stage_finish", runId, stageName, projectRoot);
805
+ if (stagePause && runId && stageName && workId)
806
+ return stageLifecycleMatchKey("stage_pause", runId, stageName, projectRoot, workId);
758
807
  if (stageResume && runId && stageName && workId)
759
808
  return stageResumeMatchKey(runId, stageName, workId, projectRoot);
760
809
  if (workStart && workId)
761
810
  return workStartMatchKey(workId, projectRoot);
811
+ if (workFinish && workId)
812
+ return workLifecycleMatchKey("work_finish", workId, projectRoot);
813
+ if (workFail && workId)
814
+ return workLifecycleMatchKey("work_fail", workId, projectRoot);
762
815
  return null;
763
816
  }
817
+ export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
818
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
819
+ }
820
+ export function workLifecycleMatchKey(operation, workId, projectRoot) {
821
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
822
+ }
823
+ /** Claims a Work terminal command only after its exact hook receipt has proved
824
+ * the physical Session that issued it. Work code performs the owner check. */
825
+ export function claimWorkLifecycleHookEvent(context, input) {
826
+ const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
827
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
828
+ projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
829
+ }).eventKey;
830
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
831
+ if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
832
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey });
833
+ }
834
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
835
+ if (claimed.changes !== 1)
836
+ throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
837
+ return hookSessionIdentity(context, input.projectId, eventKey);
838
+ }
839
+ /** Claims a lifecycle command only after its exact hook receipt has proved the
840
+ * physical Session that issued it. Stage code performs the Work-owner check. */
841
+ export function claimStageLifecycleHookEvent(context, input) {
842
+ const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
843
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
844
+ projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
845
+ }).eventKey;
846
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
847
+ if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
848
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey });
849
+ }
850
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
851
+ if (claimed.changes !== 1)
852
+ throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
853
+ return hookSessionIdentity(context, input.projectId, eventKey);
854
+ }
764
855
  /** Trusted session identity is created by PreToolUse, never supplied by an agent. */
765
856
  export function sessionIdForHookEvent(context, projectId, eventKey) {
766
857
  const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
@@ -175,10 +175,18 @@ function lifecycleOperation(argv) {
175
175
  return "session_register";
176
176
  if (key === "stage start")
177
177
  return "stage_start";
178
+ if (key === "stage finish")
179
+ return "stage_finish";
180
+ if (key === "stage pause")
181
+ return "stage_pause";
178
182
  if (key === "stage resume")
179
183
  return "stage_resume";
180
184
  if (key === "work start")
181
185
  return "work_start";
186
+ if (key === "work finish")
187
+ return "work_finish";
188
+ if (key === "work fail")
189
+ return "work_fail";
182
190
  return null;
183
191
  }
184
192
  export function parseCommandArgs(argv) {
@@ -0,0 +1,104 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { resolveProjectRoot } from "../storage/paths.js";
6
+ import { requireProjectByRoot } from "./projects.js";
7
+ import { appendFlowRunTimelineEvent } from "./runs.js";
8
+ function requireRun(context, projectId, runId) {
9
+ const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]);
10
+ if (!run?.run_root)
11
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId });
12
+ return run;
13
+ }
14
+ function parseObject(value, label) { try {
15
+ const parsed = JSON.parse(value);
16
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
17
+ return parsed;
18
+ }
19
+ catch { /* validated below */ } throw new AppError("validation", `${label} must be a JSON object`, 2); }
20
+ export function recoveryGuard(context, projectId, runId) { return context.db.get("SELECT recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at FROM run_recovery_guards WHERE project_id = ? AND run_id = ? ORDER BY generation DESC LIMIT 1", [projectId, runId]) ?? null; }
21
+ export function assertRunMutationAllowed(context, projectId, runId) { const guard = recoveryGuard(context, projectId, runId); if (guard && ["draining", "sealed", "resuming"].includes(guard.status))
22
+ throw new AppError("run_recovery_guarded", "RUN is retained for recovery; reconcile or resume its recovery operation before accepting a new lifecycle mutation", 1, { run_id: runId, recovery_id: guard.recovery_id, generation: guard.generation, status: guard.status }); }
23
+ export function beginRunRecovery(context, input) {
24
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
25
+ const run = requireRun(context, project.id, input.runId);
26
+ const interruption = parseObject(input.interruption, "interruption");
27
+ const prior = recoveryGuard(context, project.id, run.id);
28
+ if (prior && ["draining", "sealed", "resuming"].includes(prior.status))
29
+ return { ok: true, recovery: { ...prior, interruption: JSON.parse(prior.interruption_json) }, reused: true };
30
+ const recoveryId = `RCV-${crypto.randomUUID()}`, generation = (prior?.generation ?? 0) + 1, now = context.now();
31
+ context.db.run("INSERT INTO run_recovery_guards (recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at) VALUES (?, ?, ?, ?, 'draining', ?, NULL, NULL, ?, ?, NULL, NULL)", [recoveryId, project.id, run.id, generation, JSON.stringify(interruption), now, now]);
32
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_draining", recovery_id: recoveryId, generation, interruption });
33
+ return { ok: true, recovery: { recovery_id: recoveryId, run_id: run.id, generation, status: "draining", interruption } };
34
+ }
35
+ export function sealRunRecovery(context, input) {
36
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
37
+ const run = requireRun(context, project.id, input.runId);
38
+ const guard = recoveryGuard(context, project.id, run.id);
39
+ if (!guard || guard.recovery_id !== input.recoveryId)
40
+ throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
41
+ const settlement = parseObject(input.settlement, "settlement");
42
+ if (settlement.settled !== true)
43
+ throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1, { recovery_id: guard.recovery_id });
44
+ if (guard.status === "sealed")
45
+ return { ok: true, recovery: { ...guard, settlement: JSON.parse(guard.settlement_json ?? "{}") }, reused: true };
46
+ if (guard.status !== "draining")
47
+ throw new AppError("recovery_invalid_state", "Recovery is not draining", 1, { recovery_id: guard.recovery_id, status: guard.status });
48
+ const now = context.now();
49
+ context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining'", [JSON.stringify(settlement), now, now, guard.recovery_id]);
50
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_sealed", recovery_id: guard.recovery_id, generation: guard.generation, settlement });
51
+ return { ok: true, recovery: { recovery_id: guard.recovery_id, run_id: run.id, generation: guard.generation, status: "sealed", settlement } };
52
+ }
53
+ export function recordRecoveryCapture(context, input) {
54
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
55
+ const run = requireRun(context, project.id, input.runId);
56
+ const guard = recoveryGuard(context, project.id, run.id);
57
+ if (!guard || guard.recovery_id !== input.recoveryId || guard.status !== "sealed")
58
+ throw new AppError("recovery_invalid_state", "Recovery capture requires a sealed recovery operation", 1, { recovery_id: input.recoveryId, run_id: run.id });
59
+ const capture = path.resolve(input.capturePath);
60
+ if (!fs.existsSync(capture))
61
+ throw new AppError("recovery_capture_missing", "Recovery capture path is unavailable", 1, { capture });
62
+ context.db.run("UPDATE run_recovery_guards SET capture_path = ?, updated_at = ? WHERE recovery_id = ?", [capture, context.now(), guard.recovery_id]);
63
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_capture_ready", recovery_id: guard.recovery_id, capture_path: capture });
64
+ return { ok: true, recovery_id: guard.recovery_id, capture_path: capture };
65
+ }
66
+ export function resumeRunRecovery(context, input) {
67
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
68
+ const run = requireRun(context, project.id, input.runId);
69
+ const guard = recoveryGuard(context, project.id, run.id);
70
+ if (!guard || guard.recovery_id !== input.recoveryId)
71
+ throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
72
+ if (guard.status === "recovered")
73
+ return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: true, reused: true };
74
+ if (guard.status !== "sealed" || !guard.capture_path)
75
+ throw new AppError("recovery_capture_required", "Recovery must be sealed and captured before it can resume", 1, { recovery_id: guard.recovery_id, status: guard.status });
76
+ // The root coordinator keeps its same native Session and receives a recovery
77
+ // prompt from the eval runner. Only delegated Work is returned to the ready
78
+ // graph and therefore gets a fresh, addressable recovery packet.
79
+ const now = context.now(), running = context.db.all("SELECT work_id, started_at, status FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' AND parent_work_id IS NOT NULL ORDER BY work_id", [project.id, run.id]);
80
+ context.db.exec("BEGIN IMMEDIATE");
81
+ try {
82
+ for (const work of running) {
83
+ const evidence = path.join(run.run_root, "recoveries", guard.recovery_id, "works", work.work_id), source = path.join(run.run_root, "works", work.work_id);
84
+ fs.mkdirSync(path.dirname(evidence), { recursive: true });
85
+ if (fs.existsSync(source) && !fs.existsSync(evidence))
86
+ fs.cpSync(source, evidence, { recursive: true, verbatimSymlinks: true });
87
+ context.db.run("INSERT INTO work_recovery_segments (recovery_id, work_id, generation, prior_started_at, evidence_path, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)", [guard.recovery_id, work.work_id, guard.generation, work.started_at, evidence, now, now]);
88
+ context.db.run("UPDATE work_sessions SET status = 'interrupted', completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]);
89
+ context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
90
+ }
91
+ context.db.run("UPDATE run_recovery_guards SET status = 'recovered', resumed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'sealed'", [now, now, guard.recovery_id]);
92
+ context.db.exec("COMMIT");
93
+ }
94
+ catch (error) {
95
+ context.db.exec("ROLLBACK");
96
+ throw error;
97
+ }
98
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_resumed", recovery_id: guard.recovery_id, generation: guard.generation, interrupted_works: running.map((work) => work.work_id) });
99
+ return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: true, interrupted_works: running.map((work) => work.work_id) };
100
+ }
101
+ export function recoveryPromptForWork(context, workId) { const row = context.db.get("SELECT recovery_id, generation, evidence_path FROM work_recovery_segments WHERE work_id = ? AND status = 'prepared' ORDER BY generation DESC LIMIT 1", [workId]); return row ? { recoveryId: row.recovery_id, generation: row.generation, evidencePath: row.evidence_path } : null; }
102
+ export function activateRecoveryWork(context, workId) { context.db.run("UPDATE work_recovery_segments SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'prepared'", [context.now(), workId]); }
103
+ export function settleRecoveryWork(context, workId, status) { context.db.run("UPDATE work_recovery_segments SET status = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [status, context.now(), workId]); }
104
+ export function inspectRunRecovery(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const run = requireRun(context, project.id, input.runId); const guard = recoveryGuard(context, project.id, run.id); const works = context.db.all("SELECT w.work_id, w.status, s.recovery_id, s.status AS segment_status FROM works w LEFT JOIN work_recovery_segments s ON s.work_id = w.work_id AND s.generation = (SELECT MAX(generation) FROM work_recovery_segments latest WHERE latest.work_id = w.work_id) WHERE w.project_id = ? AND w.run_id = ? ORDER BY w.work_id", [project.id, run.id]); return { ok: true, run_id: run.id, recovery: guard ? { ...guard, interruption: JSON.parse(guard.interruption_json), settlement: guard.settlement_json ? JSON.parse(guard.settlement_json) : null } : null, works }; }
@@ -18,6 +18,7 @@ import { registerProject } from "./projects.js";
18
18
  import { registerProtocol } from "./protocols.js";
19
19
  import { previewNextEntityId } from "./ids.js";
20
20
  import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
21
+ import { assertRunMutationAllowed } from "./run-recovery.js";
21
22
  const stagePromptSections = [
22
23
  "stage_identity",
23
24
  "authoritative_runtime_facts",
@@ -119,6 +120,7 @@ export function startStage(context, input) {
119
120
  if (!runId)
120
121
  throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
121
122
  const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
123
+ assertRunMutationAllowed(context, before.run.project_id, before.run.id);
122
124
  const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
123
125
  const attached = runView(attachFlowRunStage(context, {
124
126
  projectRoot,
@@ -263,6 +265,7 @@ export function finishStage(context, input) {
263
265
  throw new AppError("usage", "stage finish requires a RUN id", 2);
264
266
  const runId = input.runId;
265
267
  const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
268
+ assertRunMutationAllowed(context, view.run.project_id, view.run.id);
266
269
  const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
267
270
  if (!existing) {
268
271
  throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
@@ -19,6 +19,14 @@ export function ingestOpenCodeUsage(context, input) {
19
19
  export function ingestAgyUsage(context, input) {
20
20
  return ingestHarnessUsage(context, input, { harness: "antigravity-cli", label: "Antigravity", idPrefix: "AUSG", sourceKind: "antigravity_cli_session_usage_v1" });
21
21
  }
22
+ /** Droid counters must describe one physical Session; inclusive roots double-count children. */
23
+ export function ingestDroidUsage(context, input) {
24
+ const payload = parseJsonObject(input.stdin || "{}", "Droid usage snapshot");
25
+ if (payload.usage_scope !== "physical_session" || !["complete", "partial"].includes(String(payload.completeness))) {
26
+ throw new Error("Droid usage requires physical_session scope and complete or partial completeness");
27
+ }
28
+ return ingestHarnessUsage(context, input, { harness: "droid-cli", label: "Droid", idPrefix: "DUSG", sourceKind: "droid_session_usage_v1" });
29
+ }
22
30
  function ingestHarnessUsage(context, input, harness) {
23
31
  const payload = parseJsonObject(input.stdin || "{}", `${harness.label} usage snapshot`);
24
32
  const usage = object(payload.usage);
@@ -262,9 +270,9 @@ function toolCallsForLinks(context, links, observedAt) {
262
270
  result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length ? "partial" : "measured";
263
271
  return result;
264
272
  }
265
- function isExternalHarness(harness) { return harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
273
+ function isExternalHarness(harness) { return harness === "droid-cli" || harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
266
274
  function harnessSourceKind(harness) {
267
- return harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
275
+ return harness === "droid-cli" ? "droid_session_usage_v1" : harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
268
276
  }
269
277
  function readHarnessToolCallsWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
270
278
  if (!providerSessionId)
@@ -425,7 +433,7 @@ function hasInclusiveUsageSnapshot(context, projectId, harness, providerSessionI
425
433
  function readHarnessUsageWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
426
434
  if (!providerSessionId)
427
435
  return { status: "not_observable", diagnostic: "provider_session_id_missing" };
428
- const snapshots = context.db.all(`SELECT observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
436
+ const snapshots = context.db.all(`SELECT completeness, observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
429
437
  FROM harness_usage_snapshots
430
438
  WHERE project_id = ? AND harness = ? AND provider_session_id = ? AND observed_at <= ?
431
439
  ORDER BY observed_at, id`, [projectId, harness, providerSessionId, observedAt]);
@@ -434,6 +442,8 @@ function readHarnessUsageWindow(context, harness, projectId, providerSessionId,
434
442
  const before = snapshots.filter((snapshot) => Date.parse(snapshot.observed_at) <= Date.parse(startedAt)).at(-1);
435
443
  if (!last || !before || last === before)
436
444
  return { status: "not_yet_emitted", diagnostic: `${harness}_usage_window_incomplete` };
445
+ if (harness === "droid-cli" && (last.completeness !== "complete" || before.completeness !== "complete"))
446
+ return { status: "not_observable", diagnostic: "droid-cli_usage_window_partial" };
437
447
  const delta = (value, baseline) => value === null || baseline === null ? null : Math.max(0, value - baseline);
438
448
  const input = delta(last.input_tokens, before.input_tokens);
439
449
  const cacheRead = delta(last.cache_read_input_tokens, before.cache_read_input_tokens);
@@ -135,3 +135,43 @@ export async function reconcileVnextFanout(context, input) {
135
135
  appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_reconciliation", stage: input.stage, observations: value, issues, reconciled });
136
136
  return { ok: true, issues, reconciled };
137
137
  }
138
+ /**
139
+ * Records every terminal native child at the Stage where it is first observed.
140
+ * This is separate from fan-out settlement: a historical child can be valid
141
+ * evidence for an earlier Work, while an unbound terminal child is retained
142
+ * as an inconsistency instead of being rediscovered in a later review wave.
143
+ */
144
+ export function observeVnextNativeChildren(context, input) {
145
+ const value = input.observations;
146
+ if (!value || typeof value.harness_id !== "string" || !value.harness_id || typeof value.parent_session_id !== "string" || !Array.isArray(value.children)
147
+ || value.children.some(child => !child || typeof child.session_id !== "string" || !["running", "unknown", "completed", "failed", "cancelled"].includes(String(child.status))))
148
+ throw new AppError("usage", "Expected native observations: harness_id, parent_session_id and children[{session_id,status}]", 2);
149
+ if (new Set(value.children.map(child => child.session_id)).size !== value.children.length)
150
+ throw new AppError("usage", "Native observations must contain each child Session exactly once", 2);
151
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
152
+ const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
153
+ const issues = [], history = [], pending = [];
154
+ for (const child of value.children) {
155
+ const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
156
+ const links = context.db.all(`SELECT ws.id, ws.work_id, ws.status, w.status AS work_status, w.parent_work_id, s.current_stage
157
+ FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id
158
+ JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
159
+ WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? AND s.parent_session_id = ? ORDER BY ws.created_at`, [project.id, input.runId, sessionId, parent]);
160
+ if (child.status === "running" || child.status === "unknown") {
161
+ pending.push({ session_id: child.session_id, outcome: child.status, links: links.map(link => ({ work_id: link.work_id, work_session_id: link.id })) });
162
+ continue;
163
+ }
164
+ if (!links.length) {
165
+ issues.push({ code: "unbound_native_delegation_detected", session_id: child.session_id, outcome: child.status, detected_at_stage: input.stage, origin_stage: "unknown" });
166
+ continue;
167
+ }
168
+ if (links.length !== 1) {
169
+ issues.push({ code: "child_execution_ambiguous", session_id: child.session_id, outcome: child.status, detected_at_stage: input.stage, work_ids: links.map(link => link.work_id) });
170
+ continue;
171
+ }
172
+ const link = links[0];
173
+ history.push({ session_id: child.session_id, outcome: child.status, work_id: link.work_id, work_session_id: link.id, work_status: link.work_status, origin_stage: link.current_stage ?? "unknown", detected_at_stage: input.stage });
174
+ }
175
+ appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_observation", stage: input.stage, observations: value, issues, history, pending });
176
+ return { ok: true, issues, history, pending };
177
+ }