@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100

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 (153) hide show
  1. package/CHANGELOG.md +561 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +91 -15
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1506 -334
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  11. package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
  12. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  13. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  14. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  15. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  16. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
  19. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  21. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  22. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  23. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  24. package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
  25. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
  26. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  27. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
  28. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  29. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  30. package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  33. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  34. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  35. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  36. package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
  37. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  38. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  39. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  40. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  41. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  42. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  43. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  44. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  45. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  46. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  47. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  48. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  49. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  50. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  51. package/dist/runtime/context.js +3 -1
  52. package/dist/schemas/agent-profile.schema.json +1 -1
  53. package/dist/schemas/code-review-result.schema.json +2 -2
  54. package/dist/schemas/code-work-batch.schema.json +4 -3
  55. package/dist/schemas/code-work-result.schema.json +4 -4
  56. package/dist/schemas/harness-config.schema.json +23 -0
  57. package/dist/schemas/plan-review-decision.schema.json +1 -1
  58. package/dist/schemas/plan-review-result.schema.json +2 -2
  59. package/dist/schemas/run-control-receipt.schema.json +99 -0
  60. package/dist/schemas/run-control-request.schema.json +36 -0
  61. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  62. package/dist/services/canon.js +11 -8
  63. package/dist/services/cleanup.js +75 -21
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +676 -79
  66. package/dist/services/codex-hook-delivery.js +28 -0
  67. package/dist/services/command-context.js +80 -0
  68. package/dist/services/config.js +11 -8
  69. package/dist/services/continuation-outcome.js +20 -0
  70. package/dist/services/controller-fanout.js +134 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +125 -80
  73. package/dist/services/eval-snapshots.js +838 -74
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +110 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +68 -0
  78. package/dist/services/hooks.js +609 -310
  79. package/dist/services/lanes.js +62 -53
  80. package/dist/services/lifecycle-command.js +103 -17
  81. package/dist/services/lifecycle-contract.js +30 -0
  82. package/dist/services/lifecycle-invocations.js +1128 -0
  83. package/dist/services/managed-daemon-binding.js +38 -0
  84. package/dist/services/managed-processes.js +425 -0
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +78 -27
  87. package/dist/services/migrations.js +14 -9
  88. package/dist/services/native-daemon-history.js +49 -0
  89. package/dist/services/native-session-control.js +39 -0
  90. package/dist/services/plan-runtime.js +19 -8
  91. package/dist/services/plans.js +28 -25
  92. package/dist/services/portable-refs.js +57 -0
  93. package/dist/services/projects.js +18 -5
  94. package/dist/services/prompts.js +27 -18
  95. package/dist/services/protocols.js +64 -20
  96. package/dist/services/recovery-observation-budget.js +61 -0
  97. package/dist/services/recovery-snapshot-database.js +107 -0
  98. package/dist/services/repair-intents.js +40 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +363 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +222 -0
  103. package/dist/services/run-controller-capture.js +136 -0
  104. package/dist/services/run-controller-process.js +189 -0
  105. package/dist/services/run-controller-recovery.js +222 -0
  106. package/dist/services/run-controller-state.js +36 -0
  107. package/dist/services/run-controller.js +803 -0
  108. package/dist/services/run-engine-bindings.js +20 -62
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-projection.js +10 -8
  112. package/dist/services/run-recovery-runtime.js +69 -0
  113. package/dist/services/run-recovery.js +343 -0
  114. package/dist/services/runs.js +437 -81
  115. package/dist/services/runtime-budget.js +439 -0
  116. package/dist/services/runtime-command.js +43 -0
  117. package/dist/services/runtime-scope-capture.js +50 -0
  118. package/dist/services/runtime-scope-control.js +450 -0
  119. package/dist/services/runtime-scope-resume.js +565 -0
  120. package/dist/services/runtime-scope-stop.js +99 -0
  121. package/dist/services/runtime-scope-worker.js +243 -0
  122. package/dist/services/runtime-service.js +97 -0
  123. package/dist/services/schema-validation.js +21 -19
  124. package/dist/services/session-identity.js +19 -0
  125. package/dist/services/sessions.js +61 -80
  126. package/dist/services/stage-blocker.js +17 -7
  127. package/dist/services/stage-context.js +44 -18
  128. package/dist/services/stage-lifecycle.js +112 -108
  129. package/dist/services/stage-pause.js +117 -73
  130. package/dist/services/stage-work-graph.js +35 -0
  131. package/dist/services/usage.js +108 -132
  132. package/dist/services/vnext-code-review.js +245 -98
  133. package/dist/services/vnext-code.js +355 -257
  134. package/dist/services/vnext-execution-profile.js +5 -3
  135. package/dist/services/vnext-fanout.js +166 -26
  136. package/dist/services/vnext-merge.js +413 -147
  137. package/dist/services/vnext-plan-review.js +182 -109
  138. package/dist/services/vnext-plan.js +199 -82
  139. package/dist/services/vnext-protocolize.js +133 -60
  140. package/dist/services/vnext-specify.js +94 -71
  141. package/dist/services/work-registry.js +813 -160
  142. package/dist/services/workspace-bootstrap.js +76 -0
  143. package/dist/services/worktrees.js +85 -35
  144. package/dist/shared/entity-references.js +8 -0
  145. package/dist/shared/errors.js +12 -0
  146. package/dist/storage/database.js +569 -16
  147. package/dist/storage/paths.js +17 -4
  148. package/dist/storage/work-references.js +23 -0
  149. package/dist/storage/writer-contract.js +77 -0
  150. package/dist/storage/writer-migration.js +102 -0
  151. package/package.json +24 -13
  152. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
  153. 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,61 +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
- }
56
- const intakeDir = path.join(started.run.run_home_path ?? path.dirname(started.run.id), "intake");
45
+ registerProtocol(context, { handshakeId: bootstrap.protocolId, projectRoot });
46
+ const started = startFlowRun(context, bootstrap.startInput, bootstrap.start);
47
+ if (intakeMarkdown !== undefined) {
48
+ if (!started.run.run_root)
49
+ throw new AppError("runtime_missing", "New RUN has no artifact root", 1, { run_id: started.run.id });
50
+ const intakeDir = path.join(started.run.run_root, "intake");
57
51
  fs.mkdirSync(intakeDir, { recursive: true });
58
- fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
52
+ fs.writeFileSync(path.join(intakeDir, "user-request.md"), intakeMarkdown);
59
53
  }
60
54
  return started.run.id;
61
55
  }
62
- function stagePreflight(projectRoot, stageRoot) {
56
+ function stagePreflight(projectRoot) {
63
57
  const memoryBank = preflightMemoryPermissions({
64
58
  root: projectRoot,
65
59
  memoryBank: ".memory-bank",
66
60
  flow: "custom",
67
- mode: "write",
61
+ mode: "read",
68
62
  targets: [{ path: ".memory-bank", mode: "read" }]
69
63
  });
70
- const probe = path.join(stageRoot, `.dd-flow-permission-${crypto.randomUUID()}`);
71
- try {
72
- fs.writeFileSync(probe, "");
73
- fs.renameSync(probe, `${probe}.renamed`);
74
- fs.rmSync(`${probe}.renamed`);
75
- return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
76
- }
77
- catch (error) {
78
- for (const candidate of [probe, `${probe}.renamed`]) {
79
- try {
80
- fs.rmSync(candidate, { force: true });
81
- }
82
- catch { /* best effort */ }
83
- }
84
- return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
85
- }
64
+ if (memoryBank.ok !== true)
65
+ throw new AppError("permission_preflight_failed", "Stage project context is not readable", 1, { preflight: memoryBank });
66
+ return memoryBank;
86
67
  }
87
68
  function flowKindForStage(stage) {
88
69
  return ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning";
@@ -101,23 +82,57 @@ function runtimeFacts(view, projectRoot) {
101
82
  git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
102
83
  };
103
84
  }
104
- 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)) {
105
119
  if (input.requireSessionBinding && !input.hookEventId) {
106
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);
107
121
  }
108
- 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) });
109
125
  let requiredSessionId = null;
110
126
  let requiredIdentity = null;
111
127
  if (input.requireSessionBinding) {
112
- registerProject(context, { root: projectRoot });
113
128
  requiredIdentity = hookSessionIdentity(context, requireProjectByRoot(context, projectRoot).id, input.hookEventId);
114
129
  requiredSessionId = requiredIdentity.sessionId;
115
130
  }
116
- const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
131
+ const runId = prepared.bootstrap ? bootstrapStageRun(context, projectRoot, prepared.bootstrap, prepared.intakeMarkdown) : input.runId;
117
132
  if (!runId)
118
133
  throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
119
- const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
120
- 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);
121
136
  const attached = runView(attachFlowRunStage(context, {
122
137
  projectRoot,
123
138
  runId,
@@ -130,9 +145,6 @@ export function startStage(context, input) {
130
145
  const stageRoot = path.join(runHome, dir);
131
146
  ensureWithin(runHome, stageRoot, "stage root");
132
147
  fs.mkdirSync(stageRoot, { recursive: true });
133
- const preflight = stagePreflight(projectRoot, stageRoot);
134
- if (preflight.ok !== true)
135
- throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
136
148
  if (attached.run.subject.type === "protocol") {
137
149
  syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
138
150
  }
@@ -140,7 +152,7 @@ export function startStage(context, input) {
140
152
  if (sessionId) {
141
153
  const identity = requiredIdentity ?? (input.hookEventId ? hookSessionIdentity(context, attached.run.project_id, input.hookEventId) : null);
142
154
  registerFlowSession(context, {
143
- sessionId,
155
+ hookEventId: input.hookEventId,
144
156
  payloadJson: JSON.stringify({
145
157
  project_root: projectRoot,
146
158
  harness: identity?.harness ?? "codex-desktop",
@@ -165,7 +177,7 @@ export function startStage(context, input) {
165
177
  });
166
178
  }
167
179
  const promptPath = path.join(stageRoot, "stage-prompt.md");
168
- const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
180
+ const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight, sources);
169
181
  atomicWrite(promptPath, prompt);
170
182
  const semanticInputPath = path.join(stageRoot, "stage-input.json");
171
183
  if (!fs.existsSync(semanticInputPath)) {
@@ -176,7 +188,6 @@ export function startStage(context, input) {
176
188
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
177
189
  const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
178
190
  const attemptNumber = Number(attempt.replace("try-", "")) || 1;
179
- const sources = stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage);
180
191
  atomicWrite(promptDataPath, {
181
192
  schema_id: "dd-flow/stage-prompt@2",
182
193
  run_id: attached.run.id,
@@ -216,7 +227,7 @@ export function startStage(context, input) {
216
227
  })),
217
228
  worker_prompt_markdown: prompt
218
229
  });
219
- validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, ddFlowHome: context.ddFlowHome });
230
+ validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, runRoot: runHome, ddFlowHome: context.ddFlowHome });
220
231
  atomicWrite(path.join(stageRoot, "stage-start.json"), {
221
232
  schema_id: "dd-flow/stage-start@2",
222
233
  run_id: attached.run.id,
@@ -255,12 +266,13 @@ export function startStage(context, input) {
255
266
  lifecycle: "stage start -> semantic work -> stage finish"
256
267
  };
257
268
  }
258
- export function finishStage(context, input) {
269
+ export function prepareStageFinish(context, input) {
259
270
  const projectRoot = resolveProjectRoot(input.projectRoot);
260
271
  if (!input.runId)
261
272
  throw new AppError("usage", "stage finish requires a RUN id", 2);
262
273
  const runId = input.runId;
263
274
  const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
275
+ assertRunMutationAllowed(context, view.run.project_id, view.run.id);
264
276
  const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
265
277
  if (!existing) {
266
278
  throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
@@ -269,31 +281,38 @@ export function finishStage(context, input) {
269
281
  const runHome = runHomePath(view.run);
270
282
  const stageRoot = path.join(runHome, dir);
271
283
  ensureWithin(runHome, stageRoot, "stage root");
272
- fs.mkdirSync(stageRoot, { recursive: true });
273
284
  const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
274
- 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);
275
301
  try {
276
- validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, ddFlowHome: context.ddFlowHome });
277
- const semantic = readSemanticFile(semanticFile, runHome);
278
- const status = stringValue(semantic.status) ?? "done";
279
- if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
280
- throw new AppError("validation", "Stage finish status must be done, waiting_for_user, blocked, or failed", 2, { status });
281
- }
282
- validateStageOutcome(input.stage, status, semantic);
283
302
  const coverage = workerCoverage(context, view.run.project_id, view.run.id);
284
303
  if (coverage.jobs.missing?.length) {
285
304
  throw new AppError("worker_jobs_incomplete", "Stage finish requires all declared worker jobs", 1, { coverage });
286
305
  }
287
306
  const lint = runTargetedMemoryBankLint(projectRoot);
288
- const planFinish = input.stage === "plan"
289
- ? preparePlanFinish(context, projectRoot, view, stageRoot)
290
- : 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);
291
310
  const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
292
311
  const dataPath = path.join(stageRoot, "stage-report.json");
293
312
  const reportPath = path.join(stageRoot, "stage-report.md");
294
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 });
295
315
  atomicWrite(dataPath, report);
296
- validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, ddFlowHome: context.ddFlowHome });
297
316
  atomicWrite(reportPath, renderMarkdown(report));
298
317
  atomicWrite(htmlPath, renderHtml(projectRoot, report));
299
318
  if (view.run.subject.type === "protocol") {
@@ -308,7 +327,8 @@ export function finishStage(context, input) {
308
327
  stageReport: htmlPath,
309
328
  data: dataPath,
310
329
  dataSchemaId: String(report.schema_id),
311
- report: reportPath
330
+ report: reportPath,
331
+ usage: stageUsage
312
332
  });
313
333
  finishReceipt(receipt, { outcome: "accepted", result: { status, report: dataPath } });
314
334
  appendAudit(context, {
@@ -410,7 +430,12 @@ function nextActionForProtocolStage(stage) {
410
430
  return `continue_${stage}`;
411
431
  }
412
432
  function runHomePath(run) {
413
- return run.run_home_path ?? path.dirname(run.run_index_path);
433
+ if (!run.run_root)
434
+ throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
435
+ return run.run_root;
436
+ }
437
+ function runStatePath(run) {
438
+ return path.join(runHomePath(run), "run.json");
414
439
  }
415
440
  export function defaultStageDir(stage, flowKind) {
416
441
  const normalized = stage === "implementation" ? "code" : stage;
@@ -473,12 +498,10 @@ function semanticInputTemplate(stage) {
473
498
  next_action: waiting ? "waiting_for_user: obtain the clarification packet" : "Continue to the next approved stage."
474
499
  };
475
500
  }
476
- function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
501
+ function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight, sources) {
477
502
  const project = requireProjectByRoot(context, projectRoot);
478
503
  const runHome = runHomePath(current.run);
479
- const instructions = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage).map(({ label, absolute }) => {
480
- return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
481
- }).join("\n\n");
504
+ const instructions = sources.map(({ label, text }) => `### ${label}\n\n${text}`).join("\n\n");
482
505
  const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
483
506
  return [
484
507
  "<stage_identity>",
@@ -501,7 +524,7 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
501
524
  "</authoritative_runtime_facts>",
502
525
  "",
503
526
  "<preflight>",
504
- `- Permission probe: ${String(preflight.ok === true ? "passed" : "failed")}.`,
527
+ `- Read-only permission check: ${String(preflight.ok === true ? "passed" : "failed")}.`,
505
528
  "- CLI compatibility was checked before this state-changing command.",
506
529
  "</preflight>",
507
530
  "",
@@ -574,16 +597,6 @@ function safeProtocolState(context, projectId, protocolId) {
574
597
  return undefined;
575
598
  }
576
599
  }
577
- function readSemanticFile(file, runHome) {
578
- const resolved = path.resolve(file);
579
- ensureWithin(runHome, resolved, "semantic file");
580
- if (!fs.existsSync(resolved))
581
- throw new AppError("not_found", "Semantic input file is missing", 1, { path: file });
582
- const value = JSON.parse(fs.readFileSync(resolved, "utf8"));
583
- if (!value || typeof value !== "object" || Array.isArray(value))
584
- throw new AppError("validation", "Semantic input must be a JSON object", 2);
585
- return value;
586
- }
587
600
  function validateStageOutcome(stage, status, semantic) {
588
601
  if (stage !== "specify")
589
602
  return;
@@ -595,17 +608,12 @@ function validateStageOutcome(stage, status, semantic) {
595
608
  throw new AppError("semantic_status_inconsistent", "SPECIFY done cannot contain unresolved structured questions", 2);
596
609
  }
597
610
  }
598
- function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage) {
611
+ function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage, raw) {
599
612
  const receiptDir = path.join(stageRoot, "finish-receipts");
600
613
  fs.mkdirSync(receiptDir, { recursive: true });
601
614
  const number = fs.readdirSync(receiptDir).filter((file) => /^\d{3}\.json$/u.test(file)).length + 1;
602
615
  const receiptPath = path.join(receiptDir, `${String(number).padStart(3, "0")}.json`);
603
- let raw = null;
604
- try {
605
- raw = fs.readFileSync(semanticFile, "utf8");
606
- }
607
- catch { /* receipt records the missing input below */ }
608
- const semanticSha = raw === null ? null : crypto.createHash("sha256").update(raw).digest("hex");
616
+ const semanticSha = crypto.createHash("sha256").update(raw).digest("hex");
609
617
  atomicWrite(receiptPath, {
610
618
  schema_id: "dd-flow/stage-finish-receipt@1",
611
619
  run_id: runId,
@@ -702,7 +710,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
702
710
  if (stage === "code" || stage === "implementation") {
703
711
  return {
704
712
  schema_id: "dd-flow/code-stage-report@2",
705
- run: { run_id: view.run.id, run_state: view.run.run_index_path },
713
+ run: { run_id: view.run.id, run_state: runStatePath(view.run) },
706
714
  stage: { name: stage, dir: path.basename(stageRoot), status },
707
715
  project: { id: view.run.project_id, title: path.basename(view.run.project_root) },
708
716
  subject: { id: view.run.subject.id, title: view.run.subject.id },
@@ -712,7 +720,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
712
720
  summary: result,
713
721
  next_action: stringValue(semantic.next_action) ?? "Proceed to readiness."
714
722
  },
715
- breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }],
723
+ breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }],
716
724
  implemented_goals: [{ title: `Stage ${stage}`, summary: result }],
717
725
  acceptance_scenarios: acceptance.map((summary, index) => ({
718
726
  id: `SCN-${stage}-${index + 1}`,
@@ -763,22 +771,18 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
763
771
  memory_bank_scope: "changed_files_and_links_only",
764
772
  status: "passed"
765
773
  },
766
- breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }]
774
+ breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }]
767
775
  };
768
776
  }
769
777
  function preparePlanFinish(context, projectRoot, view, stageRoot) {
770
778
  const planPath = planJsonPath(projectRoot, view.run.subject.id);
771
- const canonical = boundCanonicalPlan(context, {
779
+ const canonical = prepareCanonicalPlan(context, {
772
780
  projectId: view.run.project_id,
773
781
  protocolId: view.run.subject.id,
774
782
  planPath
775
783
  });
776
784
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
777
- if (!fs.existsSync(aspectMapPath)) {
778
- throw new AppError("not_found", "PLAN aspect-map.json is missing", 1, { path: aspectMapPath });
779
- }
780
- const raw = fs.readFileSync(aspectMapPath, "utf8");
781
- const value = JSON.parse(raw);
785
+ const { text: raw, value } = prepareJsonFile(aspectMapPath, "PLAN aspect map");
782
786
  if (!value || typeof value !== "object" || Array.isArray(value)) {
783
787
  throw new AppError("validation", "PLAN aspect-map.json must be a JSON object", 2, { path: aspectMapPath });
784
788
  }