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

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 +22 -0
  2. package/README.md +25 -9
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +45 -29
  5. package/dist/cli/run-cli.js +229 -49
  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 +17 -5
  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 +3 -75
  24. package/dist/services/dashboard.js +48 -11
  25. package/dist/services/engines.js +124 -13
  26. package/dist/services/hooks.js +6 -6
  27. package/dist/services/ids.js +40 -49
  28. package/dist/services/merge-queue.js +33 -26
  29. package/dist/services/merge-worker.js +2 -1
  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 +77 -46
  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
@@ -6,15 +6,17 @@ import { parseJsonObject } from "../shared/json.js";
6
6
  import { ensureReadableFile } from "../storage/database.js";
7
7
  import { appendAudit } from "./audit.js";
8
8
  import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
9
+ import { requireProjectByRoot } from "./projects.js";
10
+ import { resolveProjectRoot } from "../storage/paths.js";
9
11
  import { writePlanFile } from "../protocol/local-files.js";
10
12
  export function setProtocolPlan(context, input) {
11
- const protocol = requireProtocol(context, input.protocolId);
13
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
12
14
  ensureReadableFile(input.file);
13
15
  const plan = validatePlan(parseJsonObject(fs.readFileSync(input.file, "utf8"), input.file), protocol.id);
14
16
  writePlanFile(protocol.plan_path, plan);
15
- context.db.run(`INSERT INTO plans (protocol_id, plan_json, updated_at)
16
- VALUES (?, ?, ?)
17
- ON CONFLICT(protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.id, JSON.stringify(plan), context.now()]);
17
+ context.db.run(`INSERT INTO plans (project_id, protocol_id, plan_json, updated_at)
18
+ VALUES (?, ?, ?, ?)
19
+ ON CONFLICT(project_id, protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.project_id, protocol.id, JSON.stringify(plan), context.now()]);
18
20
  updateStatePlanSummary(context, protocol, plan);
19
21
  appendAudit(context, {
20
22
  protocolId: protocol.id,
@@ -25,8 +27,8 @@ export function setProtocolPlan(context, input) {
25
27
  return { ok: true, protocol_id: protocol.id, plan: summarizePlan(plan) };
26
28
  }
27
29
  export function getPlanStatus(context, input) {
28
- const protocol = requireProtocol(context, input.protocolId);
29
- const plan = requireStoredPlan(context, protocol.id);
30
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
31
+ const plan = requireStoredPlan(context, protocol.project_id, protocol.id);
30
32
  return {
31
33
  ok: true,
32
34
  protocol_id: protocol.id,
@@ -43,7 +45,7 @@ export function getPlanStatus(context, input) {
43
45
  };
44
46
  }
45
47
  export function startPlanItem(context, input) {
46
- return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
48
+ return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
47
49
  assertDependenciesClosed(plan, item);
48
50
  return {
49
51
  ...item,
@@ -53,7 +55,7 @@ export function startPlanItem(context, input) {
53
55
  });
54
56
  }
55
57
  export function completePlanItem(context, input) {
56
- return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
58
+ return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
57
59
  assertDependenciesClosed(plan, item);
58
60
  return {
59
61
  ...item,
@@ -64,7 +66,7 @@ export function completePlanItem(context, input) {
64
66
  });
65
67
  }
66
68
  export function blockPlanItem(context, input) {
67
- return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
69
+ return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
68
70
  ...item,
69
71
  status: "blocked",
70
72
  summary: input.reason,
@@ -73,15 +75,15 @@ export function blockPlanItem(context, input) {
73
75
  }));
74
76
  }
75
77
  export function skipPlanItem(context, input) {
76
- return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
78
+ return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
77
79
  ...item,
78
80
  status: "skipped",
79
81
  summary: input.reason
80
82
  }));
81
83
  }
82
- function updatePlanItem(context, protocolId, itemId, transform) {
83
- const protocol = requireProtocol(context, protocolId);
84
- const plan = requireStoredPlan(context, protocol.id);
84
+ function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
85
+ const protocol = scopedProtocol(context, projectRoot, protocolId);
86
+ const plan = requireStoredPlan(context, protocol.project_id, protocol.id);
85
87
  const item = plan.items.find((candidate) => candidate.id === itemId);
86
88
  if (!item) {
87
89
  throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
@@ -92,9 +94,10 @@ function updatePlanItem(context, protocolId, itemId, transform) {
92
94
  };
93
95
  const validated = validatePlan(nextPlan, protocol.id);
94
96
  writePlanFile(protocol.plan_path, validated);
95
- context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE protocol_id = ?", [
97
+ context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE project_id = ? AND protocol_id = ?", [
96
98
  JSON.stringify(validated),
97
99
  context.now(),
100
+ protocol.project_id,
98
101
  protocol.id
99
102
  ]);
100
103
  updateStatePlanSummary(context, protocol, validated);
@@ -111,8 +114,12 @@ function updatePlanItem(context, protocolId, itemId, transform) {
111
114
  });
112
115
  return { ok: true, protocol_id: protocol.id, item: validated.items.find((candidate) => candidate.id === itemId) };
113
116
  }
114
- function requireStoredPlan(context, protocolId) {
115
- const row = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocolId]);
117
+ function scopedProtocol(context, projectRoot, protocolId) {
118
+ const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
119
+ return requireProtocol(context, protocolId, project.id);
120
+ }
121
+ function requireStoredPlan(context, projectId, protocolId) {
122
+ const row = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
116
123
  if (!row) {
117
124
  throw new AppError("not_found", `Plan is not attached to protocol: ${protocolId}`, 1);
118
125
  }
@@ -91,7 +91,7 @@ export function resolveProject(context, input) {
91
91
  }
92
92
  const details = { input: input.idOrAlias };
93
93
  if (!isFullEntityId(input.idOrAlias) && !isShortEntityId(input.idOrAlias)) {
94
- details.expected = "Use full id PRJ-NNN-slug, short alias PRJ-NNN, slug, root path, or --root for root-based commands.";
94
+ details.expected = "Use full id PRJ-<sequence>-slug, short alias PRJ-<sequence>, slug, root path, or --root for root-based commands.";
95
95
  }
96
96
  throw new AppError("not_found", `Project is not registered: ${input.idOrAlias}`, 1, details);
97
97
  }
@@ -108,7 +108,7 @@ export function requireProjectByReference(context, reference) {
108
108
  }
109
109
  throw new AppError("not_found", `Project is not registered: ${reference}`, 1, {
110
110
  input: reference,
111
- expected: "Use full id PRJ-NNN-slug, short alias PRJ-NNN, slug, or project root path."
111
+ expected: "Use full id PRJ-<sequence>-slug, short alias PRJ-<sequence>, slug, or project root path."
112
112
  });
113
113
  }
114
114
  export function migrateProjectIds(context, input) {
@@ -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
+ }