@deksden-com/dd-flow-cli 0.9.0-beta.8 → 0.9.0-beta.80

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 (136) hide show
  1. package/CHANGELOG.md +396 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +300 -0
  5. package/dist/cli/help.js +81 -9
  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 +1342 -375
  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/config.js +11 -8
  63. package/dist/services/controller-fanout.js +116 -0
  64. package/dist/services/dashboard.js +103 -31
  65. package/dist/services/engines.js +121 -76
  66. package/dist/services/eval-snapshots.js +750 -73
  67. package/dist/services/execution-policy.js +170 -0
  68. package/dist/services/external-work-launch.js +110 -0
  69. package/dist/services/harness-adapter.js +147 -24
  70. package/dist/services/harness-config.js +2 -0
  71. package/dist/services/hooks.js +553 -285
  72. package/dist/services/lanes.js +61 -53
  73. package/dist/services/lifecycle-command.js +87 -7
  74. package/dist/services/lifecycle-invocations.js +828 -0
  75. package/dist/services/managed-daemon-binding.js +38 -0
  76. package/dist/services/managed-processes.js +283 -27
  77. package/dist/services/merge-queue.js +179 -104
  78. package/dist/services/merge-server.js +73 -28
  79. package/dist/services/migrations.js +14 -9
  80. package/dist/services/native-daemon-history.js +49 -0
  81. package/dist/services/native-session-control.js +39 -0
  82. package/dist/services/plan-runtime.js +19 -8
  83. package/dist/services/plans.js +28 -25
  84. package/dist/services/projects.js +18 -5
  85. package/dist/services/prompts.js +23 -16
  86. package/dist/services/protocols.js +64 -20
  87. package/dist/services/recovery-observation-budget.js +61 -0
  88. package/dist/services/recovery-snapshot-database.js +107 -0
  89. package/dist/services/run-control-receipt.js +56 -0
  90. package/dist/services/run-control-worker.js +363 -0
  91. package/dist/services/run-control.js +876 -0
  92. package/dist/services/run-controller-adapter.js +222 -0
  93. package/dist/services/run-controller-capture.js +134 -0
  94. package/dist/services/run-controller-process.js +189 -0
  95. package/dist/services/run-controller-recovery.js +222 -0
  96. package/dist/services/run-controller-state.js +36 -0
  97. package/dist/services/run-controller.js +779 -0
  98. package/dist/services/run-engine-bindings.js +1 -1
  99. package/dist/services/run-fork.js +138 -0
  100. package/dist/services/run-observations.js +112 -0
  101. package/dist/services/run-recovery-runtime.js +69 -0
  102. package/dist/services/run-recovery.js +342 -0
  103. package/dist/services/runs.js +368 -74
  104. package/dist/services/runtime-budget.js +434 -0
  105. package/dist/services/runtime-scope-capture.js +50 -0
  106. package/dist/services/runtime-scope-control.js +450 -0
  107. package/dist/services/runtime-scope-resume.js +565 -0
  108. package/dist/services/runtime-scope-stop.js +99 -0
  109. package/dist/services/runtime-scope-worker.js +243 -0
  110. package/dist/services/runtime-service.js +97 -0
  111. package/dist/services/schema-validation.js +10 -8
  112. package/dist/services/sessions.js +35 -69
  113. package/dist/services/stage-blocker.js +17 -7
  114. package/dist/services/stage-context.js +44 -18
  115. package/dist/services/stage-lifecycle.js +99 -102
  116. package/dist/services/stage-pause.js +65 -23
  117. package/dist/services/usage.js +37 -93
  118. package/dist/services/vnext-code-review.js +189 -85
  119. package/dist/services/vnext-code.js +257 -243
  120. package/dist/services/vnext-execution-profile.js +5 -3
  121. package/dist/services/vnext-fanout.js +134 -19
  122. package/dist/services/vnext-merge.js +285 -102
  123. package/dist/services/vnext-plan-review.js +148 -94
  124. package/dist/services/vnext-plan.js +141 -72
  125. package/dist/services/vnext-protocolize.js +129 -56
  126. package/dist/services/vnext-specify.js +87 -64
  127. package/dist/services/work-registry.js +707 -156
  128. package/dist/services/workspace-bootstrap.js +76 -0
  129. package/dist/services/worktrees.js +85 -35
  130. package/dist/shared/errors.js +12 -0
  131. package/dist/storage/database.js +444 -32
  132. package/dist/storage/paths.js +17 -4
  133. package/dist/storage/writer-contract.js +77 -0
  134. package/dist/storage/writer-migration.js +102 -0
  135. package/package.json +11 -4
  136. 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
  }
@@ -2,30 +2,39 @@ 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 { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  import { resolveProjectRoot } from "../storage/paths.js";
7
8
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
8
9
  import { requireProjectByRoot } from "./projects.js";
9
10
  import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
10
11
  import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
11
- 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);
12
17
  const projectRoot = resolveProjectRoot(input.projectRoot);
13
18
  const project = requireProjectByRoot(context, projectRoot);
14
19
  const run = requireRun(context, project.id, input.runId);
20
+ assertRunMutationAllowed(context, project.id, run.id);
15
21
  const work = requireWork(context, project.id, run.id, input.workId);
16
22
  if (work.status !== "running")
17
23
  throw new AppError("invalid_work_state", "Only a running Work can pause", 1, { work_id: work.work_id, status: work.status });
18
24
  const question = input.question;
19
- if (!question.trim())
20
- throw new AppError("validation", "stage pause requires a non-empty user question", 2);
21
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);
22
31
  const pauseId = nextPauseId(requireRunHome(run));
23
32
  const pauseRoot = path.join(requireRunHome(run), "intake", "hitl", `${pauseId}-${safeStage(input.stage)}`);
24
33
  const questionPath = path.join(pauseRoot, "question.md");
25
34
  fs.mkdirSync(pauseRoot, { recursive: true });
26
35
  fs.writeFileSync(questionPath, question);
27
36
  const now = context.now();
28
- context.db.exec("BEGIN IMMEDIATE");
37
+ context.db.beginWriteTransaction();
29
38
  try {
30
39
  const paused = context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
31
40
  if (paused.changes !== 1)
@@ -53,34 +62,57 @@ export function pauseStageForUser(context, input) {
53
62
  resume_command_template: resumeTemplate
54
63
  };
55
64
  }
56
- 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);
57
69
  const projectRoot = resolveProjectRoot(input.projectRoot);
58
70
  const project = requireProjectByRoot(context, projectRoot);
59
71
  const run = requireRun(context, project.id, input.runId);
72
+ assertRunMutationAllowed(context, project.id, run.id);
60
73
  const work = requireWork(context, project.id, run.id, input.workId);
61
74
  if (work.status !== "paused")
62
75
  throw new AppError("invalid_work_state", "stage resume requires its paused Work", 1, { work_id: work.work_id, status: work.status });
63
76
  const stage = requireStage(run, input.stage, "paused");
64
77
  if (!stage.pause || stage.pause.work_id !== work.work_id || stage.pause.answer_path)
65
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 });
66
- if (!input.answer.trim())
67
- 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);
68
97
  const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
69
98
  projectId: project.id,
70
99
  matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
71
100
  errorCode: "trusted_stage_resume_required",
72
101
  operation: "stage resume"
73
102
  }).eventKey;
74
- const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
75
- fs.writeFileSync(answerPath, input.answer);
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
- context.db.exec("BEGIN IMMEDIATE");
104
+ const answerPath = path.join(path.dirname(pause.question_path), `answer-${crypto.createHash("sha256").update(`${pause.id}\0${input.answer}`).digest("hex")}.md`);
105
+ context.db.beginWriteTransaction();
81
106
  let identity;
82
107
  try {
83
108
  identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
109
+ try {
110
+ fs.writeFileSync(answerPath, input.answer, { flag: "wx", mode: 0o600 });
111
+ }
112
+ catch (error) {
113
+ if (error.code !== "EEXIST" || fs.readFileSync(answerPath, "utf8") !== input.answer)
114
+ throw error;
115
+ }
84
116
  if (workSession.session_id !== identity.sessionId) {
85
117
  bindSessionForResume(context, work.work_id, identity, now);
86
118
  context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
@@ -98,18 +130,20 @@ export function resumeStageAfterUser(context, input) {
98
130
  }
99
131
  refreshRunWorkProjection(context, project.id, run.id);
100
132
  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");
133
+ const prompt = evidence.prompt;
134
+ const retryCommands = lifecycleRetryCommands(context, stage.stage);
102
135
  const continuation = [
103
136
  prompt.trimEnd(),
104
137
  "",
105
138
  "<hitl_resume>",
106
- `- Pause: ${stage.pause.id}`,
107
- `- Question source: ${stage.pause.question_path}`,
139
+ `- Pause: ${pause.id}`,
140
+ `- Question source: ${pause.question_path}`,
108
141
  `- Answer source: ${answerPath}`,
109
142
  "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
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}`)] : []),
110
144
  "",
111
145
  "<user_question>",
112
- fs.readFileSync(stage.pause.question_path, "utf8"),
146
+ evidence.question,
113
147
  "</user_question>",
114
148
  "",
115
149
  "<user_answer>",
@@ -118,10 +152,10 @@ export function resumeStageAfterUser(context, input) {
118
152
  "</hitl_resume>",
119
153
  ""
120
154
  ].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}` };
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}` };
122
156
  }
123
157
  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`;
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`);
125
159
  }
126
160
  /**
127
161
  * The only shell form allowed for an agent-owned HITL pause. Supplying a
@@ -147,7 +181,7 @@ Effect on scope or acceptance:
147
181
  USER_QUESTION`;
148
182
  }
149
183
  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`;
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`);
151
185
  }
152
186
  export function flowCommand(context) {
153
187
  const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
@@ -158,7 +192,7 @@ export function flowCommand(context) {
158
192
  // make every generated lifecycle command self-contained.
159
193
  const executable = context.env.DD_FLOW_BIN;
160
194
  if (executable && path.isAbsolute(executable)) {
161
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
195
+ return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} "$DD_FLOW_BIN"`;
162
196
  }
163
197
  const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
164
198
  if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
@@ -179,8 +213,16 @@ function requireWork(context, projectId, runId, workId) {
179
213
  return work;
180
214
  }
181
215
  function requireStage(run, stage, status) {
182
- const index = JSON.parse(run.index_json);
183
- 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);
184
226
  if (!found || found.status !== status)
185
227
  throw new AppError("invalid_stage_state", `Stage must be ${status}`, 1, { run_id: run.id, stage, status: found?.status ?? "missing" });
186
228
  return found;