@deksden-com/dd-flow-cli 0.7.0 → 0.8.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 (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. package/package.json +12 -12
@@ -17,6 +17,7 @@ import { preflightMemoryPermissions } from "./memory-permissions.js";
17
17
  import { registerProject } from "./projects.js";
18
18
  import { registerProtocol } from "./protocols.js";
19
19
  import { previewNextEntityId } from "./ids.js";
20
+ import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
20
21
  const stagePromptSections = [
21
22
  "stage_identity",
22
23
  "authoritative_runtime_facts",
@@ -101,7 +102,17 @@ function runtimeFacts(view, projectRoot) {
101
102
  };
102
103
  }
103
104
  export function startStage(context, input) {
105
+ if (input.requireSessionBinding && !input.hookEventId) {
106
+ 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
+ }
104
108
  const projectRoot = resolveProjectRoot(input.projectRoot);
109
+ let requiredSessionId = null;
110
+ let requiredIdentity = null;
111
+ if (input.requireSessionBinding) {
112
+ registerProject(context, { root: projectRoot });
113
+ requiredIdentity = hookSessionIdentity(context, requireProjectByRoot(context, projectRoot).id, input.hookEventId);
114
+ requiredSessionId = requiredIdentity.sessionId;
115
+ }
105
116
  const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
106
117
  if (!runId)
107
118
  throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
@@ -125,11 +136,22 @@ export function startStage(context, input) {
125
136
  if (attached.run.subject.type === "protocol") {
126
137
  syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
127
138
  }
128
- if (input.sessionId) {
139
+ const sessionId = requiredSessionId ?? (input.hookEventId ? sessionIdForHookEvent(context, attached.run.project_id, input.hookEventId) : null);
140
+ if (sessionId) {
141
+ const identity = requiredIdentity ?? (input.hookEventId ? hookSessionIdentity(context, attached.run.project_id, input.hookEventId) : null);
129
142
  registerFlowSession(context, {
130
- sessionId: input.sessionId,
143
+ sessionId,
131
144
  payloadJson: JSON.stringify({
132
145
  project_root: projectRoot,
146
+ harness: identity?.harness ?? "codex-desktop",
147
+ provider_session_id: identity?.providerSessionId ?? sessionId,
148
+ agent_id: identity?.agentId ?? null,
149
+ provider: identity?.provider ?? null,
150
+ model: identity?.model ?? null,
151
+ reasoning: identity?.reasoning ?? null,
152
+ mode: identity?.mode ?? null,
153
+ agent_type: identity?.agentType ?? null,
154
+ parent_session_id: identity?.parentSessionId ?? null,
133
155
  flow_kind: flowKindForStage(input.stage),
134
156
  run_id: attached.run.id,
135
157
  protocol_id: attached.run.subject.id,
@@ -145,6 +167,10 @@ export function startStage(context, input) {
145
167
  const promptPath = path.join(stageRoot, "stage-prompt.md");
146
168
  const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
147
169
  atomicWrite(promptPath, prompt);
170
+ const semanticInputPath = path.join(stageRoot, "stage-input.json");
171
+ if (!fs.existsSync(semanticInputPath)) {
172
+ atomicWrite(semanticInputPath, semanticInputTemplate(input.stage));
173
+ }
148
174
  const promptDataPath = path.join(stageRoot, "stage-prompt.json");
149
175
  const planPath = planJsonPath(projectRoot, attached.run.subject.id);
150
176
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
@@ -165,6 +191,7 @@ export function startStage(context, input) {
165
191
  stage: stageRoot,
166
192
  protocol: attached.run.subject.id,
167
193
  intake: path.join(runHome, "intake"),
194
+ input: semanticInputPath,
168
195
  ...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
169
196
  },
170
197
  write_boundary: { current: "@stage", archive: attempt, archive_writable: false },
@@ -178,8 +205,8 @@ export function startStage(context, input) {
178
205
  },
179
206
  permissions: preflight,
180
207
  session_binding: {
181
- status: input.sessionId ? "bound" : "not_bound",
182
- session_id: input.sessionId ?? null
208
+ status: sessionId ? "bound" : "unavailable",
209
+ session_id: sessionId
183
210
  }
184
211
  },
185
212
  required_context: sources.map((source) => ({
@@ -189,7 +216,7 @@ export function startStage(context, input) {
189
216
  })),
190
217
  worker_prompt_markdown: prompt
191
218
  });
192
- validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot });
219
+ validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, ddFlowHome: context.ddFlowHome });
193
220
  atomicWrite(path.join(stageRoot, "stage-start.json"), {
194
221
  schema_id: "dd-flow/stage-start@2",
195
222
  run_id: attached.run.id,
@@ -208,6 +235,7 @@ export function startStage(context, input) {
208
235
  stage_root: stageRoot,
209
236
  archive_path: null,
210
237
  prompt_path: promptPath,
238
+ semantic_input_path: semanticInputPath,
211
239
  aliases: {
212
240
  project: projectRoot,
213
241
  workspace: attached.run.workspace_root,
@@ -243,50 +271,71 @@ export function finishStage(context, input) {
243
271
  ensureWithin(runHome, stageRoot, "stage root");
244
272
  fs.mkdirSync(stageRoot, { recursive: true });
245
273
  const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
246
- validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot });
247
- const semantic = readSemanticFile(semanticFile, runHome);
248
- const status = input.outcome ?? "done";
249
- if (!["done", "blocked", "failed"].includes(status)) {
250
- throw new AppError("validation", "Stage finish status must be done, blocked, or failed", 2, { status });
251
- }
252
- const coverage = workerCoverage(context, view.run.project_id, view.run.id);
253
- if (coverage.status !== "complete") {
254
- throw new AppError("worker_coverage_incomplete", "Stage finish requires complete worker coverage", 1, { coverage });
274
+ const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage);
275
+ 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
+ const coverage = workerCoverage(context, view.run.project_id, view.run.id);
284
+ if (coverage.jobs.missing?.length) {
285
+ throw new AppError("worker_jobs_incomplete", "Stage finish requires all declared worker jobs", 1, { coverage });
286
+ }
287
+ const lint = runTargetedMemoryBankLint(projectRoot);
288
+ const planFinish = input.stage === "plan"
289
+ ? preparePlanFinish(context, projectRoot, view, stageRoot)
290
+ : undefined;
291
+ const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
292
+ const dataPath = path.join(stageRoot, "stage-report.json");
293
+ const reportPath = path.join(stageRoot, "stage-report.md");
294
+ const htmlPath = path.join(stageRoot, "stage-report.html");
295
+ atomicWrite(dataPath, report);
296
+ validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, ddFlowHome: context.ddFlowHome });
297
+ atomicWrite(reportPath, renderMarkdown(report));
298
+ atomicWrite(htmlPath, renderHtml(projectRoot, report));
299
+ if (view.run.subject.type === "protocol") {
300
+ syncProtocolLifecycle(context, view.run.project_id, view.run.subject.id, input.stage, status, stringValue(semantic.next_action));
301
+ }
302
+ const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
303
+ const completed = completeFlowRunStage(context, {
304
+ projectRoot,
305
+ runId,
306
+ stage: input.stage,
307
+ status,
308
+ stageReport: htmlPath,
309
+ data: dataPath,
310
+ dataSchemaId: String(report.schema_id),
311
+ report: reportPath
312
+ });
313
+ finishReceipt(receipt, { outcome: "accepted", result: { status, report: dataPath } });
314
+ appendAudit(context, {
315
+ projectId: view.run.project_id,
316
+ ...(view.run.subject.type === "protocol" ? { protocolId: view.run.subject.id } : {}),
317
+ eventType: "stage.finish.accepted",
318
+ payload: { run_id: runId, stage: input.stage, status, receipt: receipt.path, semantic_sha256: receipt.semantic_sha256 }
319
+ });
320
+ return {
321
+ ok: true,
322
+ schema_id: "dd-flow/stage-finish@1",
323
+ ...(completed && typeof completed === "object" && !Array.isArray(completed) ? completed : {}),
324
+ artifacts: { json: dataPath, markdown: reportPath, html: htmlPath, finish_receipt: receipt.path, ...(summaryPath ? { protocol_summary: summaryPath } : {}) },
325
+ gates: { worker_coverage: coverage, targeted_lint: lint },
326
+ generated: true
327
+ };
255
328
  }
256
- const lint = runTargetedMemoryBankLint(projectRoot, semantic);
257
- const planFinish = input.stage === "plan"
258
- ? preparePlanFinish(context, projectRoot, view, stageRoot)
259
- : undefined;
260
- const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
261
- const dataPath = path.join(stageRoot, "stage-report.json");
262
- const reportPath = path.join(stageRoot, "stage-report.md");
263
- const htmlPath = path.join(stageRoot, "stage-report.html");
264
- atomicWrite(dataPath, report);
265
- validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot });
266
- atomicWrite(reportPath, renderMarkdown(report));
267
- atomicWrite(htmlPath, renderHtml(projectRoot, report));
268
- if (view.run.subject.type === "protocol") {
269
- syncProtocolLifecycle(context, view.run.project_id, view.run.subject.id, input.stage, status, stringValue(semantic.next_action));
329
+ catch (error) {
330
+ finishReceipt(receipt, { outcome: "rejected", error: finishError(error) });
331
+ appendAudit(context, {
332
+ projectId: view.run.project_id,
333
+ ...(view.run.subject.type === "protocol" ? { protocolId: view.run.subject.id } : {}),
334
+ eventType: "stage.finish.rejected",
335
+ payload: { run_id: runId, stage: input.stage, receipt: receipt.path, semantic_sha256: receipt.semantic_sha256, error: finishError(error) }
336
+ });
337
+ throw error;
270
338
  }
271
- const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
272
- const completed = completeFlowRunStage(context, {
273
- projectRoot,
274
- runId,
275
- stage: input.stage,
276
- status,
277
- stageReport: htmlPath,
278
- data: dataPath,
279
- dataSchemaId: String(report.schema_id),
280
- report: reportPath
281
- });
282
- return {
283
- ok: true,
284
- schema_id: "dd-flow/stage-finish@1",
285
- ...(completed && typeof completed === "object" && !Array.isArray(completed) ? completed : {}),
286
- artifacts: { json: dataPath, markdown: reportPath, html: htmlPath, ...(summaryPath ? { protocol_summary: summaryPath } : {}) },
287
- gates: { worker_coverage: coverage, targeted_lint: lint },
288
- generated: true
289
- };
290
339
  }
291
340
  function runView(value) {
292
341
  if (!value || typeof value !== "object")
@@ -302,12 +351,9 @@ function syncProtocolLifecycle(context, projectId, protocolId, stage, status, ne
302
351
  return;
303
352
  const protocol = requireProtocol(context, protocolId, projectId);
304
353
  const { state } = readProtocolRuntimeState(context, protocol);
305
- const currentRank = protocolStageRank(state.stage);
306
- const requestedRank = protocolStageRank(targetStage);
307
- const completedTarget = status === "done" ? nextStageAfterFinish(targetStage) : targetStage;
308
- const completedRank = protocolStageRank(completedTarget);
309
- const effectiveStage = completedRank >= currentRank ? completedTarget : state.stage;
310
- const effectiveStatus = status === "done" ? "running" : status;
354
+ const transition = lifecycleTransition(targetStage, status);
355
+ const effectiveStage = transition.stage;
356
+ const effectiveStatus = transition.status;
311
357
  const effectiveNextAction = nextAction ?? nextActionForProtocolStage(effectiveStage);
312
358
  if (effectiveStage === state.stage && effectiveStatus === state.status && effectiveNextAction === state.next_action)
313
359
  return;
@@ -328,7 +374,7 @@ function syncProtocolLifecycle(context, projectId, protocolId, stage, status, ne
328
374
  from: state.stage,
329
375
  to: effectiveStage,
330
376
  status: effectiveStatus,
331
- requested_rank: requestedRank
377
+ outcome: status
332
378
  }
333
379
  });
334
380
  }
@@ -341,11 +387,18 @@ function protocolStageForRunStage(stage) {
341
387
  return "implementation";
342
388
  return null;
343
389
  }
344
- function nextStageAfterFinish(stage) {
345
- return stage === "specify" ? "plan" : "implementation";
346
- }
347
- function protocolStageRank(stage) {
348
- return { registered: 0, specify: 1, plan: 2, implementation: 3 }[stage] ?? -1;
390
+ function lifecycleTransition(stage, status) {
391
+ if (status === "running")
392
+ return { stage, status };
393
+ if (status === "waiting_for_user" || status === "blocked" || status === "failed")
394
+ return { stage, status };
395
+ if (status !== "done")
396
+ throw new AppError("invalid_transition", `Unknown stage outcome: ${status}`, 2);
397
+ return stage === "specify"
398
+ ? { stage: "plan", status: "running" }
399
+ : stage === "plan"
400
+ ? { stage: "implementation", status: "running" }
401
+ : { stage: "readiness", status: "running" };
349
402
  }
350
403
  function nextActionForProtocolStage(stage) {
351
404
  if (stage === "registered" || stage === "specify")
@@ -377,6 +430,8 @@ export function defaultStageDir(stage, flowKind) {
377
430
  return known[normalized] ?? `03-${normalized.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
378
431
  }
379
432
  function dataSchemaForStage(stage) {
433
+ if (stage === "specify")
434
+ return "dd-flow/stage-finish-input@1";
380
435
  if (stage === "plan")
381
436
  return "dd-flow/plan-stage-report@5";
382
437
  if (stage === "code" || stage === "implementation")
@@ -396,7 +451,27 @@ function stageReportSchemaName(stage, planFinish) {
396
451
  }
397
452
  function stageFinishCommand(run, projectRoot, stage) {
398
453
  const compatibilityMode = run.flow_kind === "mb-upgrade" ? " --compatibility-mode mb-upgrade" : "";
399
- return `dd-flow stage finish ${run.id} --stage ${stage} --outcome done --project-root ${JSON.stringify(projectRoot)}${compatibilityMode} --json`;
454
+ return `dd-flow stage finish ${run.id} --stage ${stage} --project-root ${JSON.stringify(projectRoot)}${compatibilityMode} --json`;
455
+ }
456
+ function semanticInputTemplate(stage) {
457
+ const waiting = stage === "specify";
458
+ return {
459
+ schema_id: "dd-flow/stage-finish-input@1",
460
+ status: waiting ? "waiting_for_user" : "done",
461
+ result: "Replace this placeholder with the stage result.",
462
+ acceptance: ["Replace with the acceptance evidence for this stage."],
463
+ checks: ["Replace with a check actually performed."],
464
+ evidence: ["Replace with a durable evidence path or reference."],
465
+ ...(waiting ? {
466
+ questions: [{
467
+ id: "Q-001",
468
+ question: "Replace with an unresolved product decision.",
469
+ impact: "Replace with the consequence of leaving it unresolved.",
470
+ recommendation: "Replace with the smallest recommended decision."
471
+ }]
472
+ } : {}),
473
+ next_action: waiting ? "waiting_for_user: obtain the clarification packet" : "Continue to the next approved stage."
474
+ };
400
475
  }
401
476
  function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
402
477
  const project = requireProjectByRoot(context, projectRoot);
@@ -405,8 +480,6 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
405
480
  return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
406
481
  }).join("\n\n");
407
482
  const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
408
- const requiredContext = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage)
409
- .map(({ label }) => `- Read: \`${label}\``).join("\n");
410
483
  return [
411
484
  "<stage_identity>",
412
485
  "# Stage work packet",
@@ -443,7 +516,7 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
443
516
  "</applicable_instructions>",
444
517
  "",
445
518
  "<required_context>",
446
- requiredContext || "- No additional stage-specific source is required.",
519
+ "The bounded canonical instructions above are already included in this packet. Do not reopen them, prime.md, indexes, CLI help, Git status, or runtime state unless new semantic evidence makes a specific source necessary.",
447
520
  "</required_context>",
448
521
  "",
449
522
  "<work_contract>",
@@ -453,7 +526,9 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
453
526
  "",
454
527
  "<completion_contract>",
455
528
  `Write semantic output to \`@stage/stage-input.json\`, then run: \`${stageFinishCommand(current.run, projectRoot, stage)}\`.`,
529
+ "`@stage/stage-input.json` already contains the only valid field layout. Replace every placeholder; do not add mechanical fields such as changed_files. Set `status` to `done`, `waiting_for_user`, `blocked`, or `failed`. For `waiting_for_user`, preserve each question as a structured item in `questions` with id, question, impact, and recommended answer when known. For SPECIFY, `done` means there are no unresolved structured questions.",
456
530
  "The CLI must generate validated JSON, Markdown, HTML and protocol-summary evidence.",
531
+ "If finish rejects a required semantic status or a successful receipt disagrees with it, stop immediately with `flow_contract_conflict`: do not rewrite the result to another permitted status, retry finish, repair RUN state, or start a downstream stage.",
457
532
  "</completion_contract>",
458
533
  ].join("\n");
459
534
  }
@@ -478,7 +553,9 @@ function stageInstructionSources(context, projectRoot, flowKind, stage) {
478
553
  .map((file) => ({ label: `canonical:${file}`, absolute: path.join(flowRoot, file) }));
479
554
  }
480
555
  const filesByStage = {
481
- specify: [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"],
556
+ specify: [
557
+ ".memory-bank/dd-flow/mb-sdlc/specify/packet.md"
558
+ ],
482
559
  plan: [".memory-bank/dd-flow/plan.md"],
483
560
  code: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
484
561
  implementation: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
@@ -507,6 +584,49 @@ function readSemanticFile(file, runHome) {
507
584
  throw new AppError("validation", "Semantic input must be a JSON object", 2);
508
585
  return value;
509
586
  }
587
+ function validateStageOutcome(stage, status, semantic) {
588
+ if (stage !== "specify")
589
+ return;
590
+ const questions = Array.isArray(semantic.questions) ? semantic.questions : [];
591
+ if (status === "waiting_for_user" && questions.length === 0) {
592
+ throw new AppError("semantic_status_inconsistent", "SPECIFY waiting_for_user requires at least one structured question", 2);
593
+ }
594
+ if (status === "done" && questions.length > 0) {
595
+ throw new AppError("semantic_status_inconsistent", "SPECIFY done cannot contain unresolved structured questions", 2);
596
+ }
597
+ }
598
+ function beginFinishReceipt(context, stageRoot, semanticFile, runId, stage) {
599
+ const receiptDir = path.join(stageRoot, "finish-receipts");
600
+ fs.mkdirSync(receiptDir, { recursive: true });
601
+ const number = fs.readdirSync(receiptDir).filter((file) => /^\d{3}\.json$/u.test(file)).length + 1;
602
+ 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");
609
+ atomicWrite(receiptPath, {
610
+ schema_id: "dd-flow/stage-finish-receipt@1",
611
+ run_id: runId,
612
+ stage,
613
+ attempted_at: context.now(),
614
+ semantic_file: semanticFile,
615
+ semantic_sha256: semanticSha,
616
+ semantic_input_raw: raw,
617
+ outcome: "pending"
618
+ });
619
+ return { path: receiptPath, semantic_sha256: semanticSha };
620
+ }
621
+ function finishReceipt(receipt, outcome) {
622
+ const current = JSON.parse(fs.readFileSync(receipt.path, "utf8"));
623
+ atomicWrite(receipt.path, { ...current, ...outcome, finished_at: new Date().toISOString() });
624
+ }
625
+ function finishError(error) {
626
+ if (error instanceof AppError)
627
+ return { code: error.code, message: error.message, details: error.details };
628
+ return { code: "unexpected", message: String(error) };
629
+ }
510
630
  function resolveStageFile(file, stageRoot, runHome) {
511
631
  const candidate = file === "@stage" || file.startsWith("@stage/")
512
632
  ? path.join(stageRoot, file.slice("@stage".length).replace(/^[/\\]/u, ""))
@@ -516,7 +636,7 @@ function resolveStageFile(file, stageRoot, runHome) {
516
636
  }
517
637
  function buildStageReport(context, view, stage, status, semantic, stageRoot, lint, planFinish) {
518
638
  const result = stringValue(semantic.result) ?? `Stage ${stage} finished with status ${status}.`;
519
- const changedFiles = stringArray(semantic.changed_files, gitChangedFiles(view.run.workspace_root));
639
+ const changedFiles = gitChangedFiles(view.run.workspace_root);
520
640
  const checks = stringArray(semantic.checks, []);
521
641
  const evidence = stringArray(semantic.evidence, []);
522
642
  const acceptance = stringArray(semantic.acceptance, []);
@@ -608,11 +728,12 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
608
728
  };
609
729
  }
610
730
  return {
611
- schema_id: "dd-flow/stage-report@1",
731
+ schema_id: "dd-flow/stage-report@2",
612
732
  run_id: view.run.id,
613
733
  stage,
614
734
  generated_at: finishedAt,
615
735
  verdict: status,
736
+ summary: result,
616
737
  semantic: {
617
738
  result,
618
739
  acceptance,
@@ -620,6 +741,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
620
741
  checks,
621
742
  evidence,
622
743
  next_action: stringValue(semantic.next_action) ?? "Resolve the stage result before continuing.",
744
+ ...(Array.isArray(semantic.questions) ? { questions: semantic.questions } : {}),
623
745
  ...(Array.isArray(semantic.reviewer_findings) ? { reviewer_findings: semantic.reviewer_findings } : {}),
624
746
  ...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {})
625
747
  },
@@ -634,8 +756,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
634
756
  artifacts: {
635
757
  json: path.join(stageRoot, "stage-report.json"),
636
758
  markdown: path.join(stageRoot, "stage-report.md"),
637
- html: path.join(stageRoot, "stage-report.html"),
638
- summary: path.join(stageRoot, "stage-report.md")
759
+ html: path.join(stageRoot, "stage-report.html")
639
760
  },
640
761
  validation: {
641
762
  permission_scope: "known_targets_only",
@@ -706,7 +827,7 @@ function gitChangedFiles(workspaceRoot) {
706
827
  return result.stdout.split(/\r?\n/).filter(Boolean).map((line) => line.slice(3).trim()).filter(Boolean);
707
828
  }
708
829
  function workerCoverage(context, projectId, runId) {
709
- const sessions = context.db.all("SELECT * FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id", [projectId, runId]);
830
+ const sessions = context.db.all("SELECT * FROM sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id", [projectId, runId]);
710
831
  const report = reconcileSessionCoverage(sessions);
711
832
  const jobs = context.db.all("SELECT job_id, status FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY job_id", [projectId, runId]);
712
833
  const missingJobs = jobs.filter((job) => job.status !== "done").map((job) => job.job_id);
@@ -720,9 +841,8 @@ function workerCoverage(context, projectId, runId) {
720
841
  jobs: { expected: jobs.map((job) => job.job_id), missing: missingJobs }
721
842
  };
722
843
  }
723
- function runTargetedMemoryBankLint(projectRoot, semantic) {
724
- const declared = stringArray(semantic.changed_files, []);
725
- const files = [...new Set(declared.filter((file) => file.startsWith(".memory-bank/") && file.toLowerCase().endsWith(".md") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
844
+ export function runTargetedMemoryBankLint(projectRoot, selectedFiles) {
845
+ const files = [...new Set((selectedFiles ?? gitChangedFiles(projectRoot)).filter((file) => file.startsWith(".memory-bank/") && file.toLowerCase().endsWith(".md") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
726
846
  for (const file of files)
727
847
  ensureWithin(projectRoot, path.resolve(projectRoot, file), "lint target");
728
848
  if (files.length === 0) {
@@ -769,16 +889,19 @@ function renderMarkdown(report) {
769
889
  }
770
890
  function renderHtml(projectRoot, report) {
771
891
  const plan = report.schema_id === "dd-flow/plan-stage-report@5";
892
+ const specify = report.schema_id === "dd-flow/stage-report@2" && report.stage === "specify";
772
893
  const templatePath = path.join(projectRoot, plan
773
894
  ? ".memory-bank/dd-flow/mb-sdlc/plan/stage-report-template.html"
774
- : ".memory-bank/dd-flow/mb-sdlc/code/stage-report-template.html");
895
+ : specify
896
+ ? ".memory-bank/dd-flow/mb-sdlc/specify/stage-report-template.html"
897
+ : ".memory-bank/dd-flow/mb-sdlc/code/stage-report-template.html");
775
898
  if (!fs.existsSync(templatePath))
776
- throw new AppError("stage_report_template_missing", "Code stage report template is missing", 1, { path: templatePath });
899
+ throw new AppError("stage_report_template_missing", "Stage report template is missing", 1, { path: templatePath });
777
900
  const template = fs.readFileSync(templatePath, "utf8");
778
901
  const canonicalMarker = "__STAGE_REPORT_DATA__";
779
902
  const legacyMarker = "__CODE_DASHBOARD_DATA__";
780
903
  if (!template.includes(canonicalMarker) && (!template.includes("script id=\"code-data\"") || !template.includes(legacyMarker))) {
781
- throw new AppError("stage_report_template_invalid", "Code stage report template is missing required anchors", 1, { path: templatePath });
904
+ throw new AppError("stage_report_template_invalid", "Stage report template is missing required anchors", 1, { path: templatePath });
782
905
  }
783
906
  const embedded = JSON.stringify(report).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
784
907
  return template.replace(canonicalMarker, embedded).replace(legacyMarker, embedded);
@@ -0,0 +1,175 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { resolveProjectRoot } from "../storage/paths.js";
7
+ import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
8
+ import { requireProjectByRoot } from "./projects.js";
9
+ import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
10
+ import { refreshRunWorkProjection } from "./work-registry.js";
11
+ export function pauseStageForUser(context, input) {
12
+ const projectRoot = resolveProjectRoot(input.projectRoot);
13
+ const project = requireProjectByRoot(context, projectRoot);
14
+ const run = requireRun(context, project.id, input.runId);
15
+ const work = requireWork(context, project.id, run.id, input.workId);
16
+ if (work.status !== "running")
17
+ throw new AppError("invalid_work_state", "Only a running Work can pause", 1, { work_id: work.work_id, status: work.status });
18
+ const question = input.question;
19
+ if (!question.trim())
20
+ throw new AppError("validation", "stage pause requires a non-empty user question", 2);
21
+ const stage = requireStage(run, input.stage, "running");
22
+ const pauseId = nextPauseId(requireRunHome(run));
23
+ const pauseRoot = path.join(requireRunHome(run), "intake", "hitl", `${pauseId}-${safeStage(input.stage)}`);
24
+ const questionPath = path.join(pauseRoot, "question.md");
25
+ fs.mkdirSync(pauseRoot, { recursive: true });
26
+ fs.writeFileSync(questionPath, question);
27
+ const now = context.now();
28
+ context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
29
+ pauseFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, pauseId, questionPath });
30
+ refreshRunWorkProjection(context, project.id, run.id);
31
+ const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot });
32
+ const resumeTemplate = `${resume} <<'USER_ANSWER'\n<paste the complete user answer exactly>\nUSER_ANSWER`;
33
+ return {
34
+ ok: true,
35
+ outcome: "paused",
36
+ run_id: run.id,
37
+ work_id: work.work_id,
38
+ stage: stage.stage,
39
+ pause: { id: pauseId, reason: "waiting_for_user", question_path: questionPath, user_message: question },
40
+ next_action: "ask_user_then_resume_same_stage",
41
+ agent_instruction: "Send user_message to the user and stop this Turn. When the user answers, do not interpret or edit the answer first: make resume_command the first flow command, pass the complete raw answer on stdin, then follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.",
42
+ resume_command: resume,
43
+ resume_command_template: resumeTemplate
44
+ };
45
+ }
46
+ export function resumeStageAfterUser(context, input) {
47
+ const projectRoot = resolveProjectRoot(input.projectRoot);
48
+ const project = requireProjectByRoot(context, projectRoot);
49
+ const run = requireRun(context, project.id, input.runId);
50
+ const work = requireWork(context, project.id, run.id, input.workId);
51
+ if (work.status !== "paused")
52
+ throw new AppError("invalid_work_state", "stage resume requires its paused Work", 1, { work_id: work.work_id, status: work.status });
53
+ const stage = requireStage(run, input.stage, "paused");
54
+ if (!stage.pause || stage.pause.work_id !== work.work_id || stage.pause.answer_path)
55
+ 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 });
56
+ if (!input.answer.trim())
57
+ throw new AppError("validation", "stage resume requires a non-empty user answer", 2);
58
+ const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
59
+ projectId: project.id,
60
+ matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
61
+ errorCode: "trusted_stage_resume_required",
62
+ operation: "stage resume"
63
+ }).eventKey;
64
+ const identity = claimStageResumeHookEvent(context, {
65
+ projectId: project.id,
66
+ runId: run.id,
67
+ stage: stage.stage,
68
+ workId: work.work_id,
69
+ projectRoot,
70
+ eventKey: hookEventId
71
+ });
72
+ const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
73
+ fs.writeFileSync(answerPath, input.answer);
74
+ const now = context.now();
75
+ 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]);
76
+ if (!workSession)
77
+ throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
78
+ if (workSession.session_id !== identity.sessionId) {
79
+ context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
80
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [`WSES-${crypto.randomUUID()}`, work.work_id, identity.sessionId, identity.hookEventId, workSession.prompt_path, workSession.result_path, now, now]);
81
+ }
82
+ context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
83
+ resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
84
+ refreshRunWorkProjection(context, project.id, run.id);
85
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
86
+ const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
87
+ const continuation = [
88
+ prompt.trimEnd(),
89
+ "",
90
+ "<hitl_resume>",
91
+ `- Pause: ${stage.pause.id}`,
92
+ `- Question source: ${stage.pause.question_path}`,
93
+ `- Answer source: ${answerPath}`,
94
+ "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
95
+ "",
96
+ "<user_question>",
97
+ fs.readFileSync(stage.pause.question_path, "utf8"),
98
+ "</user_question>",
99
+ "",
100
+ "<user_answer>",
101
+ input.answer,
102
+ "</user_answer>",
103
+ "</hitl_resume>",
104
+ ""
105
+ ].join("\n");
106
+ 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}` };
107
+ }
108
+ export function stagePauseCommand(context, input) {
109
+ return `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
110
+ }
111
+ /**
112
+ * The only shell form allowed for an agent-owned HITL pause. Supplying a
113
+ * complete heredoc makes stdin explicit without asking an agent to invent a
114
+ * pipe, a temporary file or a second command.
115
+ */
116
+ export function stagePauseCommandTemplate(command) {
117
+ return `${command} <<'USER_QUESTION'
118
+ ## Q-001 — <short decision title>
119
+
120
+ Why this decision is required:
121
+ - <one sentence>
122
+
123
+ Options:
124
+ - <option A>
125
+ - <option B>
126
+
127
+ Recommendation:
128
+ - <one option and reason>
129
+
130
+ Effect on scope or acceptance:
131
+ - <one sentence>
132
+ USER_QUESTION`;
133
+ }
134
+ function stageResumeCommand(context, input) {
135
+ return `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
136
+ }
137
+ export function flowCommand(context) {
138
+ const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
139
+ return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
140
+ }
141
+ function requireRun(context, projectId, id) {
142
+ const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
143
+ if (rows.length !== 1)
144
+ throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: id });
145
+ return rows[0];
146
+ }
147
+ function requireWork(context, projectId, runId, workId) {
148
+ const work = context.db.get("SELECT work_id, project_id, run_id, status FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [projectId, runId, workId]);
149
+ if (!work)
150
+ throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
151
+ return work;
152
+ }
153
+ function requireStage(run, stage, status) {
154
+ const index = JSON.parse(run.index_json);
155
+ const found = index.stage_runs?.find((item) => item.stage === stage);
156
+ if (!found || found.status !== status)
157
+ throw new AppError("invalid_stage_state", `Stage must be ${status}`, 1, { run_id: run.id, stage, status: found?.status ?? "missing" });
158
+ return found;
159
+ }
160
+ function requireRunHome(run) {
161
+ if (!run.run_home_path)
162
+ throw new AppError("runtime_missing", "RUN has no artifact home", 1, { run_id: run.id });
163
+ return run.run_home_path;
164
+ }
165
+ function nextPauseId(runHome) {
166
+ const root = path.join(runHome, "intake", "hitl");
167
+ const max = fs.existsSync(root) ? fs.readdirSync(root).reduce((value, name) => Math.max(value, Number(name.match(/^HITL-(\d+)/)?.[1] ?? 0)), 0) : 0;
168
+ return `HITL-${String(max + 1).padStart(3, "0")}`;
169
+ }
170
+ function safeStage(stage) {
171
+ const value = stage.trim().toLowerCase();
172
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value))
173
+ throw new AppError("validation", "stage name is invalid", 2, { stage });
174
+ return value;
175
+ }