@deksden-com/dd-flow-cli 0.4.2 → 0.5.0

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 (49) hide show
  1. package/CHANGELOG.md +27 -4
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/help.js +14 -3
  4. package/dist/cli/run-cli.js +32 -16
  5. package/dist/domain/flow-contract.js +81 -2
  6. package/dist/domain/validation.js +56 -28
  7. package/dist/protocol/local-files.js +1 -16
  8. package/dist/schemas/code-stage-report.schema.json +2 -2
  9. package/dist/schemas/flow-contract.schema.json +150 -94
  10. package/dist/schemas/flow-run.schema.json +129 -23
  11. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  12. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  13. package/dist/schemas/merge-stage-report.schema.json +2 -2
  14. package/dist/schemas/plan-stage-report.schema.json +38 -335
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  16. package/dist/schemas/protocol-plan.schema.json +197 -0
  17. package/dist/schemas/release-impact.schema.json +9 -5
  18. package/dist/schemas/session-usage.schema.json +16 -0
  19. package/dist/schemas/stage-finish-input.schema.json +20 -0
  20. package/dist/schemas/stage-prompt.schema.json +31 -0
  21. package/dist/schemas/stage-report.schema.json +20 -0
  22. package/dist/schemas/stage-start-response.schema.json +29 -0
  23. package/dist/schemas/timeline-event.schema.json +29 -0
  24. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  25. package/dist/services/branch-context.js +9 -4
  26. package/dist/services/dashboard.js +51 -26
  27. package/dist/services/engines.js +84 -18
  28. package/dist/services/hooks.js +80 -246
  29. package/dist/services/memory-permissions.js +77 -69
  30. package/dist/services/plan-runtime.js +124 -0
  31. package/dist/services/plans.js +22 -84
  32. package/dist/services/projects.js +2 -1
  33. package/dist/services/prompts.js +26 -21
  34. package/dist/services/protocols.js +29 -25
  35. package/dist/services/run-projection.js +77 -11
  36. package/dist/services/runs.js +95 -61
  37. package/dist/services/schema-validation.js +168 -7
  38. package/dist/services/sessions.js +132 -68
  39. package/dist/services/stage-lifecycle.js +572 -0
  40. package/dist/services/tooling.js +285 -0
  41. package/dist/services/usage.js +183 -30
  42. package/dist/services/version-status.js +1 -1
  43. package/dist/services/worktrees.js +88 -39
  44. package/dist/storage/database.js +72 -30
  45. package/dist/storage/paths.js +0 -9
  46. package/package.json +14 -13
  47. package/tools/worktrunk-manifest.json +34 -0
  48. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  49. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -0,0 +1,572 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { requireProjectByRoot } from "./projects.js";
6
+ import { appendAudit } from "./audit.js";
7
+ import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from "./protocols.js";
8
+ import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus } from "./runs.js";
9
+ import { validateSchema } from "./schema-validation.js";
10
+ import { reconcileSessionCoverage } from "./sessions.js";
11
+ import { usageForRun } from "./usage.js";
12
+ import { boundCanonicalPlan } from "./plan-runtime.js";
13
+ import { AppError } from "../shared/errors.js";
14
+ import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
15
+ export function startStage(context, input) {
16
+ const projectRoot = resolveProjectRoot(input.projectRoot);
17
+ const before = runView(getFlowRunStatus(context, { projectRoot, runId: input.runId }));
18
+ const dir = input.dir ?? defaultStageDir(input.stage);
19
+ const attached = runView(attachFlowRunStage(context, {
20
+ projectRoot,
21
+ runId: input.runId,
22
+ stage: input.stage,
23
+ dir,
24
+ status: "running",
25
+ dataSchemaId: dataSchemaForStage(input.stage)
26
+ }));
27
+ const runHome = runHomePath(attached.run);
28
+ const stageRoot = path.join(runHome, dir);
29
+ ensureWithin(runHome, stageRoot, "stage root");
30
+ fs.mkdirSync(stageRoot, { recursive: true });
31
+ syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
32
+ const promptPath = path.join(stageRoot, "stage-prompt.md");
33
+ const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir);
34
+ atomicWrite(promptPath, prompt);
35
+ const promptDataPath = path.join(stageRoot, "stage-prompt.json");
36
+ const planPath = planJsonPath(projectRoot, attached.run.subject.id);
37
+ const aspectMapPath = path.join(stageRoot, "aspect-map.json");
38
+ const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
39
+ const attemptNumber = Number(attempt.replace("try-", "")) || 1;
40
+ atomicWrite(promptDataPath, {
41
+ schema_id: "dd-flow/stage-prompt@1",
42
+ run_id: attached.run.id,
43
+ stage: input.stage,
44
+ generated_at: context.now(),
45
+ prompt_path: promptPath,
46
+ sections: ["stage_identity", "runtime_context", "intake", "applicable_instructions", "stage_cli", "file_boundaries", "completion_contract"],
47
+ aliases: {
48
+ project: projectRoot,
49
+ workspace: attached.run.workspace_root,
50
+ run: attached.run.id,
51
+ stage: stageRoot,
52
+ protocol: attached.run.subject.id,
53
+ intake: path.join(runHome, "intake"),
54
+ ...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
55
+ },
56
+ write_boundary: { current: "@stage", archive: attempt, archive_writable: false },
57
+ source_fragments: input.stage === "plan" ? [".memory-bank/dd-flow/plan.md"] : [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"]
58
+ });
59
+ validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot });
60
+ atomicWrite(path.join(stageRoot, "stage-start.json"), {
61
+ schema_id: "dd-flow/stage-start@1",
62
+ run_id: attached.run.id,
63
+ stage: input.stage,
64
+ dir,
65
+ attempt: attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001",
66
+ prompt: promptPath,
67
+ started_at: context.now()
68
+ });
69
+ return {
70
+ ok: true,
71
+ schema_id: "dd-flow/stage-start-response@1",
72
+ run_id: attached.run.id,
73
+ stage: input.stage,
74
+ attempt_number: attemptNumber,
75
+ stage_root: stageRoot,
76
+ archive_path: null,
77
+ prompt_path: promptPath,
78
+ aliases: {
79
+ project: projectRoot,
80
+ workspace: attached.run.workspace_root,
81
+ run: attached.run.id,
82
+ stage: stageRoot,
83
+ protocol: attached.run.subject.id,
84
+ intake: path.join(runHome, "intake"),
85
+ ...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
86
+ },
87
+ ...(input.stage === "plan" ? { plan_ref: planPath, aspect_map_ref: aspectMapPath } : {}),
88
+ resolved_context: { run: attached.run, stage: { name: input.stage, dir, status: "running" } },
89
+ next_command: "dd-flow stage finish",
90
+ permission_probe: { status: "known_targets_only", project_root: projectRoot },
91
+ run: attached.run,
92
+ prompt: { path: promptPath, data_path: promptDataPath },
93
+ lifecycle: "stage start -> semantic work -> stage finish"
94
+ };
95
+ }
96
+ export function finishStage(context, input) {
97
+ const projectRoot = resolveProjectRoot(input.projectRoot);
98
+ const view = runView(getFlowRunStatus(context, { projectRoot, runId: input.runId }));
99
+ const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
100
+ if (!existing) {
101
+ throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
102
+ }
103
+ const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage);
104
+ const runHome = runHomePath(view.run);
105
+ const stageRoot = path.join(runHome, dir);
106
+ ensureWithin(runHome, stageRoot, "stage root");
107
+ fs.mkdirSync(stageRoot, { recursive: true });
108
+ if (!input.semanticFile) {
109
+ throw new AppError("stage_finish_input_required", "Stage finish requires --semantic-file with dd-flow/stage-finish-input@1", 2);
110
+ }
111
+ const semanticFile = resolveStageFile(input.semanticFile, stageRoot, runHome);
112
+ validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot });
113
+ const semantic = readSemanticFile(semanticFile, runHome);
114
+ const status = input.status ?? "done";
115
+ if (!["done", "blocked", "failed"].includes(status)) {
116
+ throw new AppError("validation", "Stage finish status must be done, blocked, or failed", 2, { status });
117
+ }
118
+ const coverage = workerCoverage(context, view.run.project_id, view.run.id);
119
+ if (coverage.status !== "complete") {
120
+ throw new AppError("worker_coverage_incomplete", "Stage finish requires complete worker coverage", 1, { coverage });
121
+ }
122
+ const lint = runTargetedMemoryBankLint(projectRoot, semantic);
123
+ const planFinish = input.stage === "plan"
124
+ ? preparePlanFinish(context, projectRoot, view, stageRoot)
125
+ : undefined;
126
+ const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
127
+ const dataPath = path.join(stageRoot, "stage-report.json");
128
+ const reportPath = path.join(stageRoot, "stage-report.md");
129
+ const htmlPath = path.join(stageRoot, "stage-report.html");
130
+ atomicWrite(dataPath, report);
131
+ validateSchema({ schemaName: planFinish ? "plan-stage-report" : "stage-report", file: dataPath, projectRoot });
132
+ atomicWrite(reportPath, renderMarkdown(report));
133
+ atomicWrite(htmlPath, renderHtml(projectRoot, report));
134
+ syncProtocolLifecycle(context, view.run.project_id, view.run.subject.id, input.stage, status, stringValue(semantic.next_action));
135
+ const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
136
+ const completed = completeFlowRunStage(context, {
137
+ projectRoot,
138
+ runId: input.runId,
139
+ stage: input.stage,
140
+ status,
141
+ stageReport: htmlPath,
142
+ data: dataPath,
143
+ dataSchemaId: String(report.schema_id),
144
+ report: reportPath
145
+ });
146
+ return {
147
+ ok: true,
148
+ schema_id: "dd-flow/stage-finish@1",
149
+ ...(completed && typeof completed === "object" && !Array.isArray(completed) ? completed : {}),
150
+ artifacts: { json: dataPath, markdown: reportPath, html: htmlPath, ...(summaryPath ? { protocol_summary: summaryPath } : {}) },
151
+ gates: { worker_coverage: coverage, targeted_lint: lint },
152
+ generated: true
153
+ };
154
+ }
155
+ function runView(value) {
156
+ if (!value || typeof value !== "object")
157
+ throw new AppError("validation", "RUN status is malformed", 2);
158
+ const view = value;
159
+ if (!view.run || !view.index)
160
+ throw new AppError("validation", "RUN status is missing run or index", 2);
161
+ return view;
162
+ }
163
+ function syncProtocolLifecycle(context, projectId, protocolId, stage, status, nextAction) {
164
+ const targetStage = protocolStageForRunStage(stage);
165
+ if (!targetStage)
166
+ return;
167
+ const protocol = requireProtocol(context, protocolId, projectId);
168
+ const { state } = readProtocolRuntimeState(context, protocol);
169
+ const currentRank = protocolStageRank(state.stage);
170
+ const requestedRank = protocolStageRank(targetStage);
171
+ const completedTarget = status === "done" ? nextStageAfterFinish(targetStage) : targetStage;
172
+ const completedRank = protocolStageRank(completedTarget);
173
+ const effectiveStage = completedRank >= currentRank ? completedTarget : state.stage;
174
+ const effectiveStatus = status === "done" ? "running" : status;
175
+ const effectiveNextAction = nextAction ?? nextActionForProtocolStage(effectiveStage);
176
+ if (effectiveStage === state.stage && effectiveStatus === state.status && effectiveNextAction === state.next_action)
177
+ return;
178
+ const nextState = {
179
+ ...state,
180
+ stage: effectiveStage,
181
+ status: effectiveStatus,
182
+ next_action: effectiveNextAction,
183
+ updated_at: context.now()
184
+ };
185
+ persistProtocolState(context, protocol, nextState);
186
+ appendAudit(context, {
187
+ protocolId,
188
+ projectId,
189
+ eventType: "protocol.stage_lifecycle_synced",
190
+ payload: {
191
+ source_stage: stage,
192
+ from: state.stage,
193
+ to: effectiveStage,
194
+ status: effectiveStatus,
195
+ requested_rank: requestedRank
196
+ }
197
+ });
198
+ }
199
+ function protocolStageForRunStage(stage) {
200
+ if (stage === "specify")
201
+ return "specify";
202
+ if (stage === "plan")
203
+ return "plan";
204
+ if (stage === "code" || stage === "implementation")
205
+ return "implementation";
206
+ return null;
207
+ }
208
+ function nextStageAfterFinish(stage) {
209
+ return stage === "specify" ? "plan" : "implementation";
210
+ }
211
+ function protocolStageRank(stage) {
212
+ return { registered: 0, specify: 1, plan: 2, implementation: 3 }[stage] ?? -1;
213
+ }
214
+ function nextActionForProtocolStage(stage) {
215
+ if (stage === "registered" || stage === "specify")
216
+ return "run_plan";
217
+ if (stage === "plan")
218
+ return "run_code_flow";
219
+ if (stage === "implementation")
220
+ return "run_readiness_gate";
221
+ return `continue_${stage}`;
222
+ }
223
+ function runHomePath(run) {
224
+ return run.run_home_path ?? path.dirname(run.run_index_path);
225
+ }
226
+ function defaultStageDir(stage) {
227
+ const normalized = stage === "implementation" ? "code" : stage;
228
+ const known = { specify: "01-specify", plan: "02-plan", code: "03-code", readiness: "03-code", merge: "04-merge" };
229
+ return known[normalized] ?? `03-${normalized.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
230
+ }
231
+ function dataSchemaForStage(stage) {
232
+ if (stage === "plan")
233
+ return "dd-flow/plan-stage-report@5";
234
+ if (stage === "code" || stage === "implementation")
235
+ return "dd-flow/code-stage-report@2";
236
+ if (stage === "merge")
237
+ return "dd-flow/merge-stage-report@2";
238
+ return "dd-flow/code-stage-report@2";
239
+ }
240
+ function composeStagePrompt(context, projectRoot, before, current, stage, dir) {
241
+ const project = requireProjectByRoot(context, projectRoot);
242
+ const runHome = runHomePath(current.run);
243
+ const flowFiles = stage === "plan"
244
+ ? [".memory-bank/dd-flow/plan.md"]
245
+ : [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"];
246
+ const instructions = flowFiles.map((file) => {
247
+ const absolute = path.join(projectRoot, file);
248
+ return fs.existsSync(absolute) ? `### ${file}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${file}\n\n(unavailable)`;
249
+ }).join("\n\n");
250
+ const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
251
+ return [
252
+ "# Stage Prompt",
253
+ "",
254
+ "## Identity",
255
+ `- Project: ${project.id}`,
256
+ `- Run: ${current.run.id}`,
257
+ `- Protocol: ${current.run.subject.id}`,
258
+ `- Stage: ${stage}`,
259
+ `- Attempt: ${current.index.stage_runs?.find((item) => item.stage === stage)?.attempt ?? "try-001"}`,
260
+ "",
261
+ "## Runtime",
262
+ `- Project root: ${projectRoot}`,
263
+ `- Workspace root: ${current.run.workspace_root}`,
264
+ `- Run home: ${runHome}`,
265
+ `- Stage root: ${path.join(runHome, dir)}`,
266
+ `- Git branch: ${current.index.execution?.git?.branch ?? "unknown"}`,
267
+ `- Git head: ${current.index.execution?.git?.head ?? "unknown"}`,
268
+ "",
269
+ "## Intake",
270
+ "Raw intake and provider telemetry stay under the RUN home. Promote only durable decisions to the protocol summary.",
271
+ `- Protocol next action: ${protocol?.next_action ?? "not recorded"}`,
272
+ `- Previous stage: ${before.index.stage_runs?.map((item) => `${item.stage}:${item.status}`).join(", ") || "none"}`,
273
+ "",
274
+ "## Instructions",
275
+ instructions,
276
+ "",
277
+ "## CLI",
278
+ "Use the project-compatible dd-flow CLI for state transitions and artifact registration. Keep paths inside the selected workspace and RUN home.",
279
+ "",
280
+ "## Boundaries",
281
+ "Do not use raw Git as a workspace manager, unverified tools, PATH/latest fallbacks, recursive unrelated validation, or model-supplied session identity.",
282
+ "",
283
+ "## Completion Contract",
284
+ `Finish with: dd-flow stage finish ${current.run.id} --stage ${stage} --project-root <project-root> --semantic-file @stage/stage-input.json --json`,
285
+ "The CLI must generate validated JSON, Markdown, HTML and protocol-summary evidence.",
286
+ ].join("\n");
287
+ }
288
+ function safeProtocolState(context, projectId, protocolId) {
289
+ try {
290
+ const protocol = requireProtocol(context, protocolId, projectId);
291
+ return readProtocolRuntimeState(context, protocol).state;
292
+ }
293
+ catch {
294
+ return undefined;
295
+ }
296
+ }
297
+ function readSemanticFile(file, runHome) {
298
+ const resolved = path.resolve(file);
299
+ ensureWithin(runHome, resolved, "semantic file");
300
+ if (!fs.existsSync(resolved))
301
+ throw new AppError("not_found", "Semantic input file is missing", 1, { path: file });
302
+ const value = JSON.parse(fs.readFileSync(resolved, "utf8"));
303
+ if (!value || typeof value !== "object" || Array.isArray(value))
304
+ throw new AppError("validation", "Semantic input must be a JSON object", 2);
305
+ return value;
306
+ }
307
+ function resolveStageFile(file, stageRoot, runHome) {
308
+ const candidate = file === "@stage" || file.startsWith("@stage/")
309
+ ? path.join(stageRoot, file.slice("@stage".length).replace(/^[/\\]/u, ""))
310
+ : path.resolve(file);
311
+ ensureWithin(runHome, candidate, "semantic file");
312
+ return candidate;
313
+ }
314
+ function buildStageReport(context, view, stage, status, semantic, stageRoot, lint, planFinish) {
315
+ const result = stringValue(semantic.result) ?? `Stage ${stage} finished with status ${status}.`;
316
+ const changedFiles = stringArray(semantic.changed_files, gitChangedFiles(view.run.workspace_root));
317
+ const checks = stringArray(semantic.checks, []);
318
+ const evidence = stringArray(semantic.evidence, []);
319
+ const acceptance = stringArray(semantic.acceptance, []);
320
+ const finishedAt = context.now();
321
+ const startedAt = view.index.stage_runs?.find((item) => item.stage === stage)?.started_at ?? finishedAt;
322
+ const usage = usageForRun(context, { projectId: view.run.project_id, runId: view.run.id, groupBy: "session" });
323
+ const usageCoverage = usage && typeof usage === "object" && "coverage" in usage ? usage.coverage : { status: "unavailable", expected: [], observed: [], missing_sessions: [] };
324
+ const wallClockMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
325
+ if (planFinish) {
326
+ const plan = planFinish.canonical.plan;
327
+ const routeDecision = plan.route_decision;
328
+ const codeHandoff = plan.code_handoff;
329
+ const nextAction = stringValue(semantic.next_action) ?? "run code";
330
+ return {
331
+ schema_id: "dd-flow/plan-stage-report@5",
332
+ generated_at: finishedAt,
333
+ protocol: {
334
+ id: view.run.subject.id,
335
+ title: view.run.subject.id,
336
+ project: view.run.project_id,
337
+ branch: view.index.execution?.git?.branch ?? "unknown",
338
+ stage: "plan"
339
+ },
340
+ overall: {
341
+ verdict: status === "done" ? "ready_for_code" : status === "blocked" ? "plan_blocked" : "degraded",
342
+ verdict_label: status === "done" ? "Ready for code" : "Plan requires attention",
343
+ score: status === "done" ? 5 : 0,
344
+ score_max: 5,
345
+ next_action: nextAction,
346
+ summary: result
347
+ },
348
+ route: [{
349
+ label: "planning route",
350
+ value: stringValue(routeDecision?.selected_route) ?? "local_compact",
351
+ note: stringValue(routeDecision?.reason) ?? "Resolved by the canonical protocol plan."
352
+ }],
353
+ plan_ref: {
354
+ path: path.relative(view.run.project_root, planFinish.plan_path).split(path.sep).join("/"),
355
+ plan_id: planFinish.canonical.plan.plan_id,
356
+ revision: planFinish.canonical.revision,
357
+ sha256: planFinish.canonical.sha256,
358
+ item_count: planFinish.canonical.plan.items.length
359
+ },
360
+ aspect_map_ref: {
361
+ path: planFinish.aspect_map_path,
362
+ sha256: planFinish.aspect_map_sha256,
363
+ coverage_count: planFinish.aspect_count
364
+ },
365
+ execution_summary: { wall_clock_ms: wallClockMs },
366
+ delta_lint: {
367
+ status: lint.status,
368
+ selected_files: lint.files,
369
+ finding_count: lint.finding_count
370
+ },
371
+ transition: { status: status === "done" ? "applied" : "blocked", next_stage: status === "done" ? "implementation" : "plan" },
372
+ handoff: {
373
+ code_prompt: ".memory-bank/dd-flow/code.md",
374
+ must_read: Array.isArray(codeHandoff?.must_read) && codeHandoff.must_read.length > 0 ? codeHandoff.must_read : [planFinish.plan_path],
375
+ next_gate: nextAction
376
+ }
377
+ };
378
+ }
379
+ return {
380
+ schema_id: "dd-flow/stage-report@1",
381
+ run_id: view.run.id,
382
+ stage,
383
+ generated_at: finishedAt,
384
+ verdict: status,
385
+ semantic: {
386
+ result,
387
+ acceptance,
388
+ changed_files: changedFiles,
389
+ checks,
390
+ evidence,
391
+ next_action: stringValue(semantic.next_action) ?? "Resolve the stage result before continuing.",
392
+ ...(Array.isArray(semantic.reviewer_findings) ? { reviewer_findings: semantic.reviewer_findings } : {}),
393
+ ...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {})
394
+ },
395
+ mechanical: {
396
+ started_at: startedAt,
397
+ finished_at: finishedAt,
398
+ wall_clock_ms: wallClockMs,
399
+ git: view.index.execution?.git ?? {},
400
+ session_coverage: workerCoverage(context, view.run.project_id, view.run.id),
401
+ usage_coverage: usageCoverage
402
+ },
403
+ artifacts: {
404
+ json: path.join(stageRoot, "stage-report.json"),
405
+ markdown: path.join(stageRoot, "stage-report.md"),
406
+ html: path.join(stageRoot, "stage-report.html"),
407
+ summary: path.join(stageRoot, "stage-report.md")
408
+ },
409
+ validation: {
410
+ permission_scope: "known_targets_only",
411
+ memory_bank_scope: "changed_files_and_links_only",
412
+ status: "passed"
413
+ },
414
+ breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }]
415
+ };
416
+ }
417
+ function preparePlanFinish(context, projectRoot, view, stageRoot) {
418
+ const planPath = planJsonPath(projectRoot, view.run.subject.id);
419
+ const canonical = boundCanonicalPlan(context, {
420
+ projectId: view.run.project_id,
421
+ protocolId: view.run.subject.id,
422
+ planPath
423
+ });
424
+ const aspectMapPath = path.join(stageRoot, "aspect-map.json");
425
+ if (!fs.existsSync(aspectMapPath)) {
426
+ throw new AppError("not_found", "PLAN aspect-map.json is missing", 1, { path: aspectMapPath });
427
+ }
428
+ const raw = fs.readFileSync(aspectMapPath, "utf8");
429
+ const value = JSON.parse(raw);
430
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
431
+ throw new AppError("validation", "PLAN aspect-map.json must be a JSON object", 2, { path: aspectMapPath });
432
+ }
433
+ const map = value;
434
+ const rows = [map.aspects, map.coverage, map.units].find(Array.isArray);
435
+ return {
436
+ canonical,
437
+ plan_path: planPath,
438
+ aspect_map_path: aspectMapPath,
439
+ aspect_map_sha256: crypto.createHash("sha256").update(raw).digest("hex"),
440
+ aspect_count: rows?.length ?? 0
441
+ };
442
+ }
443
+ function stringValue(value) {
444
+ return typeof value === "string" && value.length > 0 ? value : undefined;
445
+ }
446
+ function stringArray(value, fallback) {
447
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : fallback;
448
+ }
449
+ function gitChangedFiles(workspaceRoot) {
450
+ const result = spawnSync("git", ["-C", workspaceRoot, "status", "--short"], { encoding: "utf8" });
451
+ if (result.status !== 0)
452
+ return [];
453
+ return result.stdout.split(/\r?\n/).filter(Boolean).map((line) => line.slice(3).trim()).filter(Boolean);
454
+ }
455
+ function workerCoverage(context, projectId, runId) {
456
+ const sessions = context.db.all("SELECT * FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id", [projectId, runId]);
457
+ const report = reconcileSessionCoverage(sessions);
458
+ const jobs = context.db.all("SELECT job_id, status FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY job_id", [projectId, runId]);
459
+ const missingJobs = jobs.filter((job) => job.status !== "done").map((job) => job.job_id);
460
+ const diagnostics = report.diagnostics.filter((diagnostic) => diagnostic !== "expected_worker_units_missing" || jobs.length > 0);
461
+ return {
462
+ status: diagnostics.length === 0 && missingJobs.length === 0 ? "complete" : "partial",
463
+ expected: report.expected_unit_ids,
464
+ observed: report.observed_unit_ids,
465
+ missing: report.missing_unit_ids,
466
+ diagnostics: [...diagnostics, ...(missingJobs.length > 0 ? ["jobs_unfinished"] : [])],
467
+ jobs: { expected: jobs.map((job) => job.job_id), missing: missingJobs }
468
+ };
469
+ }
470
+ function runTargetedMemoryBankLint(projectRoot, semantic) {
471
+ const declared = stringArray(semantic.changed_files, []);
472
+ const files = [...new Set(declared.filter((file) => file.startsWith(".memory-bank/") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
473
+ for (const file of files)
474
+ ensureWithin(projectRoot, path.resolve(projectRoot, file), "lint target");
475
+ if (files.length === 0) {
476
+ return { ok: true, status: "not_applicable_no_changed_docs", files, finding_count: 0 };
477
+ }
478
+ const binary = process.env.MB_LINT_BIN ?? "mb-lint";
479
+ const result = spawnSync(binary, ["--root", projectRoot, "--files", ...files, "--format", "json"], { cwd: projectRoot, encoding: "utf8" });
480
+ if (result.error || result.status === null) {
481
+ throw new AppError("lint_gate_unavailable", "Selected-file mb-lint is unavailable", 1, { binary, files, cause: String(result.error ?? "process did not start") });
482
+ }
483
+ let parsed = {};
484
+ try {
485
+ parsed = result.stdout ? JSON.parse(result.stdout) : {};
486
+ }
487
+ catch {
488
+ throw new AppError("lint_gate_invalid_output", "Selected-file mb-lint did not return JSON", 1, { binary, files });
489
+ }
490
+ if (result.status !== 0) {
491
+ throw new AppError("lint_gate_failed", "Selected-file mb-lint reported blocking findings", 1, { binary, files, result: parsed });
492
+ }
493
+ const findingCount = parsed && typeof parsed === "object" && Array.isArray(parsed.findings)
494
+ ? parsed.findings.length
495
+ : 0;
496
+ return { ok: true, status: "passed", binary, files, finding_count: findingCount, result: parsed };
497
+ }
498
+ function renderMarkdown(report) {
499
+ const plan = report.schema_id === "dd-flow/plan-stage-report@5";
500
+ const stage = plan ? "plan" : typeof report.stage === "string" ? report.stage : "unknown";
501
+ const semantic = plan
502
+ ? report.overall
503
+ : report.semantic;
504
+ return [
505
+ `# Stage ${stage}`,
506
+ "",
507
+ `- Verdict: ${String(plan ? semantic.verdict : report.verdict ?? "unknown")}`,
508
+ `- Result: ${plan ? semantic.summary ?? "" : semantic?.result ?? ""}`,
509
+ `- Next action: ${semantic?.next_action ?? ""}`,
510
+ "",
511
+ "## Generated artifacts",
512
+ "",
513
+ "The CLI generated the canonical JSON, Markdown, HTML and protocol-summary outputs.",
514
+ ""
515
+ ].join("\n");
516
+ }
517
+ function renderHtml(projectRoot, report) {
518
+ const plan = report.schema_id === "dd-flow/plan-stage-report@5";
519
+ const templatePath = path.join(projectRoot, plan
520
+ ? ".memory-bank/dd-flow/mb-sdlc/plan/stage-report-template.html"
521
+ : ".memory-bank/dd-flow/mb-sdlc/code/stage-report-template.html");
522
+ if (!fs.existsSync(templatePath))
523
+ throw new AppError("stage_report_template_missing", "Code stage report template is missing", 1, { path: templatePath });
524
+ const template = fs.readFileSync(templatePath, "utf8");
525
+ const canonicalMarker = "__STAGE_REPORT_DATA__";
526
+ const legacyMarker = "__CODE_DASHBOARD_DATA__";
527
+ if (!template.includes(canonicalMarker) && (!template.includes("script id=\"code-data\"") || !template.includes(legacyMarker))) {
528
+ throw new AppError("stage_report_template_invalid", "Code stage report template is missing required anchors", 1, { path: templatePath });
529
+ }
530
+ const embedded = JSON.stringify(report).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
531
+ return template.replace(canonicalMarker, embedded).replace(legacyMarker, embedded);
532
+ }
533
+ function updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, markdownPath) {
534
+ const protocolRoot = path.join(view.run.workspace_root, ".memory-bank", "protocol");
535
+ if (!fs.existsSync(protocolRoot))
536
+ return undefined;
537
+ const document = fs.readdirSync(protocolRoot).find((entry) => entry.startsWith(`${view.run.subject.id}-`) && entry.endsWith(".md"));
538
+ if (!document)
539
+ return undefined;
540
+ const summaryPath = path.join(protocolRoot, document.slice(0, -3), "summary.md");
541
+ if (!fs.existsSync(summaryPath))
542
+ return undefined;
543
+ const stage = typeof report.stage === "string" ? report.stage : "unknown";
544
+ const plan = report.schema_id === "dd-flow/plan-stage-report@5";
545
+ const effectiveStage = plan ? "plan" : stage;
546
+ const result = plan
547
+ ? report.overall.summary ?? ""
548
+ : report.semantic?.result ?? "";
549
+ const marker = `<!-- dd-flow:stage:${effectiveStage} -->`;
550
+ const block = [marker, `## Runtime stage: ${effectiveStage}`, "", `- Run: ${view.run.id}`, `- Verdict: ${String(plan ? report.overall.verdict : report.verdict ?? "unknown")}`, `- Result: ${result}`, `- JSON: ${dataPath}`, `- Markdown: ${markdownPath}`, `- HTML: ${htmlPath}`, "", marker].join("\n");
551
+ const current = fs.readFileSync(summaryPath, "utf8");
552
+ const next = current.includes(marker)
553
+ ? current.replace(new RegExp(`${escapeRegExp(marker)}[\\s\\S]*?${escapeRegExp(marker)}`, "m"), block)
554
+ : `${current.trimEnd()}\n\n${block}\n`;
555
+ atomicWrite(summaryPath, next);
556
+ return summaryPath;
557
+ }
558
+ function atomicWrite(file, value) {
559
+ fs.mkdirSync(path.dirname(file), { recursive: true });
560
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
561
+ fs.writeFileSync(tmp, typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`);
562
+ fs.renameSync(tmp, file);
563
+ }
564
+ function ensureWithin(root, candidate, label) {
565
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
566
+ if (relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)))
567
+ return;
568
+ throw new AppError("path_unsafe", `${label} must stay within its root`, 2, { root, candidate });
569
+ }
570
+ function escapeRegExp(value) {
571
+ return value.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
572
+ }