@deksden-com/dd-flow-cli 0.3.1 → 0.4.1

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 (43) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +25 -9
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +58 -32
  5. package/dist/cli/run-cli.js +282 -50
  6. package/dist/domain/entity-ids.js +4 -4
  7. package/dist/domain/flow-contract.js +502 -36
  8. package/dist/domain/validation.js +34 -0
  9. package/dist/protocol/local-files.js +8 -6
  10. package/dist/schemas/code-stage-report.schema.json +197 -2
  11. package/dist/schemas/flow-contract.schema.json +126 -0
  12. package/dist/schemas/flow-run-index-v3.schema.json +203 -0
  13. package/dist/schemas/flow-run-index.schema.json +22 -2
  14. package/dist/schemas/flow-run.schema.json +36 -0
  15. package/dist/schemas/merge-stage-report.schema.json +213 -2
  16. package/dist/schemas/plan-stage-report.schema.json +156 -2
  17. package/dist/schemas/release-impact.schema.json +16 -0
  18. package/dist/services/audit.js +3 -3
  19. package/dist/services/branch-context.js +266 -0
  20. package/dist/services/canon.js +0 -1
  21. package/dist/services/cleanup.js +6 -6
  22. package/dist/services/cli-operation-classifier.js +1 -1
  23. package/dist/services/compatibility-preflight.js +8 -77
  24. package/dist/services/dashboard.js +48 -11
  25. package/dist/services/engines.js +123 -13
  26. package/dist/services/hooks.js +6 -6
  27. package/dist/services/ids.js +40 -49
  28. package/dist/services/merge-queue.js +240 -20
  29. package/dist/services/merge-worker.js +12 -4
  30. package/dist/services/migrations.js +64 -0
  31. package/dist/services/plans.js +23 -16
  32. package/dist/services/projects.js +2 -2
  33. package/dist/services/prompts.js +322 -0
  34. package/dist/services/protocols.js +78 -42
  35. package/dist/services/run-projection.js +80 -0
  36. package/dist/services/runs.js +360 -22
  37. package/dist/services/schema-validation.js +35 -12
  38. package/dist/services/sessions.js +81 -3
  39. package/dist/services/status.js +32 -1
  40. package/dist/services/usage.js +233 -0
  41. package/dist/services/worktrees.js +24 -19
  42. package/dist/storage/database.js +223 -9
  43. package/package.json +1 -1
@@ -0,0 +1,322 @@
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 { getCliVersionReport } from "./build-info.js";
6
+ import { requireProjectByRoot } from "./projects.js";
7
+ import { requireProtocol } from "./protocols.js";
8
+ import { validatePlan } from "../domain/validation.js";
9
+ import { AppError } from "../shared/errors.js";
10
+ import { parseJsonObject } from "../shared/json.js";
11
+ import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
12
+ const profiles = {
13
+ code_implementation: {
14
+ static_files: [".memory-bank/dd-flow/common/worker-session.md", ".memory-bank/dd-flow/workers/code.md"]
15
+ },
16
+ documentation: {
17
+ static_files: [".memory-bank/dd-flow/common/worker-session.md", ".memory-bank/dd-flow/workers/docs.md"]
18
+ },
19
+ verification: {
20
+ static_files: [".memory-bank/dd-flow/common/worker-session.md", ".memory-bank/dd-flow/workers/verify.md"]
21
+ }
22
+ };
23
+ export function renderWorkerPrompt(context, input) {
24
+ const projectRoot = resolveProjectRoot(input.projectRoot);
25
+ const project = requireProjectByRoot(context, projectRoot);
26
+ const protocol = requireProtocol(context, protocolIdForRun(context, project.id, input.runId), project.id);
27
+ const run = requireRun(context, project.id, input.runId);
28
+ const index = parseRunIndex(run.index_json, run.id);
29
+ const stage = requireStage(index, input.stage, run.id);
30
+ const workspaceRoot = resolveWorkspace(input.workspaceRoot ?? run.workspace_root, "workspace-root");
31
+ const recordedWorkspace = resolveWorkspace(index.execution?.workspace_root ?? run.workspace_root, "run workspace");
32
+ if (workspaceRoot !== recordedWorkspace) {
33
+ throw new AppError("prompt_runtime_mismatch", "Workspace root does not match the selected RUN", 1, {
34
+ expected: recordedWorkspace,
35
+ actual: workspaceRoot,
36
+ run_id: run.id
37
+ });
38
+ }
39
+ assertGitFacts(workspaceRoot, index.execution?.git, run.id);
40
+ const plan = input.taskFile ? null : readPlan(protocol.plan_path, protocol.id);
41
+ const genericTask = input.taskFile ? readWorkerTask(input.taskFile, runHomePath(run), protocol.id) : null;
42
+ const item = genericTask?.item ?? plan?.items.find((candidate) => candidate.id === input.planItemId);
43
+ if (!item)
44
+ throw new AppError("not_found", `Plan item is not found: ${input.planItemId}`, 1);
45
+ if (!item.execution_context || !item.semantic_spine) {
46
+ throw new AppError("prompt_context_missing", "Plan item requires execution_context and semantic_spine before prompt rendering", 1, {
47
+ plan_item_id: item.id
48
+ });
49
+ }
50
+ if (item.execution_context.prompt_profile !== input.profile) {
51
+ throw new AppError("prompt_profile_mismatch", "Requested profile does not match plan item execution_context", 1, {
52
+ requested: input.profile,
53
+ declared: item.execution_context.prompt_profile,
54
+ plan_item_id: item.id
55
+ });
56
+ }
57
+ const profile = profiles[input.profile];
58
+ if (!profile)
59
+ throw new AppError("prompt_profile_unsupported", `Unsupported prompt profile: ${input.profile}`, 2);
60
+ const staticInputs = profile.static_files.map((file) => readStaticInput(projectRoot, file));
61
+ const requiredRead = item.execution_context.required_read.map((file) => checkedReference(projectRoot, runHomePath(run), file, "required_read", true));
62
+ const discoveryBoundary = item.execution_context.discovery_boundary.map((file) => checkedReference(projectRoot, runHomePath(run), file, "discovery_boundary", false));
63
+ const writeScope = item.execution_context.write_scope.map((file) => checkedReference(projectRoot, undefined, file, "write_scope", false));
64
+ const runHome = runHomePath(run);
65
+ const outputDir = path.join(runHome, stage.dir, "subagents", item.id);
66
+ assertWithin(runHome, outputDir, "output directory");
67
+ ensureDir(outputDir);
68
+ const prompt = renderPrompt({
69
+ item,
70
+ input,
71
+ workspaceRoot,
72
+ requiredRead,
73
+ discoveryBoundary,
74
+ writeScope,
75
+ outputDir,
76
+ staticInputs,
77
+ ...(genericTask ? { handoff: genericTask.handoff } : {})
78
+ });
79
+ const promptPath = path.join(outputDir, "launch-prompt.md");
80
+ const stackPath = path.join(outputDir, "prompt-stack.json");
81
+ const reportPath = path.join(outputDir, "render-report.json");
82
+ fs.writeFileSync(promptPath, prompt);
83
+ writeJson(stackPath, {
84
+ schema_id: "dd-flow/prompt-stack@1",
85
+ profile: input.profile,
86
+ protocol_id: protocol.id,
87
+ run_id: run.id,
88
+ plan_id: plan?.plan_id ?? null,
89
+ plan_item_id: input.taskFile ? null : item.id,
90
+ task_id: input.taskFile ? item.id : null,
91
+ canon_version: readCanonVersion(projectRoot),
92
+ renderer_version: getCliVersionReport().cli.version,
93
+ static_inputs: staticInputs.map(({ path: inputPath, sha256 }) => ({ path: inputPath, sha256 })),
94
+ validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, write_scope: writeScope },
95
+ ...(genericTask ? { task_handoff: genericTask.handoff } : {})
96
+ });
97
+ writeJson(reportPath, {
98
+ schema_id: "dd-flow/prompt-render-report@1",
99
+ status: "rendered",
100
+ profile: input.profile,
101
+ protocol_id: protocol.id,
102
+ run_id: run.id,
103
+ plan_item_id: input.taskFile ? null : item.id,
104
+ task_id: input.taskFile ? item.id : null,
105
+ stage: input.stage,
106
+ workspace_root: workspaceRoot,
107
+ output: { launch_prompt: promptPath, prompt_stack: stackPath },
108
+ validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, write_scope: writeScope },
109
+ ...(genericTask ? { task_handoff: genericTask.handoff } : {})
110
+ });
111
+ return {
112
+ ok: true,
113
+ schema_id: "dd-flow/prompt-render@1",
114
+ prompt: { path: promptPath, stack_path: stackPath, report_path: reportPath },
115
+ profile: input.profile,
116
+ plan_item_id: input.taskFile ? null : item.id,
117
+ task_id: input.taskFile ? item.id : null,
118
+ run_id: run.id
119
+ };
120
+ }
121
+ function runHomePath(run) {
122
+ return run.run_home_path ?? path.dirname(run.run_index_path);
123
+ }
124
+ function readWorkerTask(taskFile, runHome, protocolId) {
125
+ const resolved = path.resolve(taskFile);
126
+ assertWithin(runHome, resolved, "worker task file");
127
+ if (!fs.existsSync(resolved))
128
+ throw new AppError("not_found", "Worker task file is missing", 1, { path: taskFile });
129
+ const task = parseJsonObject(fs.readFileSync(resolved, "utf8"), resolved);
130
+ if (task.schema_id !== "dd-flow/worker-task@1" || !task.task || typeof task.task !== "object") {
131
+ throw new AppError("validation", "Worker task must use dd-flow/worker-task@1 with a task object", 2, { path: taskFile });
132
+ }
133
+ const item = validatePlan({
134
+ schema_version: "0.1.0",
135
+ plan_id: "worker-task",
136
+ protocol_id: protocolId,
137
+ title: "worker task",
138
+ items: [{ ...task.task, depends_on: [] }]
139
+ }, protocolId).items[0];
140
+ return { item, handoff: validateWorkerTaskHandoff(task.handoff, runHome) };
141
+ }
142
+ function validateWorkerTaskHandoff(value, runHome) {
143
+ if (value === undefined)
144
+ return { predecessor_reports: [], recovery_attempt_paths: [] };
145
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
146
+ throw new AppError("validation", "Worker task handoff must be an object", 2);
147
+ }
148
+ const handoff = value;
149
+ const reports = handoff.predecessor_reports ?? [];
150
+ if (!Array.isArray(reports))
151
+ throw new AppError("validation", "Worker task predecessor_reports must be an array", 2);
152
+ const predecessor_reports = reports.map((entry, index) => {
153
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
154
+ throw new AppError("validation", `Worker task predecessor_reports[${index}] must be an object`, 2);
155
+ }
156
+ const report = entry;
157
+ if (typeof report.aspect_id !== "string" || report.aspect_id.length === 0) {
158
+ throw new AppError("validation", `Worker task predecessor_reports[${index}].aspect_id must be a string`, 2);
159
+ }
160
+ if (report.verdict !== "accepted" && report.verdict !== "not_applicable") {
161
+ throw new AppError("validation", `Worker task predecessor_reports[${index}].verdict must be accepted or not_applicable`, 2);
162
+ }
163
+ const verdict = report.verdict === "accepted" ? "accepted" : "not_applicable";
164
+ if (typeof report.report_path !== "string" || report.report_path.length === 0) {
165
+ throw new AppError("validation", `Worker task predecessor_reports[${index}].report_path must be a string`, 2);
166
+ }
167
+ const reportPath = path.resolve(report.report_path);
168
+ assertWithin(runHome, reportPath, "predecessor report");
169
+ if (!fs.existsSync(reportPath))
170
+ throw new AppError("not_found", "Accepted predecessor report is missing", 1, { path: report.report_path });
171
+ return { aspect_id: report.aspect_id, verdict, report_path: reportPath };
172
+ });
173
+ const recovery = handoff.recovery_attempt_paths ?? [];
174
+ if (!Array.isArray(recovery) || recovery.some((entry) => typeof entry !== "string" || entry.length === 0)) {
175
+ throw new AppError("validation", "Worker task recovery_attempt_paths must be a string array", 2);
176
+ }
177
+ for (const attemptPath of recovery)
178
+ assertWithin(runHome, path.resolve(attemptPath), "recovery attempt path");
179
+ if (handoff.acceptance_owner !== undefined && (typeof handoff.acceptance_owner !== "string" || handoff.acceptance_owner.length === 0)) {
180
+ throw new AppError("validation", "Worker task acceptance_owner must be a string", 2);
181
+ }
182
+ return {
183
+ predecessor_reports,
184
+ recovery_attempt_paths: recovery.map((attemptPath) => path.resolve(attemptPath)),
185
+ ...(typeof handoff.acceptance_owner === "string" ? { acceptance_owner: handoff.acceptance_owner } : {})
186
+ };
187
+ }
188
+ function protocolIdForRun(context, projectId, runId) {
189
+ const run = requireRun(context, projectId, runId);
190
+ const subject = parseJsonObject(run.index_json, `run ${run.id}`).subject;
191
+ if (subject?.type !== "protocol" || typeof subject.id !== "string") {
192
+ throw new AppError("prompt_run_subject", "Prompt rendering requires a protocol-owned RUN", 1, { run_id: run.id });
193
+ }
194
+ return subject.id;
195
+ }
196
+ function requireRun(context, projectId, runId) {
197
+ const row = context.db.get("SELECT id, project_id, workspace_root, run_index_path, run_home_path, index_json FROM flow_runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
198
+ if (!row)
199
+ throw new AppError("not_found", `Run is not found: ${runId}`, 1);
200
+ return row;
201
+ }
202
+ function parseRunIndex(text, runId) {
203
+ return parseJsonObject(text, `run ${runId}`);
204
+ }
205
+ function requireStage(index, stageName, runId) {
206
+ const stage = index.stage_runs?.find((candidate) => candidate.stage === stageName);
207
+ if (!stage?.dir || stage.status !== "running") {
208
+ throw new AppError("prompt_stage_unavailable", "Prompt rendering requires an attached running stage", 1, { run_id: runId, stage: stageName });
209
+ }
210
+ return { dir: stage.dir };
211
+ }
212
+ function readPlan(planPath, protocolId) {
213
+ if (!fs.existsSync(planPath))
214
+ throw new AppError("not_found", `Protocol plan is missing: ${planPath}`, 1);
215
+ return validatePlan(parseJsonObject(fs.readFileSync(planPath, "utf8"), planPath), protocolId);
216
+ }
217
+ function readStaticInput(projectRoot, relativePath) {
218
+ checkedReference(projectRoot, undefined, relativePath, "static profile input", true);
219
+ const absolute = path.resolve(projectRoot, relativePath);
220
+ const content = fs.readFileSync(absolute, "utf8");
221
+ return { path: relativePath, content, sha256: hash(content) };
222
+ }
223
+ function checkedReference(projectRoot, runHome, reference, label, mustExist) {
224
+ const isRunLocal = reference.startsWith("run://");
225
+ const relativeReference = isRunLocal ? reference.slice("run://".length) : reference;
226
+ if (isRunLocal && label !== "required_read" && label !== "discovery_boundary") {
227
+ throw new AppError("prompt_path_unsafe", `${label} cannot use a RUN-local path`, 2, { path: reference });
228
+ }
229
+ if (!relativeReference || path.isAbsolute(relativeReference) || relativeReference.split(/[\\/]/).some((part) => part === ".." || part === ".env" || part.startsWith(".env."))) {
230
+ throw new AppError("prompt_path_unsafe", `${label} contains an unsafe path`, 2, { path: reference });
231
+ }
232
+ const root = isRunLocal ? runHome : projectRoot;
233
+ if (!root)
234
+ throw new AppError("prompt_path_unsafe", `${label} cannot resolve a RUN-local path`, 2, { path: reference });
235
+ const absolute = path.resolve(root, relativeReference);
236
+ assertWithin(root, absolute, label);
237
+ if (mustExist && !fs.existsSync(absolute)) {
238
+ throw new AppError("prompt_source_missing", `${label} does not exist`, 1, { path: reference });
239
+ }
240
+ return reference;
241
+ }
242
+ function assertWithin(root, candidate, label) {
243
+ const relative = path.relative(root, candidate);
244
+ if (relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)))
245
+ return;
246
+ throw new AppError("prompt_path_unsafe", `${label} must stay within its root`, 2, { root, candidate });
247
+ }
248
+ function resolveWorkspace(value, label) {
249
+ if (!fs.existsSync(value))
250
+ throw new AppError("prompt_runtime_mismatch", `${label} does not exist`, 1, { path: value });
251
+ return fs.realpathSync(value);
252
+ }
253
+ function assertGitFacts(workspaceRoot, expected, runId) {
254
+ if (!expected?.head && !expected?.branch)
255
+ return;
256
+ const read = (args) => {
257
+ const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
258
+ return result.status === 0 ? result.stdout.trim() || null : null;
259
+ };
260
+ const actual = { branch: read("branch --show-current"), head: read("rev-parse HEAD") };
261
+ if (actual.branch !== (expected.branch ?? null) || actual.head !== (expected.head ?? null)) {
262
+ throw new AppError("prompt_runtime_mismatch", "Git branch or head no longer matches the selected RUN", 1, {
263
+ run_id: runId,
264
+ expected,
265
+ actual
266
+ });
267
+ }
268
+ }
269
+ function renderPrompt(input) {
270
+ const { item, handoff, input: command, staticInputs } = input;
271
+ return [
272
+ "# Worker Launch Prompt",
273
+ "",
274
+ `Profile: \`${command.profile}\``,
275
+ `Plan item: \`${item.id}\` - ${item.title}`,
276
+ `Stage: \`${command.stage}\``,
277
+ "",
278
+ "## Task",
279
+ item.summary,
280
+ "",
281
+ "## Semantic Spine",
282
+ `- User outcome: ${item.semantic_spine?.user_outcome ?? "not_applicable"}`,
283
+ `- Component responsibility: ${item.semantic_spine?.component_responsibility ?? "not_applicable"}`,
284
+ `- Must preserve: ${(item.semantic_spine?.must_preserve ?? []).join("; ") || "none"}`,
285
+ `- Non-goals: ${(item.semantic_spine?.non_goals ?? []).join("; ") || "none"}`,
286
+ `- Acceptance contribution: ${item.semantic_spine?.acceptance_contribution ?? "not_applicable"}`,
287
+ "",
288
+ "## Runtime",
289
+ `- Workspace: \`${input.workspaceRoot}\``,
290
+ `- Allowed writes: ${input.writeScope.map((value) => `\`${value}\``).join(", ") || "none"}`,
291
+ `- Checks: ${(item.execution_context?.checks ?? []).map((value) => `\`${value}\``).join(", ") || "none"}`,
292
+ `- Report directory: \`${input.outputDir}\``,
293
+ "",
294
+ "## Required Reads",
295
+ ...input.requiredRead.map((value) => `- \`${value}\``),
296
+ "",
297
+ ...(handoff && (handoff.predecessor_reports.length > 0 || handoff.acceptance_owner)
298
+ ? [
299
+ "## Orchestration Handoff",
300
+ ...(handoff.acceptance_owner ? [`- Acceptance owner: ${handoff.acceptance_owner}`] : []),
301
+ ...handoff.predecessor_reports.map((report) => `- ${report.aspect_id} (${report.verdict}): \`${report.report_path}\``),
302
+ ""
303
+ ]
304
+ : []),
305
+ "## Bounded Discovery",
306
+ ...input.discoveryBoundary.map((value) => `- \`${value}\``),
307
+ "",
308
+ "Read the static instructions below before acting. Report the sources actually read and any additions inside the discovery boundary. If an essential source is outside that boundary, stop with a plan question or DEF rather than expanding scope silently.",
309
+ "",
310
+ ...staticInputs.flatMap((entry) => [`## Static Input: ${entry.path}`, "", entry.content.trim(), ""])
311
+ ].join("\n");
312
+ }
313
+ function hash(value) {
314
+ return crypto.createHash("sha256").update(value).digest("hex");
315
+ }
316
+ function readCanonVersion(projectRoot) {
317
+ const versionPath = path.join(projectRoot, "VERSION");
318
+ return fs.existsSync(versionPath) ? fs.readFileSync(versionPath, "utf8").trim() : null;
319
+ }
320
+ function writeJson(file, value) {
321
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
322
+ }