@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.90

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.
Files changed (143) hide show
  1. package/CHANGELOG.md +460 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +304 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +115 -0
  8. package/dist/cli/run-cli.js +1409 -382
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  11. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  12. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  13. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  14. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  15. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  16. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  17. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  18. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  19. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  21. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  22. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  23. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  24. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  25. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  26. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  27. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  28. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  29. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  30. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  32. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  33. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  34. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  36. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  37. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  38. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  39. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  40. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  41. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  42. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  43. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  44. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  45. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  46. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  47. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  48. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  49. package/dist/schemas/agent-profile.schema.json +1 -1
  50. package/dist/schemas/code-review-result.schema.json +2 -2
  51. package/dist/schemas/code-work-batch.schema.json +4 -3
  52. package/dist/schemas/code-work-result.schema.json +4 -4
  53. package/dist/schemas/plan-review-result.schema.json +2 -2
  54. package/dist/schemas/run-control-receipt.schema.json +99 -0
  55. package/dist/schemas/run-control-request.schema.json +36 -0
  56. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  57. package/dist/services/canon.js +11 -8
  58. package/dist/services/cleanup.js +57 -13
  59. package/dist/services/cli-operation-classifier.js +22 -24
  60. package/dist/services/code-checks.js +543 -96
  61. package/dist/services/codex-hook-delivery.js +28 -0
  62. package/dist/services/command-context.js +80 -0
  63. package/dist/services/config.js +11 -8
  64. package/dist/services/continuation-outcome.js +20 -0
  65. package/dist/services/controller-fanout.js +132 -0
  66. package/dist/services/dashboard.js +103 -31
  67. package/dist/services/engines.js +121 -76
  68. package/dist/services/eval-snapshots.js +750 -73
  69. package/dist/services/execution-policy.js +170 -0
  70. package/dist/services/external-work-launch.js +110 -0
  71. package/dist/services/harness-adapter.js +147 -24
  72. package/dist/services/harness-config.js +2 -0
  73. package/dist/services/hooks.js +587 -299
  74. package/dist/services/lanes.js +61 -53
  75. package/dist/services/lifecycle-command.js +118 -7
  76. package/dist/services/lifecycle-invocations.js +1055 -0
  77. package/dist/services/managed-daemon-binding.js +38 -0
  78. package/dist/services/managed-processes.js +283 -27
  79. package/dist/services/merge-queue.js +179 -104
  80. package/dist/services/merge-server.js +73 -28
  81. package/dist/services/migrations.js +14 -9
  82. package/dist/services/native-daemon-history.js +49 -0
  83. package/dist/services/native-session-control.js +39 -0
  84. package/dist/services/plan-runtime.js +19 -8
  85. package/dist/services/plans.js +28 -25
  86. package/dist/services/projects.js +18 -5
  87. package/dist/services/prompts.js +23 -16
  88. package/dist/services/protocols.js +64 -20
  89. package/dist/services/recovery-observation-budget.js +61 -0
  90. package/dist/services/recovery-snapshot-database.js +107 -0
  91. package/dist/services/repair-intents.js +40 -0
  92. package/dist/services/run-control-receipt.js +56 -0
  93. package/dist/services/run-control-worker.js +363 -0
  94. package/dist/services/run-control.js +876 -0
  95. package/dist/services/run-controller-adapter.js +222 -0
  96. package/dist/services/run-controller-capture.js +134 -0
  97. package/dist/services/run-controller-process.js +189 -0
  98. package/dist/services/run-controller-recovery.js +222 -0
  99. package/dist/services/run-controller-state.js +36 -0
  100. package/dist/services/run-controller.js +789 -0
  101. package/dist/services/run-engine-bindings.js +1 -1
  102. package/dist/services/run-fork.js +138 -0
  103. package/dist/services/run-observations.js +112 -0
  104. package/dist/services/run-recovery-runtime.js +69 -0
  105. package/dist/services/run-recovery.js +343 -0
  106. package/dist/services/runs.js +368 -74
  107. package/dist/services/runtime-budget.js +434 -0
  108. package/dist/services/runtime-command.js +43 -0
  109. package/dist/services/runtime-scope-capture.js +50 -0
  110. package/dist/services/runtime-scope-control.js +450 -0
  111. package/dist/services/runtime-scope-resume.js +565 -0
  112. package/dist/services/runtime-scope-stop.js +99 -0
  113. package/dist/services/runtime-scope-worker.js +243 -0
  114. package/dist/services/runtime-service.js +97 -0
  115. package/dist/services/schema-validation.js +10 -8
  116. package/dist/services/sessions.js +35 -69
  117. package/dist/services/stage-blocker.js +17 -7
  118. package/dist/services/stage-context.js +44 -18
  119. package/dist/services/stage-lifecycle.js +99 -102
  120. package/dist/services/stage-pause.js +84 -55
  121. package/dist/services/stage-work-graph.js +35 -0
  122. package/dist/services/usage.js +37 -93
  123. package/dist/services/vnext-code-review.js +195 -88
  124. package/dist/services/vnext-code.js +285 -252
  125. package/dist/services/vnext-execution-profile.js +5 -3
  126. package/dist/services/vnext-fanout.js +160 -17
  127. package/dist/services/vnext-merge.js +291 -104
  128. package/dist/services/vnext-plan-review.js +139 -81
  129. package/dist/services/vnext-plan.js +138 -69
  130. package/dist/services/vnext-protocolize.js +129 -56
  131. package/dist/services/vnext-specify.js +88 -65
  132. package/dist/services/work-registry.js +716 -168
  133. package/dist/services/workspace-bootstrap.js +76 -0
  134. package/dist/services/worktrees.js +85 -35
  135. package/dist/shared/entity-references.js +8 -0
  136. package/dist/shared/errors.js +12 -0
  137. package/dist/storage/database.js +459 -32
  138. package/dist/storage/paths.js +17 -4
  139. package/dist/storage/work-references.js +23 -0
  140. package/dist/storage/writer-contract.js +77 -0
  141. package/dist/storage/writer-migration.js +102 -0
  142. package/package.json +24 -13
  143. package/tools/repair-paused-run-status.mjs +59 -0
@@ -5,19 +5,31 @@ import { spawnSync } from "node:child_process";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
6
  import { appendAudit } from "./audit.js";
7
7
  import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from "./protocols.js";
8
- import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
8
+ import { attachFlowRunStage, prepareFlowRunStart, prepareFlowRunStageAttachment, prepareStageDirectory, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
9
9
  import { validateSchema } from "./schema-validation.js";
10
10
  import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
11
- import { usageForRun } from "./usage.js";
12
- import { boundCanonicalPlan } from "./plan-runtime.js";
11
+ import { recalculateRunUsage, usageForRun } from "./usage.js";
12
+ import { boundCanonicalPlan, prepareCanonicalPlan } from "./plan-runtime.js";
13
+ import { prepareJsonFile, prepareJsonSchema, prepareOutputDirectory, prepareOutputFile, prepareTextFile } from "../cli/input-preparation.js";
13
14
  import { AppError } from "../shared/errors.js";
14
- import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
15
+ import { planJsonPath, projectHome, resolveProjectRoot } from "../storage/paths.js";
15
16
  import { resolveCanonRoot } from "./canon.js";
16
17
  import { preflightMemoryPermissions } from "./memory-permissions.js";
17
18
  import { registerProject } from "./projects.js";
18
19
  import { registerProtocol } from "./protocols.js";
19
20
  import { previewNextEntityId } from "./ids.js";
20
21
  import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
22
+ import { assertRunMutationAllowed } from "./run-recovery.js";
23
+ function stageUsageProjection(context, view, stage, startedAt) {
24
+ if (!startedAt)
25
+ return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0, observed_at: context.now() };
26
+ const value = recalculateRunUsage(context, { projectId: view.run.project_id, runId: view.run.id, stage: { name: stage, startedAt, completedAt: context.now() } });
27
+ const totals = value.totals, coverage = value.coverage;
28
+ const count = (name) => typeof coverage?.[name] === "number" ? coverage[name] : 0;
29
+ const measured = count("measured"), other = Object.values(coverage ?? {}).reduce((total, item) => total + (typeof item === "number" ? item : 0), 0) - measured;
30
+ const tokens = (name) => typeof totals?.[name] === "number" ? totals[name] : null;
31
+ return { status: measured === 0 ? "unavailable" : other > 0 || ["input_tokens", "cache_read_input_tokens", "output_tokens", "total_tokens"].some(name => tokens(name) === null) ? "partial" : "measured", input_tokens: tokens("input_tokens"), cached_input_tokens: tokens("cache_read_input_tokens"), output_tokens: tokens("output_tokens"), total_tokens: tokens("total_tokens"), session_count: Array.isArray(value.sessions) ? value.sessions.length : 0, observed_at: typeof value.observed_at === "string" ? value.observed_at : context.now() };
32
+ }
21
33
  const stagePromptSections = [
22
34
  "stage_identity",
23
35
  "authoritative_runtime_facts",
@@ -28,63 +40,30 @@ const stagePromptSections = [
28
40
  "work_contract",
29
41
  "completion_contract"
30
42
  ];
31
- function bootstrapStageRun(context, projectRoot, input) {
32
- if (input.stage !== "specify") {
33
- throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2, { stage: input.stage });
34
- }
35
- const subject = input.bootstrap?.subject.trim();
36
- if (!subject)
37
- throw new AppError("usage", "--bootstrap requires --subject", 2);
43
+ function bootstrapStageRun(context, projectRoot, bootstrap, intakeMarkdown) {
38
44
  registerProject(context, { root: projectRoot });
39
- const preview = previewNextEntityId(context, { projectRoot, type: "protocol", slug: subject });
40
- const protocolId = preview.entity.id;
41
- registerProtocol(context, { handshakeId: protocolId, projectRoot });
42
- const started = startFlowRun(context, {
43
- projectRoot,
44
- flowKind: "mb_sdlc",
45
- subjectType: "protocol",
46
- subjectId: protocolId,
47
- slug: subject,
48
- nextAction: "run_specify"
49
- });
50
- const intakeFile = input.bootstrap?.intakeFile;
51
- if (intakeFile) {
52
- const source = path.resolve(intakeFile);
53
- if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
54
- throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
55
- }
45
+ registerProtocol(context, { handshakeId: bootstrap.protocolId, projectRoot });
46
+ const started = startFlowRun(context, bootstrap.startInput, bootstrap.start);
47
+ if (intakeMarkdown !== undefined) {
56
48
  if (!started.run.run_root)
57
49
  throw new AppError("runtime_missing", "New RUN has no artifact root", 1, { run_id: started.run.id });
58
50
  const intakeDir = path.join(started.run.run_root, "intake");
59
51
  fs.mkdirSync(intakeDir, { recursive: true });
60
- fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
52
+ fs.writeFileSync(path.join(intakeDir, "user-request.md"), intakeMarkdown);
61
53
  }
62
54
  return started.run.id;
63
55
  }
64
- function stagePreflight(projectRoot, stageRoot) {
56
+ function stagePreflight(projectRoot) {
65
57
  const memoryBank = preflightMemoryPermissions({
66
58
  root: projectRoot,
67
59
  memoryBank: ".memory-bank",
68
60
  flow: "custom",
69
- mode: "write",
61
+ mode: "read",
70
62
  targets: [{ path: ".memory-bank", mode: "read" }]
71
63
  });
72
- const probe = path.join(stageRoot, `.dd-flow-permission-${crypto.randomUUID()}`);
73
- try {
74
- fs.writeFileSync(probe, "");
75
- fs.renameSync(probe, `${probe}.renamed`);
76
- fs.rmSync(`${probe}.renamed`);
77
- return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
78
- }
79
- catch (error) {
80
- for (const candidate of [probe, `${probe}.renamed`]) {
81
- try {
82
- fs.rmSync(candidate, { force: true });
83
- }
84
- catch { /* best effort */ }
85
- }
86
- return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
87
- }
64
+ if (memoryBank.ok !== true)
65
+ throw new AppError("permission_preflight_failed", "Stage project context is not readable", 1, { preflight: memoryBank });
66
+ return memoryBank;
88
67
  }
89
68
  function flowKindForStage(stage) {
90
69
  return ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning";
@@ -103,23 +82,57 @@ function runtimeFacts(view, projectRoot) {
103
82
  git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
104
83
  };
105
84
  }
106
- export function startStage(context, input) {
85
+ export function prepareLegacyStageStart(context, input, intakeMarkdown) {
86
+ if (input.bootstrap && input.stage !== "specify")
87
+ throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2);
88
+ if (input.bootstrap && !input.bootstrap.subject?.trim())
89
+ throw new AppError("usage", "--bootstrap requires --subject", 2);
90
+ if (!input.bootstrap && !input.runId)
91
+ throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
92
+ if (input.dir !== undefined)
93
+ prepareStageDirectory(input.dir);
94
+ const intake = intakeMarkdown ?? (input.bootstrap?.intakeFile ? prepareTextFile(input.bootstrap.intakeFile, "SPECIFY intake").text : undefined);
95
+ const projectRoot = resolveProjectRoot(input.projectRoot);
96
+ const retained = !input.bootstrap && input.runId ? runView(getFlowRunStatus(context, { projectRoot, runId: input.runId })) : null;
97
+ const dir = input.dir ?? defaultStageDir(input.stage, retained?.run.flow_kind ?? "mb_sdlc");
98
+ if (retained) {
99
+ prepareFlowRunStageAttachment(context, { projectRoot, runId: retained.run.id, stage: input.stage, dir, status: "running", dataSchemaId: dataSchemaForStage(input.stage) });
100
+ const root = path.join(runHomePath(retained.run), dir);
101
+ for (const name of ["stage-prompt.md", "stage-prompt.json", "stage-start.json", "stage-input.json"])
102
+ prepareOutputFile(path.join(root, name));
103
+ if (retained.run.subject.type === "protocol")
104
+ readProtocolRuntimeState(context, requireProtocol(context, retained.run.subject.id, retained.run.project_id));
105
+ }
106
+ const preflight = stagePreflight(projectRoot);
107
+ const sources = stageInstructionSources(context, projectRoot, retained?.run.flow_kind ?? "mb_sdlc", input.stage).map(source => ({ ...source, text: fs.existsSync(source.absolute) ? fs.readFileSync(source.absolute, "utf8").trim() : "(unavailable)" }));
108
+ let bootstrap;
109
+ if (input.bootstrap) {
110
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
111
+ prepareOutputDirectory(project ? projectHome(context.ddFlowHome, project.id) : path.join(context.ddFlowHome, "projects"));
112
+ const preview = previewNextEntityId(context, { projectRoot, type: "protocol", slug: input.bootstrap.subject });
113
+ const startInput = { projectRoot, flowKind: "mb_sdlc", subjectType: "protocol", subjectId: preview.entity.id, slug: input.bootstrap.subject, nextAction: "run_specify" };
114
+ bootstrap = { protocolId: preview.entity.id, startInput, start: prepareFlowRunStart(context, startInput) };
115
+ }
116
+ return { intakeMarkdown: intake, projectRoot, retained, dir, preflight, sources, bootstrap };
117
+ }
118
+ export function startStage(context, input, prepared = prepareLegacyStageStart(context, input)) {
107
119
  if (input.requireSessionBinding && !input.hookEventId) {
108
120
  throw new AppError("trusted_session_binding_required", "This stage requires a trusted harness lifecycle binding; run it from a controlled Session with the dd-flow adapter enabled", 1);
109
121
  }
110
- const projectRoot = resolveProjectRoot(input.projectRoot);
122
+ const { projectRoot, retained, dir, preflight, sources } = prepared;
123
+ if (retained)
124
+ prepareFlowRunStageAttachment(context, { projectRoot, runId: retained.run.id, stage: input.stage, dir: input.dir ?? defaultStageDir(input.stage, retained.run.flow_kind), status: "running", dataSchemaId: dataSchemaForStage(input.stage) });
111
125
  let requiredSessionId = null;
112
126
  let requiredIdentity = null;
113
127
  if (input.requireSessionBinding) {
114
- registerProject(context, { root: projectRoot });
115
128
  requiredIdentity = hookSessionIdentity(context, requireProjectByRoot(context, projectRoot).id, input.hookEventId);
116
129
  requiredSessionId = requiredIdentity.sessionId;
117
130
  }
118
- const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
131
+ const runId = prepared.bootstrap ? bootstrapStageRun(context, projectRoot, prepared.bootstrap, prepared.intakeMarkdown) : input.runId;
119
132
  if (!runId)
120
133
  throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
121
- const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
122
- const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
134
+ const before = retained ?? runView(getFlowRunStatus(context, { projectRoot, runId }));
135
+ assertRunMutationAllowed(context, before.run.project_id, before.run.id);
123
136
  const attached = runView(attachFlowRunStage(context, {
124
137
  projectRoot,
125
138
  runId,
@@ -132,9 +145,6 @@ export function startStage(context, input) {
132
145
  const stageRoot = path.join(runHome, dir);
133
146
  ensureWithin(runHome, stageRoot, "stage root");
134
147
  fs.mkdirSync(stageRoot, { recursive: true });
135
- const preflight = stagePreflight(projectRoot, stageRoot);
136
- if (preflight.ok !== true)
137
- throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
138
148
  if (attached.run.subject.type === "protocol") {
139
149
  syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
140
150
  }
@@ -142,7 +152,7 @@ export function startStage(context, input) {
142
152
  if (sessionId) {
143
153
  const identity = requiredIdentity ?? (input.hookEventId ? hookSessionIdentity(context, attached.run.project_id, input.hookEventId) : null);
144
154
  registerFlowSession(context, {
145
- sessionId,
155
+ hookEventId: input.hookEventId,
146
156
  payloadJson: JSON.stringify({
147
157
  project_root: projectRoot,
148
158
  harness: identity?.harness ?? "codex-desktop",
@@ -167,7 +177,7 @@ export function startStage(context, input) {
167
177
  });
168
178
  }
169
179
  const promptPath = path.join(stageRoot, "stage-prompt.md");
170
- const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
180
+ const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight, sources);
171
181
  atomicWrite(promptPath, prompt);
172
182
  const semanticInputPath = path.join(stageRoot, "stage-input.json");
173
183
  if (!fs.existsSync(semanticInputPath)) {
@@ -178,7 +188,6 @@ export function startStage(context, input) {
178
188
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
179
189
  const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
180
190
  const attemptNumber = Number(attempt.replace("try-", "")) || 1;
181
- const sources = stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage);
182
191
  atomicWrite(promptDataPath, {
183
192
  schema_id: "dd-flow/stage-prompt@2",
184
193
  run_id: attached.run.id,
@@ -257,12 +266,13 @@ export function startStage(context, input) {
257
266
  lifecycle: "stage start -> semantic work -> stage finish"
258
267
  };
259
268
  }
260
- export function finishStage(context, input) {
269
+ export function prepareStageFinish(context, input) {
261
270
  const projectRoot = resolveProjectRoot(input.projectRoot);
262
271
  if (!input.runId)
263
272
  throw new AppError("usage", "stage finish requires a RUN id", 2);
264
273
  const runId = input.runId;
265
274
  const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
275
+ assertRunMutationAllowed(context, view.run.project_id, view.run.id);
266
276
  const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
267
277
  if (!existing) {
268
278
  throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
@@ -271,31 +281,38 @@ export function finishStage(context, input) {
271
281
  const runHome = runHomePath(view.run);
272
282
  const stageRoot = path.join(runHome, dir);
273
283
  ensureWithin(runHome, stageRoot, "stage root");
274
- fs.mkdirSync(stageRoot, { recursive: true });
275
284
  const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
276
- const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage);
285
+ ensureWithin(runHome, semanticFile, "semantic file");
286
+ const source = prepareJsonSchema(semanticFile, "Stage finish input", { schemaName: "stage-finish-input", projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
287
+ const semantic = source.value;
288
+ const status = stringValue(semantic.status) ?? "done";
289
+ if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
290
+ throw new AppError("validation", "Stage finish status must be done, waiting_for_user, blocked, or failed", 2, { status });
291
+ }
292
+ validateStageOutcome(input.stage, status, semantic);
293
+ const planFinish = input.stage === "plan" ? preparePlanFinish(context, projectRoot, view, stageRoot) : undefined;
294
+ return { projectRoot, runId, view, existing, runHome, stageRoot, semanticFile, semantic, status, source, planFinish };
295
+ }
296
+ export function finishStage(context, input, prepared = prepareStageFinish(context, input)) {
297
+ const { projectRoot, runId, view, existing, runHome, stageRoot, semanticFile, semantic, status, source, planFinish } = prepared;
298
+ assertRunMutationAllowed(context, view.run.project_id, view.run.id);
299
+ fs.mkdirSync(stageRoot, { recursive: true });
300
+ const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage, source.text);
277
301
  try {
278
- validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
279
- const semantic = readSemanticFile(semanticFile, runHome);
280
- const status = stringValue(semantic.status) ?? "done";
281
- if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
282
- throw new AppError("validation", "Stage finish status must be done, waiting_for_user, blocked, or failed", 2, { status });
283
- }
284
- validateStageOutcome(input.stage, status, semantic);
285
302
  const coverage = workerCoverage(context, view.run.project_id, view.run.id);
286
303
  if (coverage.jobs.missing?.length) {
287
304
  throw new AppError("worker_jobs_incomplete", "Stage finish requires all declared worker jobs", 1, { coverage });
288
305
  }
289
306
  const lint = runTargetedMemoryBankLint(projectRoot);
290
- const planFinish = input.stage === "plan"
291
- ? preparePlanFinish(context, projectRoot, view, stageRoot)
292
- : undefined;
307
+ if (planFinish)
308
+ boundCanonicalPlan(context, { projectId: view.run.project_id, protocolId: view.run.subject.id, planPath: planFinish.plan_path }, planFinish.canonical);
309
+ const stageUsage = stageUsageProjection(context, view, input.stage, existing.started_at);
293
310
  const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
294
311
  const dataPath = path.join(stageRoot, "stage-report.json");
295
312
  const reportPath = path.join(stageRoot, "stage-report.md");
296
313
  const htmlPath = path.join(stageRoot, "stage-report.html");
314
+ validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, data: report, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
297
315
  atomicWrite(dataPath, report);
298
- validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
299
316
  atomicWrite(reportPath, renderMarkdown(report));
300
317
  atomicWrite(htmlPath, renderHtml(projectRoot, report));
301
318
  if (view.run.subject.type === "protocol") {
@@ -310,7 +327,8 @@ export function finishStage(context, input) {
310
327
  stageReport: htmlPath,
311
328
  data: dataPath,
312
329
  dataSchemaId: String(report.schema_id),
313
- report: reportPath
330
+ report: reportPath,
331
+ usage: stageUsage
314
332
  });
315
333
  finishReceipt(receipt, { outcome: "accepted", result: { status, report: dataPath } });
316
334
  appendAudit(context, {
@@ -480,12 +498,10 @@ function semanticInputTemplate(stage) {
480
498
  next_action: waiting ? "waiting_for_user: obtain the clarification packet" : "Continue to the next approved stage."
481
499
  };
482
500
  }
483
- function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
501
+ function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight, sources) {
484
502
  const project = requireProjectByRoot(context, projectRoot);
485
503
  const runHome = runHomePath(current.run);
486
- const instructions = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage).map(({ label, absolute }) => {
487
- return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
488
- }).join("\n\n");
504
+ const instructions = sources.map(({ label, text }) => `### ${label}\n\n${text}`).join("\n\n");
489
505
  const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
490
506
  return [
491
507
  "<stage_identity>",
@@ -508,7 +524,7 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
508
524
  "</authoritative_runtime_facts>",
509
525
  "",
510
526
  "<preflight>",
511
- `- Permission probe: ${String(preflight.ok === true ? "passed" : "failed")}.`,
527
+ `- Read-only permission check: ${String(preflight.ok === true ? "passed" : "failed")}.`,
512
528
  "- CLI compatibility was checked before this state-changing command.",
513
529
  "</preflight>",
514
530
  "",
@@ -581,16 +597,6 @@ function safeProtocolState(context, projectId, protocolId) {
581
597
  return undefined;
582
598
  }
583
599
  }
584
- function readSemanticFile(file, runHome) {
585
- const resolved = path.resolve(file);
586
- ensureWithin(runHome, resolved, "semantic file");
587
- if (!fs.existsSync(resolved))
588
- throw new AppError("not_found", "Semantic input file is missing", 1, { path: file });
589
- const value = JSON.parse(fs.readFileSync(resolved, "utf8"));
590
- if (!value || typeof value !== "object" || Array.isArray(value))
591
- throw new AppError("validation", "Semantic input must be a JSON object", 2);
592
- return value;
593
- }
594
600
  function validateStageOutcome(stage, status, semantic) {
595
601
  if (stage !== "specify")
596
602
  return;
@@ -602,17 +608,12 @@ function validateStageOutcome(stage, status, semantic) {
602
608
  throw new AppError("semantic_status_inconsistent", "SPECIFY done cannot contain unresolved structured questions", 2);
603
609
  }
604
610
  }
605
- function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage) {
611
+ function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage, raw) {
606
612
  const receiptDir = path.join(stageRoot, "finish-receipts");
607
613
  fs.mkdirSync(receiptDir, { recursive: true });
608
614
  const number = fs.readdirSync(receiptDir).filter((file) => /^\d{3}\.json$/u.test(file)).length + 1;
609
615
  const receiptPath = path.join(receiptDir, `${String(number).padStart(3, "0")}.json`);
610
- let raw = null;
611
- try {
612
- raw = fs.readFileSync(semanticFile, "utf8");
613
- }
614
- catch { /* receipt records the missing input below */ }
615
- const semanticSha = raw === null ? null : crypto.createHash("sha256").update(raw).digest("hex");
616
+ const semanticSha = crypto.createHash("sha256").update(raw).digest("hex");
616
617
  atomicWrite(receiptPath, {
617
618
  schema_id: "dd-flow/stage-finish-receipt@1",
618
619
  run_id: runId,
@@ -775,17 +776,13 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
775
776
  }
776
777
  function preparePlanFinish(context, projectRoot, view, stageRoot) {
777
778
  const planPath = planJsonPath(projectRoot, view.run.subject.id);
778
- const canonical = boundCanonicalPlan(context, {
779
+ const canonical = prepareCanonicalPlan(context, {
779
780
  projectId: view.run.project_id,
780
781
  protocolId: view.run.subject.id,
781
782
  planPath
782
783
  });
783
784
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
784
- if (!fs.existsSync(aspectMapPath)) {
785
- throw new AppError("not_found", "PLAN aspect-map.json is missing", 1, { path: aspectMapPath });
786
- }
787
- const raw = fs.readFileSync(aspectMapPath, "utf8");
788
- const value = JSON.parse(raw);
785
+ const { text: raw, value } = prepareJsonFile(aspectMapPath, "PLAN aspect map");
789
786
  if (!value || typeof value !== "object" || Array.isArray(value)) {
790
787
  throw new AppError("validation", "PLAN aspect-map.json must be a JSON object", 2, { path: aspectMapPath });
791
788
  }
@@ -1,31 +1,44 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
- import os from "node:os";
4
3
  import path from "node:path";
4
+ import { quote } from "shell-quote";
5
+ import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
5
6
  import { AppError } from "../shared/errors.js";
7
+ import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
6
8
  import { resolveProjectRoot } from "../storage/paths.js";
9
+ import { resolveWorkReference } from "../storage/work-references.js";
7
10
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
8
11
  import { requireProjectByRoot } from "./projects.js";
9
12
  import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
10
13
  import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
11
- export function pauseStageForUser(context, input) {
14
+ import { assertRunMutationAllowed } from "./run-recovery.js";
15
+ import { runtimeCommand } from "./runtime-command.js";
16
+ export { flowCommand } from "./runtime-command.js";
17
+ export function prepareStagePause(context, input) {
18
+ if (typeof input.question !== "string" || !input.question.trim())
19
+ throw new AppError("validation", "stage pause requires a non-empty user question", 2);
20
+ safeStage(input.stage);
12
21
  const projectRoot = resolveProjectRoot(input.projectRoot);
13
22
  const project = requireProjectByRoot(context, projectRoot);
14
23
  const run = requireRun(context, project.id, input.runId);
24
+ assertRunMutationAllowed(context, project.id, run.id);
15
25
  const work = requireWork(context, project.id, run.id, input.workId);
16
26
  if (work.status !== "running")
17
27
  throw new AppError("invalid_work_state", "Only a running Work can pause", 1, { work_id: work.work_id, status: work.status });
18
28
  const question = input.question;
19
- if (!question.trim())
20
- throw new AppError("validation", "stage pause requires a non-empty user question", 2);
21
29
  const stage = requireStage(run, input.stage, "running");
30
+ requireRunHome(run);
31
+ return { projectRoot, project, run, work, question, stage };
32
+ }
33
+ export function pauseStageForUser(context, input) {
34
+ const { projectRoot, project, run, work, question, stage } = prepareStagePause(context, input);
22
35
  const pauseId = nextPauseId(requireRunHome(run));
23
36
  const pauseRoot = path.join(requireRunHome(run), "intake", "hitl", `${pauseId}-${safeStage(input.stage)}`);
24
37
  const questionPath = path.join(pauseRoot, "question.md");
25
38
  fs.mkdirSync(pauseRoot, { recursive: true });
26
39
  fs.writeFileSync(questionPath, question);
27
40
  const now = context.now();
28
- context.db.exec("BEGIN IMMEDIATE");
41
+ context.db.beginWriteTransaction();
29
42
  try {
30
43
  const paused = context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
31
44
  if (paused.changes !== 1)
@@ -38,8 +51,9 @@ export function pauseStageForUser(context, input) {
38
51
  throw error;
39
52
  }
40
53
  refreshRunWorkProjection(context, project.id, run.id);
41
- const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot });
42
- const resumeTemplate = `${resume} <<'USER_ANSWER'\n<paste the complete user answer exactly>\nUSER_ANSWER`;
54
+ const answerInputPath = path.join(pauseRoot, "answer-input.md");
55
+ const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, answerInputPath });
56
+ const resumeTemplate = `Write the complete user answer exactly to ${quote([answerInputPath])}, then run:\n${resume}`;
43
57
  return {
44
58
  ok: true,
45
59
  outcome: "paused",
@@ -48,39 +62,62 @@ export function pauseStageForUser(context, input) {
48
62
  stage: stage.stage,
49
63
  pause: { id: pauseId, reason: "waiting_for_user", question_path: questionPath, user_message: question },
50
64
  next_action: "ask_user_then_resume_same_stage",
51
- agent_instruction: "Send user_message to the user and stop this Turn. When the user answers, do not interpret or edit the answer first: make resume_command the first flow command, pass the complete raw answer on stdin, then follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.",
65
+ agent_instruction: `Send user_message to the user and stop this Turn. When the user answers, write the complete raw answer without interpretation to ${answerInputPath}, then make resume_command the first flow command and follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.`,
52
66
  resume_command: resume,
53
67
  resume_command_template: resumeTemplate
54
68
  };
55
69
  }
56
- export function resumeStageAfterUser(context, input) {
70
+ export function prepareStageResume(context, input) {
71
+ if (typeof input.answer !== "string" || !input.answer.trim())
72
+ throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
73
+ safeStage(input.stage);
57
74
  const projectRoot = resolveProjectRoot(input.projectRoot);
58
75
  const project = requireProjectByRoot(context, projectRoot);
59
76
  const run = requireRun(context, project.id, input.runId);
77
+ assertRunMutationAllowed(context, project.id, run.id);
60
78
  const work = requireWork(context, project.id, run.id, input.workId);
61
79
  if (work.status !== "paused")
62
80
  throw new AppError("invalid_work_state", "stage resume requires its paused Work", 1, { work_id: work.work_id, status: work.status });
63
81
  const stage = requireStage(run, input.stage, "paused");
64
82
  if (!stage.pause || stage.pause.work_id !== work.work_id || stage.pause.answer_path)
65
83
  throw new AppError("invalid_stage_state", "Stage has no unanswered pause for this Work", 1, { run_id: run.id, stage: stage.stage, work_id: work.work_id });
66
- if (!input.answer.trim())
67
- throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
84
+ const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
85
+ if (!workSession)
86
+ throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
87
+ let evidence = input.evidence;
88
+ if (evidence && (evidence.pauseId !== stage.pause.id || evidence.promptPath !== workSession.prompt_path || evidence.questionPath !== stage.pause.question_path))
89
+ throw new AppError("runtime_state_changed", "Paused Work changed after resume preparation", 1, { work_id: work.work_id });
90
+ if (!evidence) {
91
+ try {
92
+ evidence = { pauseId: stage.pause.id, promptPath: workSession.prompt_path, questionPath: stage.pause.question_path, prompt: fs.readFileSync(workSession.prompt_path, "utf8"), question: fs.readFileSync(stage.pause.question_path, "utf8") };
93
+ }
94
+ catch (error) {
95
+ throw new AppError("runtime_missing", "Paused Work continuation artifacts cannot be read", 1, { work_id: work.work_id, cause: String(error) });
96
+ }
97
+ }
98
+ return { projectRoot, project, run, work, stage, pause: stage.pause, workSession, evidence };
99
+ }
100
+ export function resumeStageAfterUser(context, input) {
101
+ const { projectRoot, project, run, work, stage, pause, workSession, evidence } = prepareStageResume(context, input);
68
102
  const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
69
103
  projectId: project.id,
70
- matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
104
+ matchKey: entityReferenceVariants(run.id).flatMap((runReference) => entityReferenceVariants(work.work_id).map((workReference) => stageResumeMatchKey(runReference, stage.stage, workReference, projectRoot))),
71
105
  errorCode: "trusted_stage_resume_required",
72
106
  operation: "stage resume"
73
107
  }).eventKey;
74
- const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
75
- fs.writeFileSync(answerPath, input.answer);
76
108
  const now = context.now();
77
- const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
78
- if (!workSession)
79
- throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
80
- context.db.exec("BEGIN IMMEDIATE");
109
+ const answerPath = path.join(path.dirname(pause.question_path), `answer-${crypto.createHash("sha256").update(`${pause.id}\0${input.answer}`).digest("hex")}.md`);
110
+ context.db.beginWriteTransaction();
81
111
  let identity;
82
112
  try {
83
113
  identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
114
+ try {
115
+ fs.writeFileSync(answerPath, input.answer, { flag: "wx", mode: 0o600 });
116
+ }
117
+ catch (error) {
118
+ if (error.code !== "EEXIST" || fs.readFileSync(answerPath, "utf8") !== input.answer)
119
+ throw error;
120
+ }
84
121
  if (workSession.session_id !== identity.sessionId) {
85
122
  bindSessionForResume(context, work.work_id, identity, now);
86
123
  context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
@@ -98,18 +135,20 @@ export function resumeStageAfterUser(context, input) {
98
135
  }
99
136
  refreshRunWorkProjection(context, project.id, run.id);
100
137
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
101
- const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
138
+ const prompt = evidence.prompt;
139
+ const retryCommands = lifecycleRetryCommands(context, stage.stage);
102
140
  const continuation = [
103
141
  prompt.trimEnd(),
104
142
  "",
105
143
  "<hitl_resume>",
106
- `- Pause: ${stage.pause.id}`,
107
- `- Question source: ${stage.pause.question_path}`,
144
+ `- Pause: ${pause.id}`,
145
+ `- Question source: ${pause.question_path}`,
108
146
  `- Answer source: ${answerPath}`,
109
147
  "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
148
+ ...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; correct the rejected input and use only these replacements:", ...retryCommands.map(command => ` ${command}`)] : []),
110
149
  "",
111
150
  "<user_question>",
112
- fs.readFileSync(stage.pause.question_path, "utf8"),
151
+ evidence.question,
113
152
  "</user_question>",
114
153
  "",
115
154
  "<user_answer>",
@@ -118,18 +157,18 @@ export function resumeStageAfterUser(context, input) {
118
157
  "</hitl_resume>",
119
158
  ""
120
159
  ].join("\n");
121
- return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: stage.pause.id, question_path: stage.pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
160
+ return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
122
161
  }
123
162
  export function stagePauseCommand(context, input) {
124
- return `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
163
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
164
+ const run = requireRun(context, project.id, input.runId);
165
+ const questionInputPath = path.join(requireRunHome(run), "works", input.workId, "question-input.md");
166
+ return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "pause", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--question-file", questionInputPath, "--project-root", input.projectRoot, "--json"]));
125
167
  }
126
- /**
127
- * The only shell form allowed for an agent-owned HITL pause. Supplying a
128
- * complete heredoc makes stdin explicit without asking an agent to invent a
129
- * pipe, a temporary file or a second command.
130
- */
168
+ /** Render separate file-write and lifecycle-call instructions for HITL. */
131
169
  export function stagePauseCommandTemplate(command) {
132
- return `${command} <<'USER_QUESTION'
170
+ const questionFile = command.match(/--question-file\s+(?:'([^']*)'|"([^"]*)"|(\S+))/)?.slice(1).find(Boolean) ?? "<question-file-from-command>";
171
+ return `Write this text to ${questionFile}:
133
172
  ## Q-001 — <short decision title>
134
173
 
135
174
  Why this decision is required:
@@ -144,27 +183,12 @@ Recommendation:
144
183
 
145
184
  Effect on scope or acceptance:
146
185
  - <one sentence>
147
- USER_QUESTION`;
186
+
187
+ Then run this standalone command:
188
+ ${command}`;
148
189
  }
149
190
  function stageResumeCommand(context, input) {
150
- return `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
151
- }
152
- export function flowCommand(context) {
153
- const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
154
- // Desktop/ACP tool processes are not required to inherit the environment of
155
- // the adapter that created their Session. In an isolated runtime, a bare
156
- // `dd-flow` can therefore select the host-global router despite a correct
157
- // DD_FLOW_HOME. The runner supplies an absolute executable for that case;
158
- // make every generated lifecycle command self-contained.
159
- const executable = context.env.DD_FLOW_BIN;
160
- if (executable && path.isAbsolute(executable)) {
161
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
162
- }
163
- const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
164
- if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
165
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
166
- }
167
- return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
191
+ return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "resume", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--answer-file", input.answerInputPath, "--project-root", input.projectRoot, "--json"]));
168
192
  }
169
193
  function requireRun(context, projectId, id) {
170
194
  const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
@@ -173,14 +197,19 @@ function requireRun(context, projectId, id) {
173
197
  return rows[0];
174
198
  }
175
199
  function requireWork(context, projectId, runId, workId) {
176
- const work = context.db.get("SELECT work_id, project_id, run_id, status FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [projectId, runId, workId]);
177
- if (!work)
178
- throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
179
- return work;
200
+ return resolveWorkReference(context, workId, { projectId, runId });
180
201
  }
181
202
  function requireStage(run, stage, status) {
182
- const index = JSON.parse(run.index_json);
183
- const found = index.stage_runs?.find((item) => item.stage === stage);
203
+ let index;
204
+ try {
205
+ index = JSON.parse(run.index_json);
206
+ if (!index || !Array.isArray(index.stage_runs))
207
+ throw new Error("RUN index must contain stage_runs");
208
+ }
209
+ catch (error) {
210
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
211
+ }
212
+ const found = index.stage_runs.find((item) => item.stage === stage);
184
213
  if (!found || found.status !== status)
185
214
  throw new AppError("invalid_stage_state", `Stage must be ${status}`, 1, { run_id: run.id, stage, status: found?.status ?? "missing" });
186
215
  return found;