@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
@@ -0,0 +1,595 @@
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 { AppError } from "../shared/errors.js";
6
+ import { applyExternalStageContext } from "./stage-context.js";
7
+ import { ensureVnextWorkStorage } from "../storage/database.js";
8
+ import { resolveProjectRoot, writeJsonAtomic } from "../storage/paths.js";
9
+ import { preflightMemoryPermissions } from "./memory-permissions.js";
10
+ import { registerProject, requireProjectByRoot } from "./projects.js";
11
+ import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
12
+ import { validateSchema } from "./schema-validation.js";
13
+ import { bindRunningWorkSession, refreshRunWorkProjection } from "./work-registry.js";
14
+ import { nextWorkId } from "./ids.js";
15
+ import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
16
+ import { writeStageReport } from "./stage-report-renderer.js";
17
+ const flowId = "mb-sdlc-vnext-specify";
18
+ const protocolizeFlowId = "mb-sdlc-vnext-protocolize";
19
+ const flowVersion = 4;
20
+ const stageId = "specify";
21
+ const entryId = "default";
22
+ export function isVnextSpecifyFlow(projectRoot) {
23
+ return Boolean(readVnextFlowDefinition(projectRoot));
24
+ }
25
+ export function isVnextSpecifyRun(context, input) {
26
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
27
+ return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind IN ('vnext_specify', 'vnext_protocolize')", [project.id, input.runId]));
28
+ }
29
+ export function launchVnextSpecify(context, input) {
30
+ ensureVnextWorkStorage(context.db);
31
+ const projectRoot = resolveProjectRoot(input.projectRoot);
32
+ registerProject(context, { root: projectRoot });
33
+ const intake = readIntake(input);
34
+ const discussion = intake.markdown.trim();
35
+ if (!discussion)
36
+ throw new AppError("validation", "SPECIFY intake must not be empty", 2);
37
+ const flow = assertFlowDefinition(projectRoot);
38
+ const template = readRequiredFile(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "specify.md"), "vNext SPECIFY prompt template");
39
+ const started = input.runId ? null : startFlowRun(context, {
40
+ projectRoot,
41
+ flowKind: flow.id === protocolizeFlowId ? "vnext_protocolize" : "vnext_specify",
42
+ subjectType: "discussion",
43
+ subjectId: "SPECIFY",
44
+ slug: input.slug ?? "specify",
45
+ nextAction: "await_work_session"
46
+ });
47
+ const run = requireRun(context, projectRoot, input.runId ?? started.run.id);
48
+ const existingStages = getFlowRunStatus(context, { projectRoot, runId: run.id });
49
+ if (existingStages.index?.stage_runs?.some((entry) => entry.stage === stageId))
50
+ throw new AppError("stage_already_started", "SPECIFY is already started for this RUN", 1, { run_id: run.id });
51
+ const runHome = requiredRunHome(run);
52
+ const stageRoot = path.join(runHome, "01-specify");
53
+ const intakeDir = path.join(runHome, "intake");
54
+ fs.mkdirSync(stageRoot, { recursive: true });
55
+ fs.mkdirSync(intakeDir, { recursive: true });
56
+ const discussionPath = path.join(intakeDir, "discussion.md");
57
+ writeText(discussionPath, intake.markdown);
58
+ const promptPath = path.join(stageRoot, "prompt.md");
59
+ const resultPath = path.join(stageRoot, "specify.json");
60
+ const contextPath = path.join(stageRoot, "work-context.json");
61
+ const workId = nextWorkId(context, run.project_id, "root");
62
+ const now = context.now();
63
+ const preflight = writablePreflight(projectRoot, stageRoot);
64
+ if (preflight.ok !== true)
65
+ throw new AppError("permission_preflight_failed", "SPECIFY workspace is not writable", 1, { preflight });
66
+ const grounding = projectGrounding(projectRoot);
67
+ const handoff = executionSnapshot(context, run.id);
68
+ const workContext = {
69
+ schema_id: "dd-flow/work-context@1",
70
+ system: { run_id: run.id, work_id: workId, stage: stageId },
71
+ execution: handoff,
72
+ workspace: { project_root: projectRoot, workspace_root: run.workspace_root, git: gitFacts(run.workspace_root) },
73
+ input: { discussion_path: discussionPath, discussion },
74
+ runtime: {
75
+ compatibility: { status: "checked", operation: "flow.launch" },
76
+ permissions: preflight,
77
+ session_binding: { status: "bound", source: "PreToolUse" }
78
+ },
79
+ artifacts: { stage_root: stageRoot, prompt_path: promptPath, result_path: resultPath },
80
+ grounding: grounding.map(({ source }) => source)
81
+ };
82
+ writeJson(contextPath, workContext);
83
+ const pauseCommand = stagePauseCommand(context, { runId: run.id, stage: stageId, workId, projectRoot });
84
+ const prompt = renderPrompt({ workId, runId: run.id, projectRoot, stageRoot, resultPath, discussion, template, workContext, grounding, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), flow: flowCommand(context) });
85
+ fs.writeFileSync(promptPath, prompt);
86
+ const externalContext = applyExternalStageContext({ stageRoot, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
87
+ context.db.run(`INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, depends_on_json, status, result, started_at, created_at, updated_at, completed_at)
88
+ VALUES (?, ?, ?, NULL, ?, ?, NULL, '[]', 'running', NULL, ?, ?, ?, NULL)`, [workId, run.project_id, run.id, "Complete the requested MB-SDLC flow through its configured stop target.", "reuse_allowed", now, now, now]);
89
+ const binding = bindRunningWorkSession(context, { workId, hookEventId: input.hookEventId, promptPath, resultPath });
90
+ const workSessionId = String(binding.work_session_id);
91
+ attachFlowRunStage(context, {
92
+ projectRoot,
93
+ runId: run.id,
94
+ stage: stageId,
95
+ dir: "01-specify",
96
+ status: "running",
97
+ dataSchemaId: "dd-flow/specify@1"
98
+ });
99
+ refreshRunWorkProjection(context, run.project_id, run.id);
100
+ appendFlowRunTimelineEvent(context, run.project_id, run.id, {
101
+ type: "work_waiting_for_agent",
102
+ work_id: workId,
103
+ work_session_id: workSessionId,
104
+ stage: stageId,
105
+ action_index: 1
106
+ });
107
+ return {
108
+ ok: true,
109
+ schema_id: "dd-flow/vnext-work-launch@1",
110
+ outcome: "work_session_required",
111
+ run_id: run.id,
112
+ work_id: workId,
113
+ work_session_id: workSessionId,
114
+ prompt_path: promptPath,
115
+ result_path: resultPath,
116
+ work_context_path: contextPath,
117
+ worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"),
118
+ ...(externalContext ? { external_context: externalContext } : {})
119
+ };
120
+ }
121
+ export function submitVnextSpecify(context, input) {
122
+ const projectRoot = resolveProjectRoot(input.projectRoot);
123
+ const project = requireProjectByRoot(context, projectRoot);
124
+ const work = requireWork(context, input.workId);
125
+ if (work.project_id !== project.id)
126
+ throw new AppError("project_mismatch", "Work does not belong to --project-root", 1, { work_id: work.work_id });
127
+ if (work.status !== "running") {
128
+ throw new AppError("invalid_work_state", "Work is not waiting for a SPECIFY result", 1, { work_id: work.work_id, status: work.status });
129
+ }
130
+ const run = requireRun(context, projectRoot, work.run_id);
131
+ if (runStatus(context, project.id, run.id) !== "running") {
132
+ throw new AppError("run_terminal", "SPECIFY result cannot finish after its RUN has reached a terminal state", 1, { run_id: run.id });
133
+ }
134
+ const runHome = requiredRunHome(run);
135
+ const stageRoot = path.join(runHome, "01-specify");
136
+ const candidateFile = path.resolve(input.resultFile);
137
+ const resultFile = path.join(stageRoot, "specify.json");
138
+ requireInside(stageRoot, candidateFile, "--result-file");
139
+ if (!fs.existsSync(candidateFile) || !fs.statSync(candidateFile).isFile()) {
140
+ throw new AppError("not_found", "--result-file must point to an existing file inside the SPECIFY workspace", 1, { result_file: input.resultFile });
141
+ }
142
+ try {
143
+ validateSchema({ schemaName: "vnext-specify", file: candidateFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
144
+ const result = readVnextSpecifyResult(candidateFile);
145
+ validateObligations(result, candidateFile);
146
+ const normalizedResult = `${JSON.stringify(result, null, 2)}\n`;
147
+ const renderedMarkdown = renderSpecifyMarkdown(result);
148
+ writeJsonAtomic(resultFile, result);
149
+ if (candidateFile !== resultFile)
150
+ fs.rmSync(candidateFile);
151
+ writeText(path.join(stageRoot, "specify.md"), renderedMarkdown);
152
+ const outcome = input.outcome;
153
+ const now = context.now();
154
+ const continuesToProtocolize = outcome === "specified" && flowKind(context, project.id, run.id) === "vnext_protocolize";
155
+ const handoff = continuesToProtocolize ? executionSnapshotFromPath(path.join(stageRoot, "work-context.json"), work.work_id) : null;
156
+ const sameSessionContinuation = handoff?.stage_handoff.effective === "same_session";
157
+ const terminal = !continuesToProtocolize && (outcome === "specified" || outcome === "failed" || outcome === "cancelled");
158
+ if (terminal) {
159
+ context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [outcome === "specified" ? "completed" : outcome, normalizedResult, now, now, work.work_id]);
160
+ context.db.run("UPDATE work_sessions SET status = ?, result_path = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [outcome === "specified" ? "completed" : outcome, resultFile, now, now, work.work_id]);
161
+ }
162
+ const workSession = latestWorkSession(context, work.work_id);
163
+ if (workSession && !sameSessionContinuation) {
164
+ context.db.run("UPDATE work_sessions SET status = 'completed', result_path = ?, updated_at = ?, completed_at = ? WHERE id = ?", [resultFile, now, now, workSession.id]);
165
+ }
166
+ const reportPath = path.join(stageRoot, "stage-report.json");
167
+ const markdownPath = path.join(stageRoot, "stage-report.md");
168
+ const htmlPath = path.join(stageRoot, "stage-report.html");
169
+ const report = buildStageReport({ run, work, workSession, outcome, result, resultMarkdown: renderedMarkdown, now, stageRoot, resultFile });
170
+ writeStageReport(stageRoot, report);
171
+ validateSchema({ schemaName: "stage-report", file: reportPath, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
172
+ completeFlowRunStage(context, {
173
+ projectRoot,
174
+ runId: run.id,
175
+ stage: stageId,
176
+ status: outcome === "specified" ? "done" : outcome === "cancelled" ? "skipped" : "failed",
177
+ data: "specify.json",
178
+ dataSchemaId: "dd-flow/specify@1",
179
+ report: "stage-report.md",
180
+ stageReport: "stage-report.html"
181
+ });
182
+ const updatedWork = requireWork(context, work.work_id);
183
+ const updatedWorkSession = workSession ? requireWorkSession(context, workSession.id) : null;
184
+ refreshRunWorkProjection(context, run.project_id, run.id);
185
+ if (!continuesToProtocolize)
186
+ appendFlowRunTimelineEvent(context, run.project_id, run.id, { type: "work_completed", work_id: work.work_id, outcome, stage: stageId });
187
+ if (continuesToProtocolize) {
188
+ const nextWork = requireWork(context, work.work_id);
189
+ advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "specified", nextAction: "start_protocolize" });
190
+ refreshRunWorkProjection(context, run.project_id, run.id);
191
+ const nextCommand = `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json`;
192
+ return {
193
+ ok: true,
194
+ schema_id: "dd-flow/vnext-work-submit@1",
195
+ outcome,
196
+ run_id: run.id,
197
+ work: workProjection(nextWork, latestWorkSession(context, work.work_id)),
198
+ next: { kind: "start_stage", stage: "protocolize", command: nextCommand, handoff_mode: handoff.stage_handoff.effective },
199
+ artifacts: { result_json: resultFile, result_markdown: path.join(stageRoot, "specify.md"), report_json: reportPath, report_markdown: markdownPath, report_html: htmlPath }
200
+ };
201
+ }
202
+ completeFlowRun(context, {
203
+ projectRoot,
204
+ runId: run.id,
205
+ status: outcome === "specified" ? "done" : outcome,
206
+ verdict: outcome,
207
+ nextAction: outcome === "specified" ? "start_protocolize" : "none"
208
+ });
209
+ return {
210
+ ok: true,
211
+ schema_id: "dd-flow/vnext-work-submit@1",
212
+ outcome,
213
+ run_id: run.id,
214
+ work: workProjection(updatedWork, updatedWorkSession),
215
+ artifacts: { result_json: resultFile, result_markdown: path.join(stageRoot, "specify.md"), report_json: reportPath, report_markdown: markdownPath, report_html: htmlPath }
216
+ };
217
+ }
218
+ catch (error) {
219
+ refreshRunWorkProjection(context, run.project_id, run.id);
220
+ appendFlowRunTimelineEvent(context, run.project_id, run.id, { type: "work_result_rejected", work_id: work.work_id, stage: stageId });
221
+ throw error;
222
+ }
223
+ }
224
+ export function getVnextWorkStatus(context, input) {
225
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
226
+ const work = context.db.get("SELECT * FROM works WHERE work_id = ?", [input.workId]);
227
+ if (!work)
228
+ throw new AppError("not_found", "Work is not registered", 1, { work_id: input.workId });
229
+ if (work.project_id !== project.id)
230
+ throw new AppError("project_mismatch", "Work does not belong to --project-root", 1, { work_id: work.work_id });
231
+ const workSession = context.db.get("SELECT id, session_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.work_id]);
232
+ return { ok: true, work: { ...work, ...(workSession ? { work_session: workSession } : {}) } };
233
+ }
234
+ export function finishVnextSpecifyStage(context, input) {
235
+ const projectRoot = resolveProjectRoot(input.projectRoot);
236
+ const project = requireProjectByRoot(context, projectRoot);
237
+ const work = context.db.get("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [project.id, input.runId]);
238
+ if (!work)
239
+ throw new AppError("not_found", "RUN has no vNext SPECIFY Work", 1, { run_id: input.runId, stage: stageId });
240
+ const run = requireRun(context, projectRoot, input.runId);
241
+ const stageRoot = path.join(requiredRunHome(run), "01-specify");
242
+ const acceptedResult = path.join(stageRoot, "specify.json");
243
+ const suppliedResult = input.resultJson ?? readRequiredFile(path.resolve(input.resultFile ?? acceptedResult), "SPECIFY result");
244
+ const candidate = path.join(stageRoot, `.specify-candidate-${crypto.randomUUID()}.json`);
245
+ writeText(candidate, suppliedResult);
246
+ // A previous worker contract allowed the final projection path as input.
247
+ // Do not leave that unvalidated payload behind as an accepted-looking result.
248
+ if (input.resultFile && path.resolve(input.resultFile) === acceptedResult)
249
+ fs.rmSync(acceptedResult, { force: true });
250
+ try {
251
+ return submitVnextSpecify(context, { projectRoot, workId: work.work_id, resultFile: candidate, outcome: input.outcome });
252
+ }
253
+ finally {
254
+ fs.rmSync(candidate, { force: true });
255
+ }
256
+ }
257
+ function readIntake(input) {
258
+ if (input.intakeMarkdown !== undefined)
259
+ return { markdown: input.intakeMarkdown };
260
+ if (!input.intakeFile)
261
+ throw new AppError("usage", "SPECIFY intake is required", 2);
262
+ const intakeFile = path.resolve(input.intakeFile);
263
+ if (!fs.existsSync(intakeFile) || !fs.statSync(intakeFile).isFile()) {
264
+ throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: input.intakeFile });
265
+ }
266
+ return { markdown: fs.readFileSync(intakeFile, "utf8") };
267
+ }
268
+ function requireRun(context, projectRoot, runId) {
269
+ const project = requireProjectByRoot(context, projectRoot);
270
+ const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_home_path FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]);
271
+ if (!run)
272
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
273
+ return run;
274
+ }
275
+ function runStatus(context, projectId, runId) {
276
+ const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
277
+ if (!run)
278
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
279
+ return run.status;
280
+ }
281
+ function requiredRunHome(run) {
282
+ if (!run.run_home_path)
283
+ throw new AppError("runtime_missing", "RUN has no portable workspace", 1, { run_id: run.id });
284
+ return run.run_home_path;
285
+ }
286
+ function requireWork(context, workId) {
287
+ const work = context.db.get("SELECT * FROM works WHERE work_id = ?", [workId]);
288
+ if (!work)
289
+ throw new AppError("not_found", "Work is not registered", 1, { work_id: workId });
290
+ return work;
291
+ }
292
+ function requireWorkSession(context, workSessionId) {
293
+ const workSession = context.db.get("SELECT * FROM work_sessions WHERE id = ?", [workSessionId]);
294
+ if (!workSession)
295
+ throw new AppError("not_found", "Agent WorkSession is not registered", 1, { id: workSessionId });
296
+ return workSession;
297
+ }
298
+ function latestWorkSession(context, workId) {
299
+ return context.db.get("SELECT * FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC, id DESC LIMIT 1", [workId]) ?? null;
300
+ }
301
+ function writablePreflight(projectRoot, stageRoot) {
302
+ const memoryBank = preflightMemoryPermissions({
303
+ root: projectRoot,
304
+ memoryBank: ".memory-bank",
305
+ flow: "custom",
306
+ mode: "write",
307
+ targets: [{ path: ".memory-bank", mode: "read" }]
308
+ });
309
+ const probe = path.join(stageRoot, `.dd-flow-probe-${crypto.randomUUID()}`);
310
+ try {
311
+ fs.writeFileSync(probe, "");
312
+ fs.rmSync(probe);
313
+ return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
314
+ }
315
+ catch (error) {
316
+ try {
317
+ fs.rmSync(probe, { force: true });
318
+ }
319
+ catch { /* best effort */ }
320
+ return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
321
+ }
322
+ }
323
+ function projectGrounding(projectRoot) {
324
+ return [
325
+ ".memory-bank/index.md",
326
+ ".memory-bank/structure.md",
327
+ ".memory-bank/project-policy.md",
328
+ ".memory-bank/mbb/index.md",
329
+ ".memory-bank/spec/index.md",
330
+ ".memory-bank/scenarios/index.md",
331
+ ".memory-bank/defs/index.md",
332
+ ".memory-bank/protocol/index.md",
333
+ ".memory-bank/plans/index.md"
334
+ ]
335
+ .map((source) => ({ source, absolute: path.join(projectRoot, source) }))
336
+ .filter(({ absolute }) => fs.existsSync(absolute) && fs.statSync(absolute).isFile())
337
+ .map(({ source, absolute }) => ({ source, content: compactMarkdown(fs.readFileSync(absolute, "utf8")) }));
338
+ }
339
+ function compactMarkdown(markdown) {
340
+ const parts = markdown.trim().split(/^---\s*$/m);
341
+ const frontmatter = parts.length >= 3 ? parts[1] ?? "" : "";
342
+ const body = parts.length >= 3 ? parts.slice(2).join("---") : markdown;
343
+ const metadata = frontmatter.split(/\r?\n/).filter((line) => /^(description|purpose|status):/.test(line.trim()));
344
+ const overview = body.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 12);
345
+ return [...metadata, ...overview].join("\n").slice(0, 1600);
346
+ }
347
+ function renderPrompt(input) {
348
+ return [
349
+ "<work_identity>",
350
+ `- Flow: ${flowId}@${flowVersion}`,
351
+ `- RUN: ${input.runId}`,
352
+ `- Work: ${input.workId}`,
353
+ `- Stage: ${stageId}.${entryId}`,
354
+ "</work_identity>",
355
+ "",
356
+ "<user_discussion>",
357
+ input.discussion,
358
+ "</user_discussion>",
359
+ "",
360
+ "<trusted_runtime_context>",
361
+ "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.",
362
+ "- This beta Work uses the vNext SPECIFY flow; historical protocol-first lifecycle text in project documents is not an instruction for this Work.",
363
+ `- Project root: ${input.projectRoot}`,
364
+ `- Stage workspace: ${input.stageRoot}`,
365
+ "```json",
366
+ JSON.stringify(input.workContext.workspace, null, 2),
367
+ "```",
368
+ "</trusted_runtime_context>",
369
+ "",
370
+ "<eval_input_boundary>",
371
+ "Use only this prompt, the current project root, this stage workspace and project files reached through the supplied grounding. Do not read or search another RUN, transcript, prior result, reviewer material or other project under ~/.dd-flow or ~/.codex.",
372
+ "If stage finish rejects the result, correct this same JSON result only from the returned validation error. Do not seek another schema or example outside this stage workspace.",
373
+ "</eval_input_boundary>",
374
+ "",
375
+ "<project_grounding>",
376
+ input.grounding.length === 0
377
+ ? "No project Memory Bank index was present; state this limitation explicitly if it affects the result."
378
+ : input.grounding.map(({ source, content }) => `### ${source}\n${content}`).join("\n\n"),
379
+ "These are bounded navigation excerpts. Read a referenced project file only when its specific content is necessary to settle a requirement or gap.",
380
+ "</project_grounding>",
381
+ "",
382
+ "<stage_instructions>",
383
+ input.template,
384
+ "</stage_instructions>",
385
+ "",
386
+ "<output_contract>",
387
+ `Write one complete JSON result to ${path.join(input.stageRoot, "specify-result.json")}. dd-flow validates it, atomically stores it as \`specify.json\` and renders \`specify.md\`.`,
388
+ "The JSON shape below is the complete contract. R-* and AC-* are the only structured semantic lists. Put all other narrative content into the four Markdown strings in sections.",
389
+ "```json",
390
+ JSON.stringify(specifyExample(), null, 2),
391
+ "```",
392
+ "The JSON must preserve the problem-space contract for a fresh PROTOCOLIZE worker: user intent, scope, acceptance and verification, settled defaults, relevant project facts, gap-method outcomes, task assessment, delivery shape and handoff. Use stable R-001... identifiers in requirements and AC-001... identifiers in acceptance_criteria. Do not describe implementation design.",
393
+ "Do not finish while a material user question remains. Use stage pause instead of adding a question to specify.json.",
394
+ "If a user answer is required, make this the lifecycle command instead of finish. Run this exact one-command heredoc, replacing only the placeholder body with the concise user-facing question packet. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:",
395
+ "```sh",
396
+ input.pauseCommandTemplate,
397
+ "```",
398
+ "The response tells you exactly what to ask and how to resume this same stage. Ask its user_message and stop the Turn.",
399
+ `Finish exactly once only after all questions are resolved. Run this as one standalone Bash command:\n\`${input.flow} stage finish ${input.runId} --project-root "${input.projectRoot}" --stage specify --result-file ${JSON.stringify(path.join(input.stageRoot, "specify-result.json"))} --outcome specified --json\`.`,
400
+ "Do not combine the lifecycle command with cat, skill reads, Git commands, pipes or shell operators. If finish reports a validation error, correct the same result file and rerun that exact command in this Session. After success, follow only its returned next directive; a focused eval controller may explicitly stop at this stage boundary.",
401
+ "</output_contract>",
402
+ ""
403
+ ].join("\n");
404
+ }
405
+ function buildStageReport(input) {
406
+ return {
407
+ schema_id: "dd-flow/stage-report@1",
408
+ run_id: input.run.id,
409
+ stage: stageId,
410
+ generated_at: input.now,
411
+ verdict: input.outcome === "specified" ? "done" : input.outcome,
412
+ semantic: {
413
+ result: input.result.summary,
414
+ acceptance: input.result.acceptance_criteria.map((criterion) => criterion.id),
415
+ changed_files: [],
416
+ checks: ["specified result contract", "stage-report schema"],
417
+ evidence: [input.resultFile, path.join(input.stageRoot, "specify.md")],
418
+ next_action: input.outcome === "specified" ? "start_protocolize" : "none",
419
+ document_path: input.resultFile,
420
+ document_sha256: crypto.createHash("sha256").update(fs.readFileSync(input.resultFile)).digest("hex"),
421
+ projection_path: path.join(input.stageRoot, "specify.md"),
422
+ projection_sha256: crypto.createHash("sha256").update(input.resultMarkdown).digest("hex"),
423
+ work_id: input.work.work_id,
424
+ id: input.workSession?.id ?? null,
425
+ session_id: input.workSession?.session_id ?? null
426
+ },
427
+ mechanical: {
428
+ started_at: input.work.created_at,
429
+ finished_at: input.now,
430
+ wall_clock_ms: Math.max(0, Date.parse(input.now) - Date.parse(input.work.created_at)),
431
+ git: gitFacts(input.run.workspace_root),
432
+ observability: "query dd-flow stat after all RUN sessions have settled"
433
+ },
434
+ artifacts: {
435
+ json: path.join(input.stageRoot, "stage-report.json"),
436
+ markdown: path.join(input.stageRoot, "stage-report.md"),
437
+ html: path.join(input.stageRoot, "stage-report.html"),
438
+ summary: path.join(input.stageRoot, "stage-report.md")
439
+ },
440
+ validation: { permission_scope: "known_targets_only", memory_bank_scope: "changed_files_and_links_only", status: "passed" },
441
+ breadcrumbs: [{ label: "RUN", href: input.run.id, status: "available" }]
442
+ };
443
+ }
444
+ function specifyExample() {
445
+ return {
446
+ schema_id: "dd-flow/specify@1",
447
+ summary: "Clarified outcome.",
448
+ requirements: [{ id: "R-001", statement: "The system preserves the accepted business rule." }],
449
+ acceptance_criteria: [{ id: "AC-001", statement: "The actor observes the accepted result." }],
450
+ sections: {
451
+ problem_and_scope: "## Goal\n\n- State the problem, actors, in-scope and out-of-scope behavior.",
452
+ acceptance_and_verification: "## Scenario\n\n- State the observable path and evidence.",
453
+ gaps_defaults_and_project_facts: "## Baseline\n\n- Record the selected gap methods, defaults and binding project facts.",
454
+ assessment_and_protocolize_handoff: "## Handoff\n\n- Record assessment, delivery-shape seed, material sources and verification seeds."
455
+ }
456
+ };
457
+ }
458
+ export function readVnextSpecifyResult(file) {
459
+ try {
460
+ return JSON.parse(fs.readFileSync(file, "utf8"));
461
+ }
462
+ catch (error) {
463
+ throw new AppError("validation", "SPECIFY result must be valid JSON", 2, { file, message: String(error) });
464
+ }
465
+ }
466
+ function validateObligations(result, file) {
467
+ const errors = [];
468
+ const validate = (values, prefix) => {
469
+ const ids = new Set();
470
+ values.forEach((value, index) => {
471
+ const valuePath = `${prefix === "R" ? "requirements" : "acceptance_criteria"}[${index}]`;
472
+ if (ids.has(value.id))
473
+ errors.push({ path: `${valuePath}.id`, message: `SPECIFY ${prefix}-* ids must be unique` });
474
+ ids.add(value.id);
475
+ if (!new RegExp(`^${prefix}-[0-9]{3,}$`).test(value.id) || !value.statement.trim()) {
476
+ errors.push({ path: valuePath, message: `SPECIFY ${prefix}-* obligations need a stable id and non-empty statement` });
477
+ }
478
+ });
479
+ };
480
+ validate(result.requirements, "R");
481
+ validate(result.acceptance_criteria, "AC");
482
+ if (errors.length)
483
+ throw new AppError("validation", "SPECIFY obligations have validation errors", 2, { file, errors });
484
+ }
485
+ export function renderSpecifyMarkdown(result) {
486
+ const render = (values) => values.map((value) => `- ${value.id}: ${value.statement.trim()}`).join("\n");
487
+ return [
488
+ "# Summary", "", result.summary.trim(), "",
489
+ "# User problem and scope", "", result.sections.problem_and_scope.trim(), "",
490
+ "# Requirements", "", render(result.requirements), "",
491
+ "# Acceptance criteria", "", render(result.acceptance_criteria), "",
492
+ "# Acceptance and verification", "", result.sections.acceptance_and_verification.trim(), "",
493
+ "# Gaps, defaults and project facts", "", result.sections.gaps_defaults_and_project_facts.trim(), "",
494
+ "# Assessment and PROTOCOLIZE handoff", "", result.sections.assessment_and_protocolize_handoff.trim(), ""
495
+ ].join("\n");
496
+ }
497
+ function workProjection(work, workSession) {
498
+ return {
499
+ schema_id: "dd-flow/work@1",
500
+ ...work,
501
+ work_session: workSession ? {
502
+ id: workSession.id,
503
+ work_id: workSession.work_id,
504
+ status: workSession.status,
505
+ prompt_path: workSession.prompt_path,
506
+ result_path: workSession.result_path,
507
+ created_at: workSession.created_at,
508
+ updated_at: workSession.updated_at,
509
+ completed_at: workSession.completed_at
510
+ } : null
511
+ };
512
+ }
513
+ function writeJson(file, value) {
514
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
515
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
516
+ fs.renameSync(tmp, file);
517
+ }
518
+ function writeText(file, content) {
519
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
520
+ fs.writeFileSync(tmp, content);
521
+ fs.renameSync(tmp, file);
522
+ }
523
+ function requireInside(root, candidate, label) {
524
+ const relative = path.relative(root, candidate);
525
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
526
+ throw new AppError("path_escape", `${label} must be inside the SPECIFY workspace`, 2, { root, candidate });
527
+ }
528
+ }
529
+ function readRequiredFile(file, label) {
530
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile())
531
+ throw new AppError("not_found", `${label} is missing`, 1, { file });
532
+ const content = fs.readFileSync(file, "utf8").trim();
533
+ if (!content)
534
+ throw new AppError("validation", `${label} is empty`, 2, { file });
535
+ return content;
536
+ }
537
+ function assertFlowDefinition(projectRoot) {
538
+ const definition = readVnextFlowDefinition(projectRoot);
539
+ if (!definition) {
540
+ throw new AppError("validation", "vNext SPECIFY flow definition does not match this beta engine", 2, { expected: `${flowId}@${flowVersion}` });
541
+ }
542
+ return definition;
543
+ }
544
+ export function readVnextFlowDefinition(projectRoot) {
545
+ for (const file of [
546
+ path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "mb-sdlc-vnext-protocolize.json"),
547
+ path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "mb-sdlc-vnext-specify.json")
548
+ ]) {
549
+ if (!fs.existsSync(file))
550
+ continue;
551
+ const definition = JSON.parse(readRequiredFile(file, "vNext SPECIFY flow definition"));
552
+ const actions = definition.stages && typeof definition.stages === "object"
553
+ ? definition.stages.specify?.entries?.default
554
+ : undefined;
555
+ const ordered = Array.isArray(actions?.actions) ? actions.actions : [];
556
+ if ((definition.id === flowId || definition.id === protocolizeFlowId) && definition.version === flowVersion && ordered.length === 3) {
557
+ return { id: definition.id };
558
+ }
559
+ }
560
+ return null;
561
+ }
562
+ function executionSnapshot(context, runId) {
563
+ const row = context.db.get("SELECT index_json FROM runs WHERE id = ?", [runId]);
564
+ const snapshot = row ? JSON.parse(row.index_json) : null;
565
+ const handoff = snapshot?.execution_profile?.settings?.stage_session_mode;
566
+ if (handoff !== "same_session" && handoff !== "new_session")
567
+ throw new AppError("execution_profile_invalid", "RUN has no frozen stage session mode", 1, { run_id: runId });
568
+ return { stage_handoff: { effective: handoff, source: "run_override" } };
569
+ }
570
+ function executionSnapshotFromPath(contextPath, workId) {
571
+ try {
572
+ const value = JSON.parse(fs.readFileSync(contextPath, "utf8"));
573
+ if (value.execution?.stage_handoff?.effective === "same_session" || value.execution?.stage_handoff?.effective === "new_session")
574
+ return value.execution;
575
+ }
576
+ catch { /* a malformed runtime context is an engine error below */ }
577
+ throw new AppError("runtime_context_invalid", "SPECIFY Work is missing its immutable handoff policy", 1, { work_id: workId });
578
+ }
579
+ function flowKind(context, projectId, runId) {
580
+ return context.db.get("SELECT flow_kind FROM runs WHERE project_id = ? AND id = ?", [projectId, runId])?.flow_kind ?? null;
581
+ }
582
+ function gitFacts(workspaceRoot) {
583
+ const read = (args) => {
584
+ const result = spawnSync("git", ["-C", workspaceRoot, ...args], { encoding: "utf8" });
585
+ return result.status === 0 ? result.stdout.trim() || null : null;
586
+ };
587
+ const statusResult = spawnSync("git", ["-C", workspaceRoot, "status", "--porcelain", "--untracked-files=all"], { encoding: "utf8" });
588
+ const status = statusResult.status === 0 ? statusResult.stdout.trim() : null;
589
+ return {
590
+ branch: read(["branch", "--show-current"]),
591
+ head: read(["rev-parse", "HEAD"]),
592
+ status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean",
593
+ remotes: (read(["remote"]) ?? "").split(/\r?\n/).filter(Boolean)
594
+ };
595
+ }