@deksden-com/dd-flow-cli 0.9.0-beta.18 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.19
4
+
5
+ ### Patch Changes
6
+
7
+ - Require a fresh trusted hook receipt from the physical Work owner before terminal stage or Work lifecycle commands can mutate a vNext RUN. Record terminal native-child observations at their originating Stage so unbound delegation is retained as a diagnostic instead of contaminating a later fan-out wave.
8
+
3
9
  ## 0.9.0-beta.18
4
10
 
5
11
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.18",
4
- "cli_commit": "5624d92e8e96732db6a81f1cf86fb5342f38401f",
5
- "built_at": "2026-09-06T03:05:30.379Z",
3
+ "cli_version": "0.9.0-beta.19",
4
+ "cli_commit": "02648aa2db37e9332089ca941b06f5735b563496",
5
+ "built_at": "2026-09-06T13:33:22.164Z",
6
6
  "built_with_canon": {
7
- "version": "4.0.5",
8
- "commit": "272579e9035b88772a0f017c9bec09f877c17552",
7
+ "version": "4.0.6",
8
+ "commit": "2aafb30d23e3f646b736b7ead25438cab97b16bf",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
10
  "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
11
  "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
package/dist/cli/help.js CHANGED
@@ -155,7 +155,8 @@ Usage:
155
155
  dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
156
156
  dd-flow run list --project-root <root> --json
157
157
  dd-flow run prepare-vnext-specify --project-root <root> --slug <slug> --json
158
- dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate|--incomplete) --project-root <root> --output <directory> --json
158
+ dd-flow run recovery inspect|begin|seal|capture|resume <RUN-ID|RUN-short-id> --project-root <root> --json
159
+ dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate|--incomplete|--recovery-id <RCV-ID>) --project-root <root> --output <directory> --json
159
160
  dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> --json
160
161
  dd-flow run snapshot bootstrap create --project-root <root> --output <directory> --json
161
162
  dd-flow run snapshot bootstrap restore --snapshot <directory> --project-root <prepared-root> --json
@@ -170,7 +171,7 @@ Usage:
170
171
 
171
172
  RUN-* is the execution envelope for one concrete flow launch. Semantic truth remains in protocol, experiment, DEF, scenario, evidence, and Memory Bank documents. New run artifacts are stored under DD_FLOW_HOME/projects/<PRJ-ID-slug>/runs/<RUN-ID-slug>/ with one run.json projection and an append-only timeline.jsonl. Timeline is the compact full run report: summary is always present, while --hide may suppress bulky sections. Use \`dd-flow stat usage --run …\` to reread registered local Codex transcript counters and tool-call facts and refresh the current usage projection; unavailable data is explicit rather than zero.
172
173
 
173
- run prepare-vnext-specify allocates an otherwise unstarted vNext RUN for the canonical SPECIFY entry checkpoint. run snapshot is the eval-only checkpoint boundary: it snapshots one quiescent RUN, its dedicated DD_FLOW_HOME, and the exact project tree including its Git repository, then restores them into a fresh dedicated runtime. It is not a general runtime backup command and fails closed for a shared runtime.
174
+ run prepare-vnext-specify allocates an otherwise unstarted vNext RUN for the canonical SPECIFY entry checkpoint. run recovery makes an interrupted RUN read-only, requires an explicit settled-tree receipt, captures immutable recovery evidence, then explicitly returns delegated Work to the ready graph. run snapshot is the eval-only checkpoint boundary: it snapshots one quiescent RUN, its dedicated DD_FLOW_HOME, and the exact project tree including its Git repository, then restores them into a fresh dedicated runtime. It is not a general runtime backup command and fails closed for a shared runtime.
174
175
 
175
176
  Examples:
176
177
  dd-flow run start --project-root "$PWD" --flow-kind mb_sdlc --subject-type protocol --subject-id PRT-001-demo --slug demo --json
@@ -192,6 +193,7 @@ Usage:
192
193
  dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
193
194
  dd-flow stage fanout dispatch <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
194
195
  dd-flow stage fanout reconcile <RUN-ID> --project-root <root> --stage <name> --observations-file <native-observations.json> --json
196
+ dd-flow stage native observe <RUN-ID> --project-root <root> --stage <name> --observations-file <native-observations.json> --json
195
197
  dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--semantic-file <RUN-local-json>] --json
196
198
  dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage specify (--result-file <RUN-local-specify.json>|--result-stdin) --outcome <specified|failed|cancelled> --json
197
199
 
@@ -41,14 +41,15 @@ import { addVnextCodeRepair, finishVnextCode, startVnextCode } from "../services
41
41
  import { addVnextCodeReviewRepair, finishVnextCodeReview, isVnextCodeReviewRun, startVnextCodeReview } from "../services/vnext-code-review.js";
42
42
  import { applyVnextMerge, finishVnextMerge, getVnextMergeRequest, isVnextMergeRun, repairVnextMerge, routeVnextMergeRequest, startVnextMerge } from "../services/vnext-merge.js";
43
43
  import { mergeServerStatus, serveMergeRequests, stopMergeServer } from "../services/merge-server.js";
44
- import { getVnextFanoutStatus, reconcileVnextFanout } from "../services/vnext-fanout.js";
44
+ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout } from "../services/vnext-fanout.js";
45
45
  import { pauseStageForUser, resumeStageAfterUser } from "../services/stage-pause.js";
46
46
  import { blockStageForRuntime, unblockStageAfterRuntimeRepair } from "../services/stage-blocker.js";
47
47
  import { classifyCliOperation, requiresExplicitUpgradeAuthorization } from "../services/cli-operation-classifier.js";
48
48
  import { renderWorkerPrompt } from "../services/prompts.js";
49
49
  import { finishStage, startStage } from "../services/stage-lifecycle.js";
50
- import { addWorkBatch, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
50
+ import { addWorkBatch, assertStageLifecycleOwner, assertWorkLifecycleOwner, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
51
51
  import { createEvalBootstrapSnapshot, createEvalRunSnapshot, prepareVnextSpecifyRun, restoreEvalBootstrapSnapshot, restoreEvalRunSnapshot } from "../services/eval-snapshots.js";
52
+ import { beginRunRecovery, inspectRunRecovery, recordRecoveryCapture, resumeRunRecovery, sealRunRecovery } from "../services/run-recovery.js";
52
53
  import { loadExternalStageContext } from "../services/stage-context.js";
53
54
  import { stopManagedProcess, confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
54
55
  import { startRuntimeService } from "../services/runtime-service.js";
@@ -670,6 +671,11 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
670
671
  }
671
672
  throw new AppError("usage", "Use: dd-flow stage fanout <status|dispatch|reconcile> <RUN> --stage <stage> --project-root <path>; reconcile requires --observations-file <native-observations.json>", 2);
672
673
  }
674
+ if (family === "stage" && command === "native") {
675
+ if (requiredPosition(parsed, 0, "native action") !== "observe")
676
+ throw new AppError("usage", "Use: dd-flow stage native observe <RUN> --stage <stage> --observations-file <native-observations.json> --project-root <path>", 2);
677
+ return observeVnextNativeChildren(context, { projectRoot: requiredScopeProjectRoot(scopeProjectRoot, "stage native observe"), runId: requiredPosition(parsed, 1, "run-id"), stage: requiredOption(parsed, "stage"), observations: JSON.parse(fs.readFileSync(requiredOption(parsed, "observations-file"), "utf8")) });
678
+ }
673
679
  if (family === "run" && command === "capacity") {
674
680
  if (requiredPosition(parsed, 0, "capacity action") !== "record")
675
681
  throw new AppError("usage", "Use: dd-flow run capacity record <RUN> --available-slots <0..15> --project-root <path>", 2);
@@ -765,11 +771,18 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
765
771
  const resultStdin = hasOption(parsed, "result-stdin");
766
772
  if (Boolean(resultFile) === resultStdin)
767
773
  throw new AppError("usage", "work finish requires exactly one of --result-file or --result-stdin", 2);
774
+ const workId = requiredPosition(parsed, 0, "work-id");
775
+ const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work finish");
776
+ assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_finish", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
768
777
  const result = resultFile ? fs.readFileSync(path.resolve(resultFile), "utf8") : await readStdin(io.stdin);
769
- return await finishWork(context, requiredPosition(parsed, 0, "work-id"), result, commandProgress(io, output));
778
+ return await finishWork(context, workId, result, commandProgress(io, output));
779
+ }
780
+ if (family === "work" && command === "fail") {
781
+ const workId = requiredPosition(parsed, 0, "work-id");
782
+ const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work fail");
783
+ assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_fail", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
784
+ return failWork(context, workId, requiredOption(parsed, "reason"));
770
785
  }
771
- if (family === "work" && command === "fail")
772
- return failWork(context, requiredPosition(parsed, 0, "work-id"), requiredOption(parsed, "reason"));
773
786
  if (family === "work" && command === "cancel")
774
787
  return cancelWork(context, requiredPosition(parsed, 0, "work-id"), requiredOption(parsed, "reason"));
775
788
  if (family === "work" && command === "retry")
@@ -882,8 +895,17 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
882
895
  const resultStdin = hasOption(parsed, "result-stdin");
883
896
  const retryCheckId = optionalOption(parsed, "retry-check");
884
897
  const retryReason = optionalOption(parsed, "reason");
898
+ const hookEventId = optionalOption(parsed, "hook-event-id");
885
899
  if (retryCheckId && !retryReason?.trim())
886
900
  throw new AppError("usage", "--retry-check requires --reason explaining the recovered environment", 2);
901
+ const protectedLifecycle = (stage === "specify" && isVnextSpecifyRun(context, { projectRoot, runId }))
902
+ || (stage === "protocolize" && isVnextProtocolizeRun(context, { projectRoot, runId }))
903
+ || (stage === "plan" && isVnextPlanRun(context, { projectRoot, runId }))
904
+ || (stage === "plan-review" && isVnextPlanReviewRun(context, { projectRoot, runId }))
905
+ || (stage === "code" && isVnextPlanRun(context, { projectRoot, runId }))
906
+ || (stage === "code-review" && isVnextCodeReviewRun(context, { projectRoot, runId }));
907
+ if (protectedLifecycle)
908
+ assertStageLifecycleOwner(context, { projectRoot, runId, stage, operation: "stage_finish", ...(hookEventId ? { hookEventId } : {}) });
887
909
  if (stage === "specify" && isVnextSpecifyRun(context, { projectRoot, runId })) {
888
910
  const outcome = optionalOption(parsed, "outcome");
889
911
  if (!outcome || !["specified", "failed", "cancelled"].includes(outcome)) {
@@ -949,11 +971,23 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
949
971
  if (family === "stage" && command === "pause") {
950
972
  if (!hasOption(parsed, "question-stdin"))
951
973
  throw new AppError("usage", "stage pause requires --question-stdin", 2);
974
+ const projectRoot = requiredOption(parsed, "project-root");
975
+ const runId = requiredPosition(parsed, 0, "run-id");
976
+ const stage = requiredOption(parsed, "stage");
977
+ const workId = requiredOption(parsed, "work");
978
+ const protectedLifecycle = (stage === "specify" && isVnextSpecifyRun(context, { projectRoot, runId }))
979
+ || (stage === "protocolize" && isVnextProtocolizeRun(context, { projectRoot, runId }))
980
+ || (stage === "plan" && isVnextPlanRun(context, { projectRoot, runId }))
981
+ || (stage === "plan-review" && isVnextPlanReviewRun(context, { projectRoot, runId }))
982
+ || (stage === "code" && isVnextPlanRun(context, { projectRoot, runId }))
983
+ || (stage === "code-review" && isVnextCodeReviewRun(context, { projectRoot, runId }));
984
+ if (protectedLifecycle)
985
+ assertStageLifecycleOwner(context, { projectRoot, runId, stage, workId, operation: "stage_pause", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
952
986
  return pauseStageForUser(context, {
953
- projectRoot: requiredOption(parsed, "project-root"),
954
- runId: requiredPosition(parsed, 0, "run-id"),
955
- stage: requiredOption(parsed, "stage"),
956
- workId: requiredOption(parsed, "work"),
987
+ projectRoot,
988
+ runId,
989
+ stage,
990
+ workId,
957
991
  question: await readStdin(io.stdin)
958
992
  });
959
993
  }
@@ -1429,6 +1463,22 @@ function dispatchSession(context, command, parsed) {
1429
1463
  throw new AppError("usage", `Unknown session command: ${command ?? "<empty>"}`, 2);
1430
1464
  }
1431
1465
  function dispatchRun(context, command, parsed) {
1466
+ if (command === "recovery") {
1467
+ const action = requiredPosition(parsed, 0, "recovery action");
1468
+ const projectRoot = requiredOption(parsed, "project-root");
1469
+ const runId = requiredPosition(parsed, 1, "run-id");
1470
+ if (action === "inspect")
1471
+ return inspectRunRecovery(context, { projectRoot, runId });
1472
+ if (action === "begin")
1473
+ return beginRunRecovery(context, { projectRoot, runId, interruption: requiredOption(parsed, "interruption-json") });
1474
+ if (action === "seal")
1475
+ return sealRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), settlement: requiredOption(parsed, "settlement-json") });
1476
+ if (action === "capture")
1477
+ return recordRecoveryCapture(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), capturePath: requiredOption(parsed, "capture-path") });
1478
+ if (action === "resume")
1479
+ return resumeRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id") });
1480
+ throw new AppError("usage", "Use: dd-flow run recovery inspect|begin|seal|capture|resume", 2);
1481
+ }
1432
1482
  if (command === "snapshot") {
1433
1483
  const action = requiredPosition(parsed, 0, "snapshot action");
1434
1484
  if (action === "bootstrap") {
@@ -1448,9 +1498,10 @@ function dispatchRun(context, command, parsed) {
1448
1498
  if (action === "create") {
1449
1499
  const candidate = parsed.options.has("candidate");
1450
1500
  const incomplete = parsed.options.has("incomplete");
1501
+ const recoveryId = optionalOption(parsed, "recovery-id");
1451
1502
  const stageEntry = optionalOption(parsed, "stage-entry");
1452
- if (Number(candidate) + Number(incomplete) + Number(stageEntry !== undefined) !== 1)
1453
- throw new AppError("usage", "Use exactly one of --candidate, --incomplete or --stage-entry", 2);
1503
+ if (Number(candidate) + Number(incomplete) + Number(stageEntry !== undefined) + Number(recoveryId !== undefined) !== 1)
1504
+ throw new AppError("usage", "Use exactly one of --candidate, --incomplete, --recovery-id or --stage-entry", 2);
1454
1505
  const input = {
1455
1506
  projectRoot: requiredOption(parsed, "project-root"),
1456
1507
  runId: requiredPosition(parsed, 1, "run-id"),
@@ -1458,6 +1509,8 @@ function dispatchRun(context, command, parsed) {
1458
1509
  };
1459
1510
  if (incomplete)
1460
1511
  return createEvalRunSnapshot(context, { ...input, incomplete: true });
1512
+ if (recoveryId)
1513
+ return createEvalRunSnapshot(context, { ...input, recoveryId });
1461
1514
  return candidate
1462
1515
  ? createEvalRunSnapshot(context, { ...input, candidate: true })
1463
1516
  : createEvalRunSnapshot(context, { ...input, stageEntry: stageEntry });
@@ -55,7 +55,13 @@ export function createEvalRunSnapshot(context, input) {
55
55
  const project = requireProjectByRoot(context, projectRoot);
56
56
  const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
57
57
  assertDedicatedHome(context, project.id, input.runId);
58
- if (input.incomplete) { /* Forensic evidence is not an accepted or restorable boundary. */ }
58
+ const recovery = typeof input.recoveryId === "string";
59
+ if (recovery) {
60
+ const guard = context.db.get("SELECT status, recovery_id FROM run_recovery_guards WHERE project_id = ? AND run_id = ? AND recovery_id = ?", [project.id, input.runId, input.recoveryId]);
61
+ if (!guard || guard.status !== "sealed")
62
+ throw new AppError("recovery_invalid_state", "Recovery snapshots require the matching sealed recovery guard", 1, { run_id: input.runId, recovery_id: input.recoveryId ?? null, status: guard?.status ?? null });
63
+ }
64
+ if (input.incomplete || recovery) { /* Recovery captures only after the runner seals all writers. */ }
59
65
  else if (input.candidate)
60
66
  assertTerminalCandidate(status.index.stage_runs ?? []);
61
67
  else if (input.stageEntry)
@@ -66,7 +72,7 @@ export function createEvalRunSnapshot(context, input) {
66
72
  const activeChildren = input.candidate
67
73
  ? context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND status IN ('created', 'running', 'paused')", [project.id, input.runId])?.count ?? 0
68
74
  : context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? IS NULL OR COALESCE(result_schema, '') NOT LIKE ?)))", [project.id, input.runId, allowedCreatedResultSchema, allowedCreatedResultSchema ? `${allowedCreatedResultSchema}@%` : ""])?.count ?? 0;
69
- if (!input.incomplete && activeChildren > 0)
75
+ if (!input.incomplete && !recovery && activeChildren > 0)
70
76
  throw new AppError("snapshot_not_quiescent", `${input.candidate ? "Candidate" : "Stage-entry"} snapshot requires no active child Work`, 1, { run_id: input.runId, active_children: activeChildren });
71
77
  const output = path.resolve(input.output);
72
78
  if (fs.existsSync(output))
@@ -103,9 +109,9 @@ export function createEvalRunSnapshot(context, input) {
103
109
  project_id: project.id,
104
110
  project_root: projectRoot,
105
111
  dd_flow_home: context.ddFlowHome,
106
- purpose: input.incomplete ? "incomplete" : input.candidate ? "candidate" : "stage_entry",
107
- stage_entry: input.incomplete || input.candidate ? null : input.stageEntry,
108
- ...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : {}),
112
+ purpose: input.incomplete ? "incomplete" : recovery ? "recovery" : input.candidate ? "candidate" : "stage_entry",
113
+ stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
114
+ ...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : recovery ? { recovery_id: input.recoveryId, consistency: "sealed_writer_barrier_required", active_child_work_count: activeChildren, restorable: "same-runtime-or-explicit-restore" } : {}),
109
115
  created_at: context.now(),
110
116
  runtime_sha256: treeHash(path.join(output, "runtime")),
111
117
  project_sha256: treeHash(path.join(output, "project")),
@@ -129,6 +135,8 @@ export function restoreEvalRunSnapshot(context, input) {
129
135
  const manifest = readManifest(snapshot);
130
136
  if (manifest.purpose === "incomplete")
131
137
  throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
138
+ if (manifest.purpose === "recovery")
139
+ throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require the recovery runner; they are not a generic stage-entry fixture", 1, { snapshot });
132
140
  if (manifest.purpose === "candidate")
133
141
  throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
134
142
  if (!manifest.stage_entry)
@@ -383,7 +391,7 @@ function readManifest(snapshot) {
383
391
  if (!fs.existsSync(file))
384
392
  throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
385
393
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
386
- if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
394
+ if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete", "recovery"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
387
395
  throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
388
396
  if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
389
397
  throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
@@ -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,7 +581,7 @@ 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
  }
@@ -635,7 +646,7 @@ function handleControlledToolEvent(context, input, event, harness) {
635
646
  const lifecycle = lifecycleFacts(command);
636
647
  if (!lifecycle)
637
648
  return { ok: true, observed: false, reason: "event_not_participating" };
638
- if (lifecycle.analysis.kind === "compound") {
649
+ if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
639
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 });
640
651
  }
641
652
  const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
@@ -747,25 +758,39 @@ function lifecycleFacts(command) {
747
758
  return null;
748
759
  const invocation = analysis.invocation;
749
760
  const stageStart = invocation.operation === "stage_start";
761
+ const stageFinish = invocation.operation === "stage_finish";
762
+ const stagePause = invocation.operation === "stage_pause";
750
763
  const stageResume = invocation.operation === "stage_resume";
751
764
  const workStart = invocation.operation === "work_start";
765
+ const workFinish = invocation.operation === "work_finish";
766
+ const workFail = invocation.operation === "work_fail";
752
767
  return {
753
768
  analysis,
754
769
  invocation,
755
770
  flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
756
771
  stageStart,
772
+ stageFinish,
773
+ stagePause,
757
774
  stageResume,
758
- workStart,
775
+ workStart, workFinish, workFail,
759
776
  bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
760
777
  commandProjectRoot: commandOption(invocation, "project-root"),
761
- stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
762
- runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
763
- 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,
764
781
  contextSha256: commandOption(invocation, "context-sha256")
765
782
  };
766
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
+ }
767
792
  function lifecycleMatchKey(facts, projectRoot) {
768
- 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;
769
794
  if (bootstrapStageStart)
770
795
  return bootstrapMatchKey({
771
796
  projectRoot,
@@ -775,12 +800,58 @@ function lifecycleMatchKey(facts, projectRoot) {
775
800
  });
776
801
  if (stageStart && runId && stageName)
777
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);
778
807
  if (stageResume && runId && stageName && workId)
779
808
  return stageResumeMatchKey(runId, stageName, workId, projectRoot);
780
809
  if (workStart && workId)
781
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);
782
815
  return null;
783
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
+ }
784
855
  /** Trusted session identity is created by PreToolUse, never supplied by an agent. */
785
856
  export function sessionIdForHookEvent(context, projectId, eventKey) {
786
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 });
@@ -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
+ }
@@ -2,20 +2,78 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
5
+ import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
6
6
  import { validateSchema } from "./schema-validation.js";
7
7
  import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
8
8
  import { refreshRunSessionProjection } from "./run-projection.js";
9
9
  import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
10
10
  import { nextWorkId, nextWorkIds } from "./ids.js";
11
11
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
+ import { appendAudit } from "./audit.js";
12
13
  import { flowCommand } from "./stage-pause.js";
13
14
  import { assertPortableArtifactRef } from "./portable-refs.js";
14
15
  import { publicSessionIdentity } from "./session-identity.js";
16
+ import { activateRecoveryWork, assertRunMutationAllowed, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
15
17
  const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
16
18
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
19
+ /**
20
+ * A Stage terminal command is allowed only from the Session that owns its
21
+ * running coordinator Work. A hook receipt proves the caller; a WorkSession
22
+ * proves the owner. Either fact alone is insufficient.
23
+ */
24
+ export function assertStageLifecycleOwner(context, input) {
25
+ ensureWorkRegistry(context);
26
+ const projectRoot = resolveProjectRoot(input.projectRoot);
27
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
28
+ if (!project)
29
+ throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
30
+ requireRun(context, project.id, input.runId);
31
+ const owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id
32
+ FROM work_sessions ws
33
+ JOIN works w ON w.work_id = ws.work_id
34
+ JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
35
+ WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
36
+ AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
37
+ ${input.workId ? "AND w.work_id = ?" : ""}
38
+ ORDER BY ws.created_at DESC LIMIT 1`, input.workId ? [project.id, input.runId, input.stage, input.workId] : [project.id, input.runId, input.stage]);
39
+ if (!owner)
40
+ throw new AppError("trusted_session_binding_required", "Stage lifecycle command has no running coordinator Work/Session binding", 1, { run_id: input.runId, stage: input.stage, ...(input.workId ? { work_id: input.workId } : {}) });
41
+ const identity = claimStageLifecycleHookEvent(context, {
42
+ projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
43
+ runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
44
+ });
45
+ if (identity.sessionId === owner.session_id)
46
+ return identity;
47
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
48
+ const payload = { run_id: input.runId, stage: input.stage, work_id: owner.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
49
+ appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "lifecycle_caller_mismatch", ...payload });
50
+ appendAudit(context, { projectId: project.id, eventType: "stage.lifecycle_caller_rejected", payload });
51
+ throw new AppError("lifecycle_caller_mismatch", "Stage lifecycle command was issued by a Session that does not own the running Work", 1, payload);
52
+ }
53
+ /** A Work terminal command belongs to the Session that started that Work.
54
+ * Controllers can still use their explicit non-agent operations; they cannot
55
+ * borrow a native worker's authority by naming its Work id. */
56
+ export function assertWorkLifecycleOwner(context, input) {
57
+ ensureWorkRegistry(context);
58
+ const work = requireWork(context, input.workId);
59
+ const run = requireRun(context, work.project_id, work.run_id);
60
+ if (resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
61
+ throw new AppError("project_mismatch", "Work lifecycle project root does not match its RUN", 1, { work_id: work.work_id });
62
+ const owner = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
63
+ if (!owner)
64
+ throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
65
+ const identity = claimWorkLifecycleHookEvent(context, { projectId: work.project_id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation, workId: work.work_id, projectRoot: run.project_root });
66
+ if (identity.sessionId === owner.session_id)
67
+ return identity;
68
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
69
+ const payload = { run_id: work.run_id, work_id: work.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
70
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_lifecycle_caller_mismatch", ...payload });
71
+ appendAudit(context, { projectId: work.project_id, eventType: "work.lifecycle_caller_rejected", payload });
72
+ throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
73
+ }
17
74
  export function createChildWork(context, input) {
18
75
  const parent = requireWork(context, input.parentWorkId);
76
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
19
77
  if (parent.status !== "running")
20
78
  throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
21
79
  const id = nextWorkId(context, parent.project_id, input.slug);
@@ -46,6 +104,7 @@ export function validateWorkBatchFile(file) {
46
104
  export function addWorkBatch(context, input) {
47
105
  ensureWorkRegistry(context);
48
106
  const parent = requireWork(context, input.parentWorkId);
107
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
49
108
  if (parent.status !== "running")
50
109
  throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
51
110
  const parsed = readJson(input.file);
@@ -118,6 +177,7 @@ export function mutateWorkDeps(context, input) {
118
177
  const work = requireWork(context, input.workId);
119
178
  if (input.action === "list")
120
179
  return { work_id: work.work_id, depends_on: parseDependencies(work) };
180
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
121
181
  if (work.status !== "created")
122
182
  throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
123
183
  const dependencies = new Set(parseDependencies(work));
@@ -139,7 +199,7 @@ export function mutateWorkDeps(context, input) {
139
199
  refreshRunWorkProjection(context, work.project_id, work.run_id);
140
200
  return { work_id: work.work_id, depends_on: [...dependencies] };
141
201
  }
142
- export function deleteWork(context, id) { const work = requireWork(context, id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
202
+ export function deleteWork(context, id) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
143
203
  throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }
144
204
  export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
145
205
  /**
@@ -152,6 +212,7 @@ export function workStartCommand(context, work) { const run = requireRun(context
152
212
  export function startWork(context, id, input) {
153
213
  ensureWorkRegistry(context);
154
214
  const work = requireWork(context, id);
215
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
155
216
  if (work.status !== "created")
156
217
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
157
218
  if (!isReady(context, work)) {
@@ -179,6 +240,7 @@ export function startWork(context, id, input) {
179
240
  /** Binds the already-running coordinator of a stage to its trusted hook Session. */
180
241
  export function bindRunningWorkSession(context, input) {
181
242
  const work = requireWork(context, input.workId);
243
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
182
244
  if (work.status !== "running")
183
245
  throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
184
246
  const run = requireRun(context, work.project_id, work.run_id);
@@ -231,6 +293,7 @@ export function bindRunningWorkSession(context, input) {
231
293
  /** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
232
294
  export function bindStageCoordinatorWork(context, input) {
233
295
  const work = requireWork(context, input.workId);
296
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
234
297
  const run = requireRun(context, work.project_id, work.run_id);
235
298
  claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
236
299
  const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
@@ -242,6 +305,7 @@ export function bindStageCoordinatorWork(context, input) {
242
305
  /** Starts a created coordinator Work from its trusted `stage start` event. */
243
306
  export function startStageCoordinatorWork(context, input) {
244
307
  const work = requireWork(context, input.workId);
308
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
245
309
  if (work.status !== "created")
246
310
  throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
247
311
  const run = requireRun(context, work.project_id, work.run_id);
@@ -256,6 +320,7 @@ export function startStageCoordinatorWork(context, input) {
256
320
  }
257
321
  /** Stage entry points call this only after their own trusted hook claim. */
258
322
  export function startBoundWork(context, work, run, identity, hookEventId) {
323
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
259
324
  if (work.status !== "created")
260
325
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
261
326
  const now = context.now();
@@ -266,10 +331,13 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
266
331
  const resultPath = path.join(directory, "result.json");
267
332
  const contextPath = path.join(directory, "context.json");
268
333
  const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
269
- const prompt = renderWorkerPrompt(context, work, run, dependencyResults);
334
+ const recovery = recoveryPromptForWork(context, work.work_id);
335
+ let prompt = renderWorkerPrompt(context, work, run, dependencyResults);
336
+ if (recovery)
337
+ prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
270
338
  const payload = parsePayload(work);
271
339
  const readOnly = payload?.read_only === true;
272
- const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
340
+ const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
273
341
  const token = crypto.randomUUID();
274
342
  const promptCandidate = `${promptPath}.${token}.tmp`;
275
343
  const contextCandidate = `${contextPath}.${token}.tmp`;
@@ -294,17 +362,19 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
294
362
  fs.rmSync(contextCandidate, { force: true });
295
363
  throw error;
296
364
  }
365
+ if (recovery)
366
+ activateRecoveryWork(context, work.work_id);
297
367
  refreshRunWorkProjection(context, work.project_id, work.run_id);
298
- return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
368
+ return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
299
369
  }
300
370
  export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
301
371
  /** Structured fan-in closes a parent after its Session was handed to a child. */
302
- export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
372
+ export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
303
373
  throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
304
374
  throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
305
375
  export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
306
376
  export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
307
- export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
377
+ export function retryWork(context, id, reason) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); id = work.work_id; if (work.status !== "failed")
308
378
  throw new AppError("invalid_work_state", "Only failed Work may be retried", 2); const run = requireRun(context, work.project_id, work.run_id); const directory = path.join(requireRunHome(run), "works", id); const attempts = path.join(directory, "attempts"); const number = fs.existsSync(attempts) ? fs.readdirSync(attempts).filter((entry) => /^ATT-\d{3}$/.test(entry)).length + 1 : 1; const archive = path.join(attempts, `ATT-${String(number).padStart(3, "0")}`); fs.mkdirSync(archive, { recursive: true }); for (const file of ["prompt.md", "result.json", "context.json"]) {
309
379
  const source = path.join(directory, file);
310
380
  if (fs.existsSync(source))
@@ -312,6 +382,7 @@ export function retryWork(context, id, reason) { const work = requireWork(contex
312
382
  } context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ?", [context.now(), id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, work_id: id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason }; }
313
383
  async function settle(context, id, status, result, progress) {
314
384
  const work = requireWork(context, id);
385
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
315
386
  id = work.work_id;
316
387
  if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
317
388
  throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
@@ -367,6 +438,7 @@ async function settle(context, id, status, result, progress) {
367
438
  context.db.exec("ROLLBACK");
368
439
  throw error;
369
440
  }
441
+ settleRecoveryWork(context, work.work_id, status);
370
442
  refreshRunWorkProjection(context, work.project_id, work.run_id);
371
443
  const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
372
444
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
@@ -374,6 +446,7 @@ async function settle(context, id, status, result, progress) {
374
446
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
375
447
  return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
376
448
  }
449
+ function recoveryPrompt(recovery) { return ["<recovery_identity>", `- recovery_id: ${recovery.recoveryId}`, `- execution_generation: ${recovery.generation}`, "This Work was interrupted and is now an authorized recovery segment of the same assignment.", "</recovery_identity>", "", "<recovery_instructions>", `Read the retained pre-interruption evidence at ${recovery.evidencePath}.`, "Before changing files or rerunning a command, inspect the workspace, receipts, logs, and partial artifacts. Do not assume the last command succeeded or failed merely because its reply was lost.", "Preserve accepted work. Do not repeat completed Work, create undeclared Work, alter the original requirements, or edit dd-flow runtime state/evidence.", "Observe an existing check or finish instead of duplicating it. Retry only through an exact runtime retry command after establishing that its prior outcome permits it.", "Record the concrete unresolved operation rather than guessing if an external effect cannot be reconciled.", "</recovery_instructions>"].join("\n"); }
377
450
  function plannedAreaDrift(packet, result) {
378
451
  const changed = JSON.parse(result).changed_paths;
379
452
  if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
@@ -7,6 +7,20 @@ import { AppError } from "../shared/errors.js";
7
7
  const require = createRequire(import.meta.url);
8
8
  const { DatabaseSync } = require("node:sqlite");
9
9
  const resourceDatabases = new Map();
10
+ /** Recovery guards apply to every RUN kind, including custom eval fixtures. */
11
+ export function ensureRecoveryStorage(db) {
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS run_recovery_guards (
14
+ recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
15
+ );
16
+ CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
17
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
18
+ CREATE TABLE IF NOT EXISTS work_recovery_segments (
19
+ recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
20
+ );
21
+ CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
22
+ `);
23
+ }
10
24
  /** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
11
25
  export function ensureVnextWorkStorage(db) {
12
26
  const legacy = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('vnext_works', 'vnext_agent_turns', 'flow_agent_turns') LIMIT 1");
@@ -53,6 +67,16 @@ export function ensureVnextWorkStorage(db) {
53
67
  );
54
68
  CREATE INDEX IF NOT EXISTS idx_work_sessions_work ON work_sessions(work_id, created_at DESC);
55
69
  CREATE UNIQUE INDEX IF NOT EXISTS idx_work_sessions_open ON work_sessions(work_id) WHERE status = 'running';
70
+
71
+ CREATE TABLE IF NOT EXISTS run_recovery_guards (
72
+ recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
73
+ );
74
+ CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
75
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
76
+ CREATE TABLE IF NOT EXISTS work_recovery_segments (
77
+ recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
56
80
  CREATE TABLE IF NOT EXISTS check_receipts (
57
81
  id TEXT PRIMARY KEY,
58
82
  project_id TEXT NOT NULL,
@@ -95,8 +119,10 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
95
119
  configureDatabase(db, mode);
96
120
  // Internal additive schema changes must be available to every write command.
97
121
  // Higher-level Memory Bank migrations remain explicit in services/migrations.
98
- if (mode !== "read_existing")
122
+ if (mode !== "read_existing") {
99
123
  migrate(db, dbPath);
124
+ ensureRecoveryStorage(db);
125
+ }
100
126
  if (mode === "read_existing")
101
127
  db.exec("PRAGMA query_only = ON");
102
128
  db.exec("PRAGMA foreign_keys = ON");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.18",
3
+ "version": "0.9.0-beta.19",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {