@deksden-com/dd-flow-cli 0.9.0-beta.74 → 0.9.0-beta.79

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 (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/command-inputs.js +300 -0
  4. package/dist/cli/help.js +1 -1
  5. package/dist/cli/hook-ingress.js +82 -0
  6. package/dist/cli/input-preparation.js +115 -0
  7. package/dist/cli/run-cli.js +1015 -423
  8. package/dist/cli.js +6 -2
  9. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  10. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  11. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  12. package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
  13. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  15. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  16. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  17. package/dist/services/canon.js +11 -8
  18. package/dist/services/cleanup.js +49 -8
  19. package/dist/services/cli-operation-classifier.js +12 -24
  20. package/dist/services/codex-hook-delivery.js +28 -0
  21. package/dist/services/config.js +11 -8
  22. package/dist/services/controller-fanout.js +7 -11
  23. package/dist/services/dashboard.js +94 -31
  24. package/dist/services/engines.js +75 -61
  25. package/dist/services/eval-snapshots.js +138 -38
  26. package/dist/services/execution-policy.js +5 -12
  27. package/dist/services/hooks.js +226 -203
  28. package/dist/services/lanes.js +60 -52
  29. package/dist/services/lifecycle-command.js +13 -1
  30. package/dist/services/lifecycle-invocations.js +285 -94
  31. package/dist/services/managed-processes.js +158 -18
  32. package/dist/services/merge-queue.js +173 -98
  33. package/dist/services/migrations.js +13 -8
  34. package/dist/services/plan-runtime.js +19 -8
  35. package/dist/services/plans.js +28 -25
  36. package/dist/services/projects.js +11 -2
  37. package/dist/services/prompts.js +23 -16
  38. package/dist/services/protocols.js +54 -19
  39. package/dist/services/recovery-observation-budget.js +1 -1
  40. package/dist/services/run-controller-adapter.js +31 -20
  41. package/dist/services/run-controller-state.js +5 -0
  42. package/dist/services/run-controller.js +102 -56
  43. package/dist/services/run-fork.js +36 -22
  44. package/dist/services/run-recovery.js +32 -17
  45. package/dist/services/runs.js +110 -38
  46. package/dist/services/runtime-budget.js +120 -48
  47. package/dist/services/runtime-scope-capture.js +2 -2
  48. package/dist/services/runtime-scope-control.js +2 -2
  49. package/dist/services/runtime-scope-resume.js +34 -12
  50. package/dist/services/runtime-scope-worker.js +49 -16
  51. package/dist/services/runtime-service.js +8 -7
  52. package/dist/services/schema-validation.js +9 -7
  53. package/dist/services/sessions.js +35 -69
  54. package/dist/services/stage-blocker.js +17 -7
  55. package/dist/services/stage-context.js +42 -16
  56. package/dist/services/stage-lifecycle.js +82 -100
  57. package/dist/services/stage-pause.js +54 -24
  58. package/dist/services/vnext-code-review.js +82 -32
  59. package/dist/services/vnext-code.js +78 -45
  60. package/dist/services/vnext-fanout.js +13 -12
  61. package/dist/services/vnext-merge.js +68 -35
  62. package/dist/services/vnext-plan-review.js +85 -42
  63. package/dist/services/vnext-plan.js +91 -70
  64. package/dist/services/vnext-protocolize.js +116 -50
  65. package/dist/services/vnext-specify.js +70 -57
  66. package/dist/services/work-registry.js +207 -94
  67. package/dist/services/worktrees.js +85 -35
  68. package/dist/storage/database.js +104 -10
  69. package/dist/storage/paths.js +17 -4
  70. package/package.json +1 -1
@@ -5,13 +5,14 @@ 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
11
  import { recalculateRunUsage, usageForRun } from "./usage.js";
12
- import { boundCanonicalPlan } from "./plan-runtime.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";
@@ -39,63 +40,30 @@ const stagePromptSections = [
39
40
  "work_contract",
40
41
  "completion_contract"
41
42
  ];
42
- function bootstrapStageRun(context, projectRoot, input) {
43
- if (input.stage !== "specify") {
44
- throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2, { stage: input.stage });
45
- }
46
- const subject = input.bootstrap?.subject.trim();
47
- if (!subject)
48
- throw new AppError("usage", "--bootstrap requires --subject", 2);
43
+ function bootstrapStageRun(context, projectRoot, bootstrap, intakeMarkdown) {
49
44
  registerProject(context, { root: projectRoot });
50
- const preview = previewNextEntityId(context, { projectRoot, type: "protocol", slug: subject });
51
- const protocolId = preview.entity.id;
52
- registerProtocol(context, { handshakeId: protocolId, projectRoot });
53
- const started = startFlowRun(context, {
54
- projectRoot,
55
- flowKind: "mb_sdlc",
56
- subjectType: "protocol",
57
- subjectId: protocolId,
58
- slug: subject,
59
- nextAction: "run_specify"
60
- });
61
- const intakeFile = input.bootstrap?.intakeFile;
62
- if (intakeFile) {
63
- const source = path.resolve(intakeFile);
64
- if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
65
- throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
66
- }
45
+ registerProtocol(context, { handshakeId: bootstrap.protocolId, projectRoot });
46
+ const started = startFlowRun(context, bootstrap.startInput, bootstrap.start);
47
+ if (intakeMarkdown !== undefined) {
67
48
  if (!started.run.run_root)
68
49
  throw new AppError("runtime_missing", "New RUN has no artifact root", 1, { run_id: started.run.id });
69
50
  const intakeDir = path.join(started.run.run_root, "intake");
70
51
  fs.mkdirSync(intakeDir, { recursive: true });
71
- fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
52
+ fs.writeFileSync(path.join(intakeDir, "user-request.md"), intakeMarkdown);
72
53
  }
73
54
  return started.run.id;
74
55
  }
75
- function stagePreflight(projectRoot, stageRoot) {
56
+ function stagePreflight(projectRoot) {
76
57
  const memoryBank = preflightMemoryPermissions({
77
58
  root: projectRoot,
78
59
  memoryBank: ".memory-bank",
79
60
  flow: "custom",
80
- mode: "write",
61
+ mode: "read",
81
62
  targets: [{ path: ".memory-bank", mode: "read" }]
82
63
  });
83
- const probe = path.join(stageRoot, `.dd-flow-permission-${crypto.randomUUID()}`);
84
- try {
85
- fs.writeFileSync(probe, "");
86
- fs.renameSync(probe, `${probe}.renamed`);
87
- fs.rmSync(`${probe}.renamed`);
88
- return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
89
- }
90
- catch (error) {
91
- for (const candidate of [probe, `${probe}.renamed`]) {
92
- try {
93
- fs.rmSync(candidate, { force: true });
94
- }
95
- catch { /* best effort */ }
96
- }
97
- return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
98
- }
64
+ if (memoryBank.ok !== true)
65
+ throw new AppError("permission_preflight_failed", "Stage project context is not readable", 1, { preflight: memoryBank });
66
+ return memoryBank;
99
67
  }
100
68
  function flowKindForStage(stage) {
101
69
  return ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning";
@@ -114,24 +82,57 @@ function runtimeFacts(view, projectRoot) {
114
82
  git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
115
83
  };
116
84
  }
117
- 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)) {
118
119
  if (input.requireSessionBinding && !input.hookEventId) {
119
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);
120
121
  }
121
- 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) });
122
125
  let requiredSessionId = null;
123
126
  let requiredIdentity = null;
124
127
  if (input.requireSessionBinding) {
125
- registerProject(context, { root: projectRoot });
126
128
  requiredIdentity = hookSessionIdentity(context, requireProjectByRoot(context, projectRoot).id, input.hookEventId);
127
129
  requiredSessionId = requiredIdentity.sessionId;
128
130
  }
129
- const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
131
+ const runId = prepared.bootstrap ? bootstrapStageRun(context, projectRoot, prepared.bootstrap, prepared.intakeMarkdown) : input.runId;
130
132
  if (!runId)
131
133
  throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
132
- const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
134
+ const before = retained ?? runView(getFlowRunStatus(context, { projectRoot, runId }));
133
135
  assertRunMutationAllowed(context, before.run.project_id, before.run.id);
134
- const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
135
136
  const attached = runView(attachFlowRunStage(context, {
136
137
  projectRoot,
137
138
  runId,
@@ -144,9 +145,6 @@ export function startStage(context, input) {
144
145
  const stageRoot = path.join(runHome, dir);
145
146
  ensureWithin(runHome, stageRoot, "stage root");
146
147
  fs.mkdirSync(stageRoot, { recursive: true });
147
- const preflight = stagePreflight(projectRoot, stageRoot);
148
- if (preflight.ok !== true)
149
- throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
150
148
  if (attached.run.subject.type === "protocol") {
151
149
  syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
152
150
  }
@@ -154,7 +152,7 @@ export function startStage(context, input) {
154
152
  if (sessionId) {
155
153
  const identity = requiredIdentity ?? (input.hookEventId ? hookSessionIdentity(context, attached.run.project_id, input.hookEventId) : null);
156
154
  registerFlowSession(context, {
157
- sessionId,
155
+ hookEventId: input.hookEventId,
158
156
  payloadJson: JSON.stringify({
159
157
  project_root: projectRoot,
160
158
  harness: identity?.harness ?? "codex-desktop",
@@ -179,7 +177,7 @@ export function startStage(context, input) {
179
177
  });
180
178
  }
181
179
  const promptPath = path.join(stageRoot, "stage-prompt.md");
182
- const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
180
+ const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight, sources);
183
181
  atomicWrite(promptPath, prompt);
184
182
  const semanticInputPath = path.join(stageRoot, "stage-input.json");
185
183
  if (!fs.existsSync(semanticInputPath)) {
@@ -190,7 +188,6 @@ export function startStage(context, input) {
190
188
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
191
189
  const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
192
190
  const attemptNumber = Number(attempt.replace("try-", "")) || 1;
193
- const sources = stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage);
194
191
  atomicWrite(promptDataPath, {
195
192
  schema_id: "dd-flow/stage-prompt@2",
196
193
  run_id: attached.run.id,
@@ -269,7 +266,7 @@ export function startStage(context, input) {
269
266
  lifecycle: "stage start -> semantic work -> stage finish"
270
267
  };
271
268
  }
272
- export function finishStage(context, input) {
269
+ export function prepareStageFinish(context, input) {
273
270
  const projectRoot = resolveProjectRoot(input.projectRoot);
274
271
  if (!input.runId)
275
272
  throw new AppError("usage", "stage finish requires a RUN id", 2);
@@ -284,32 +281,38 @@ export function finishStage(context, input) {
284
281
  const runHome = runHomePath(view.run);
285
282
  const stageRoot = path.join(runHome, dir);
286
283
  ensureWithin(runHome, stageRoot, "stage root");
287
- fs.mkdirSync(stageRoot, { recursive: true });
288
284
  const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
289
- 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);
290
301
  try {
291
- validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
292
- const semantic = readSemanticFile(semanticFile, runHome);
293
- const status = stringValue(semantic.status) ?? "done";
294
- if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
295
- throw new AppError("validation", "Stage finish status must be done, waiting_for_user, blocked, or failed", 2, { status });
296
- }
297
- validateStageOutcome(input.stage, status, semantic);
298
302
  const coverage = workerCoverage(context, view.run.project_id, view.run.id);
299
303
  if (coverage.jobs.missing?.length) {
300
304
  throw new AppError("worker_jobs_incomplete", "Stage finish requires all declared worker jobs", 1, { coverage });
301
305
  }
302
306
  const lint = runTargetedMemoryBankLint(projectRoot);
303
- const planFinish = input.stage === "plan"
304
- ? preparePlanFinish(context, projectRoot, view, stageRoot)
305
- : undefined;
307
+ if (planFinish)
308
+ boundCanonicalPlan(context, { projectId: view.run.project_id, protocolId: view.run.subject.id, planPath: planFinish.plan_path }, planFinish.canonical);
306
309
  const stageUsage = stageUsageProjection(context, view, input.stage, existing.started_at);
307
310
  const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
308
311
  const dataPath = path.join(stageRoot, "stage-report.json");
309
312
  const reportPath = path.join(stageRoot, "stage-report.md");
310
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 });
311
315
  atomicWrite(dataPath, report);
312
- validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
313
316
  atomicWrite(reportPath, renderMarkdown(report));
314
317
  atomicWrite(htmlPath, renderHtml(projectRoot, report));
315
318
  if (view.run.subject.type === "protocol") {
@@ -495,12 +498,10 @@ function semanticInputTemplate(stage) {
495
498
  next_action: waiting ? "waiting_for_user: obtain the clarification packet" : "Continue to the next approved stage."
496
499
  };
497
500
  }
498
- function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
501
+ function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight, sources) {
499
502
  const project = requireProjectByRoot(context, projectRoot);
500
503
  const runHome = runHomePath(current.run);
501
- const instructions = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage).map(({ label, absolute }) => {
502
- return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
503
- }).join("\n\n");
504
+ const instructions = sources.map(({ label, text }) => `### ${label}\n\n${text}`).join("\n\n");
504
505
  const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
505
506
  return [
506
507
  "<stage_identity>",
@@ -523,7 +524,7 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
523
524
  "</authoritative_runtime_facts>",
524
525
  "",
525
526
  "<preflight>",
526
- `- Permission probe: ${String(preflight.ok === true ? "passed" : "failed")}.`,
527
+ `- Read-only permission check: ${String(preflight.ok === true ? "passed" : "failed")}.`,
527
528
  "- CLI compatibility was checked before this state-changing command.",
528
529
  "</preflight>",
529
530
  "",
@@ -596,16 +597,6 @@ function safeProtocolState(context, projectId, protocolId) {
596
597
  return undefined;
597
598
  }
598
599
  }
599
- function readSemanticFile(file, runHome) {
600
- const resolved = path.resolve(file);
601
- ensureWithin(runHome, resolved, "semantic file");
602
- if (!fs.existsSync(resolved))
603
- throw new AppError("not_found", "Semantic input file is missing", 1, { path: file });
604
- const value = JSON.parse(fs.readFileSync(resolved, "utf8"));
605
- if (!value || typeof value !== "object" || Array.isArray(value))
606
- throw new AppError("validation", "Semantic input must be a JSON object", 2);
607
- return value;
608
- }
609
600
  function validateStageOutcome(stage, status, semantic) {
610
601
  if (stage !== "specify")
611
602
  return;
@@ -617,17 +608,12 @@ function validateStageOutcome(stage, status, semantic) {
617
608
  throw new AppError("semantic_status_inconsistent", "SPECIFY done cannot contain unresolved structured questions", 2);
618
609
  }
619
610
  }
620
- function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage) {
611
+ function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage, raw) {
621
612
  const receiptDir = path.join(stageRoot, "finish-receipts");
622
613
  fs.mkdirSync(receiptDir, { recursive: true });
623
614
  const number = fs.readdirSync(receiptDir).filter((file) => /^\d{3}\.json$/u.test(file)).length + 1;
624
615
  const receiptPath = path.join(receiptDir, `${String(number).padStart(3, "0")}.json`);
625
- let raw = null;
626
- try {
627
- raw = fs.readFileSync(semanticFile, "utf8");
628
- }
629
- catch { /* receipt records the missing input below */ }
630
- const semanticSha = raw === null ? null : crypto.createHash("sha256").update(raw).digest("hex");
616
+ const semanticSha = crypto.createHash("sha256").update(raw).digest("hex");
631
617
  atomicWrite(receiptPath, {
632
618
  schema_id: "dd-flow/stage-finish-receipt@1",
633
619
  run_id: runId,
@@ -790,17 +776,13 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
790
776
  }
791
777
  function preparePlanFinish(context, projectRoot, view, stageRoot) {
792
778
  const planPath = planJsonPath(projectRoot, view.run.subject.id);
793
- const canonical = boundCanonicalPlan(context, {
779
+ const canonical = prepareCanonicalPlan(context, {
794
780
  projectId: view.run.project_id,
795
781
  protocolId: view.run.subject.id,
796
782
  planPath
797
783
  });
798
784
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
799
- if (!fs.existsSync(aspectMapPath)) {
800
- throw new AppError("not_found", "PLAN aspect-map.json is missing", 1, { path: aspectMapPath });
801
- }
802
- const raw = fs.readFileSync(aspectMapPath, "utf8");
803
- const value = JSON.parse(raw);
785
+ const { text: raw, value } = prepareJsonFile(aspectMapPath, "PLAN aspect map");
804
786
  if (!value || typeof value !== "object" || Array.isArray(value)) {
805
787
  throw new AppError("validation", "PLAN aspect-map.json must be a JSON object", 2, { path: aspectMapPath });
806
788
  }
@@ -2,7 +2,6 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { quote } from "shell-quote";
6
5
  import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
7
6
  import { AppError } from "../shared/errors.js";
8
7
  import { resolveProjectRoot } from "../storage/paths.js";
@@ -10,17 +9,25 @@ import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatc
10
9
  import { requireProjectByRoot } from "./projects.js";
11
10
  import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
12
11
  import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
13
- export function pauseStageForUser(context, input) {
12
+ import { assertRunMutationAllowed } from "./run-recovery.js";
13
+ export function prepareStagePause(context, input) {
14
+ if (typeof input.question !== "string" || !input.question.trim())
15
+ throw new AppError("validation", "stage pause requires a non-empty user question", 2);
16
+ safeStage(input.stage);
14
17
  const projectRoot = resolveProjectRoot(input.projectRoot);
15
18
  const project = requireProjectByRoot(context, projectRoot);
16
19
  const run = requireRun(context, project.id, input.runId);
20
+ assertRunMutationAllowed(context, project.id, run.id);
17
21
  const work = requireWork(context, project.id, run.id, input.workId);
18
22
  if (work.status !== "running")
19
23
  throw new AppError("invalid_work_state", "Only a running Work can pause", 1, { work_id: work.work_id, status: work.status });
20
24
  const question = input.question;
21
- if (!question.trim())
22
- throw new AppError("validation", "stage pause requires a non-empty user question", 2);
23
25
  const stage = requireStage(run, input.stage, "running");
26
+ requireRunHome(run);
27
+ return { projectRoot, project, run, work, question, stage };
28
+ }
29
+ export function pauseStageForUser(context, input) {
30
+ const { projectRoot, project, run, work, question, stage } = prepareStagePause(context, input);
24
31
  const pauseId = nextPauseId(requireRunHome(run));
25
32
  const pauseRoot = path.join(requireRunHome(run), "intake", "hitl", `${pauseId}-${safeStage(input.stage)}`);
26
33
  const questionPath = path.join(pauseRoot, "question.md");
@@ -55,18 +62,38 @@ export function pauseStageForUser(context, input) {
55
62
  resume_command_template: resumeTemplate
56
63
  };
57
64
  }
58
- export function resumeStageAfterUser(context, input) {
65
+ export function prepareStageResume(context, input) {
66
+ if (typeof input.answer !== "string" || !input.answer.trim())
67
+ throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
68
+ safeStage(input.stage);
59
69
  const projectRoot = resolveProjectRoot(input.projectRoot);
60
70
  const project = requireProjectByRoot(context, projectRoot);
61
71
  const run = requireRun(context, project.id, input.runId);
72
+ assertRunMutationAllowed(context, project.id, run.id);
62
73
  const work = requireWork(context, project.id, run.id, input.workId);
63
74
  if (work.status !== "paused")
64
75
  throw new AppError("invalid_work_state", "stage resume requires its paused Work", 1, { work_id: work.work_id, status: work.status });
65
76
  const stage = requireStage(run, input.stage, "paused");
66
77
  if (!stage.pause || stage.pause.work_id !== work.work_id || stage.pause.answer_path)
67
78
  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 });
68
- if (!input.answer.trim())
69
- throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
79
+ 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]);
80
+ if (!workSession)
81
+ throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
82
+ let evidence = input.evidence;
83
+ if (evidence && (evidence.pauseId !== stage.pause.id || evidence.promptPath !== workSession.prompt_path || evidence.questionPath !== stage.pause.question_path))
84
+ throw new AppError("runtime_state_changed", "Paused Work changed after resume preparation", 1, { work_id: work.work_id });
85
+ if (!evidence) {
86
+ try {
87
+ 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") };
88
+ }
89
+ catch (error) {
90
+ throw new AppError("runtime_missing", "Paused Work continuation artifacts cannot be read", 1, { work_id: work.work_id, cause: String(error) });
91
+ }
92
+ }
93
+ return { projectRoot, project, run, work, stage, pause: stage.pause, workSession, evidence };
94
+ }
95
+ export function resumeStageAfterUser(context, input) {
96
+ const { projectRoot, project, run, work, stage, pause, workSession, evidence } = prepareStageResume(context, input);
70
97
  const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
71
98
  projectId: project.id,
72
99
  matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
@@ -74,10 +101,7 @@ export function resumeStageAfterUser(context, input) {
74
101
  operation: "stage resume"
75
102
  }).eventKey;
76
103
  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
- const answerPath = path.join(path.dirname(stage.pause.question_path), `answer-${crypto.createHash("sha256").update(`${stage.pause.id}\0${input.answer}`).digest("hex")}.md`);
104
+ const answerPath = path.join(path.dirname(pause.question_path), `answer-${crypto.createHash("sha256").update(`${pause.id}\0${input.answer}`).digest("hex")}.md`);
81
105
  context.db.beginWriteTransaction();
82
106
  let identity;
83
107
  try {
@@ -106,20 +130,20 @@ export function resumeStageAfterUser(context, input) {
106
130
  }
107
131
  refreshRunWorkProjection(context, project.id, run.id);
108
132
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
109
- const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
133
+ const prompt = evidence.prompt;
110
134
  const retryCommands = lifecycleRetryCommands(context, stage.stage);
111
135
  const continuation = [
112
136
  prompt.trimEnd(),
113
137
  "",
114
138
  "<hitl_resume>",
115
- `- Pause: ${stage.pause.id}`,
116
- `- Question source: ${stage.pause.question_path}`,
139
+ `- Pause: ${pause.id}`,
140
+ `- Question source: ${pause.question_path}`,
117
141
  `- Answer source: ${answerPath}`,
118
142
  "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
119
- ...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; use only these replacements, never invent an invocation id:", ...retryCommands.map(command => ` ${command}`)] : []),
143
+ ...(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}`)] : []),
120
144
  "",
121
145
  "<user_question>",
122
- fs.readFileSync(stage.pause.question_path, "utf8"),
146
+ evidence.question,
123
147
  "</user_question>",
124
148
  "",
125
149
  "<user_answer>",
@@ -128,7 +152,7 @@ export function resumeStageAfterUser(context, input) {
128
152
  "</hitl_resume>",
129
153
  ""
130
154
  ].join("\n");
131
- 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}` };
155
+ 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}` };
132
156
  }
133
157
  export function stagePauseCommand(context, input) {
134
158
  return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
@@ -160,8 +184,6 @@ function stageResumeCommand(context, input) {
160
184
  return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
161
185
  }
162
186
  export function flowCommand(context) {
163
- const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
164
- const prefix = scope ? `DD_FLOW_INVOCATION_SCOPE=${quote([scope])} ` : "";
165
187
  const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
166
188
  // Desktop/ACP tool processes are not required to inherit the environment of
167
189
  // the adapter that created their Session. In an isolated runtime, a bare
@@ -170,13 +192,13 @@ export function flowCommand(context) {
170
192
  // make every generated lifecycle command self-contained.
171
193
  const executable = context.env.DD_FLOW_BIN;
172
194
  if (executable && path.isAbsolute(executable)) {
173
- return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
195
+ return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} "$DD_FLOW_BIN"`;
174
196
  }
175
197
  const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
176
198
  if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
177
- return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
199
+ return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
178
200
  }
179
- return context.ddFlowHome === defaultHome ? `${prefix}dd-flow` : `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
201
+ return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
180
202
  }
181
203
  function requireRun(context, projectId, id) {
182
204
  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]);
@@ -191,8 +213,16 @@ function requireWork(context, projectId, runId, workId) {
191
213
  return work;
192
214
  }
193
215
  function requireStage(run, stage, status) {
194
- const index = JSON.parse(run.index_json);
195
- const found = index.stage_runs?.find((item) => item.stage === stage);
216
+ let index;
217
+ try {
218
+ index = JSON.parse(run.index_json);
219
+ if (!index || !Array.isArray(index.stage_runs))
220
+ throw new Error("RUN index must contain stage_runs");
221
+ }
222
+ catch (error) {
223
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
224
+ }
225
+ const found = index.stage_runs.find((item) => item.stage === stage);
196
226
  if (!found || found.status !== status)
197
227
  throw new AppError("invalid_stage_state", `Stage must be ${status}`, 1, { run_id: run.id, stage, status: found?.status ?? "missing" });
198
228
  return found;