@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,542 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { projectFeatureWorktreePath, resolveProjectRoot } from "../storage/paths.js";
6
+ import { previewNextEntityId } from "./ids.js";
7
+ import { registerProtocol } from "./protocols.js";
8
+ import { requireProjectByRoot } from "./projects.js";
9
+ import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, gitFacts, rebindFlowRunWorkspace } from "./runs.js";
10
+ import { validateSchema } from "./schema-validation.js";
11
+ import { assertStageStartHookEvent, hookSessionIdentity } from "./hooks.js";
12
+ import { bindStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
13
+ import { runTargetedMemoryBankLint } from "./stage-lifecycle.js";
14
+ import { bootstrapManagedWorktree, createManagedWorktree, recordManagedWorktree } from "./worktrees.js";
15
+ import { loadVnextWorkspacePolicy, requireVnextWorkspaceRoute, vnextRunBranch } from "./vnext-workspace-policy.js";
16
+ import { readVnextSpecifyResult } from "./vnext-specify.js";
17
+ import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
18
+ import { writeStageReport } from "./stage-report-renderer.js";
19
+ import { applyExternalStageContext } from "./stage-context.js";
20
+ const stage = "protocolize";
21
+ function stageSessionMode(context, run) {
22
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id]);
23
+ const value = row ? JSON.parse(row.index_json) : null;
24
+ const mode = value?.execution_profile?.settings?.stage_session_mode;
25
+ if (mode !== "same_session" && mode !== "new_session")
26
+ throw new AppError("execution_profile_invalid", "RUN is missing its frozen stage session mode", 1, { run_id: run.id });
27
+ return mode;
28
+ }
29
+ export function isVnextProtocolizeRun(context, input) {
30
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
31
+ return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
32
+ }
33
+ export function prepareVnextProtocolize(context, input) {
34
+ const projectRoot = resolveProjectRoot(input.projectRoot);
35
+ const project = requireProjectByRoot(context, projectRoot);
36
+ const run = requireRun(context, projectRoot, input.runId);
37
+ const work = requireWork(context, input.workId);
38
+ if (work.project_id !== project.id || work.run_id !== run.id)
39
+ throw new AppError("project_mismatch", "Work does not belong to the requested RUN", 1);
40
+ const runHome = requireRunHome(run);
41
+ const stageRoot = path.join(runHome, "02-protocolize");
42
+ const specifyPath = path.join(runHome, "01-specify", "specify.json");
43
+ const specifyMarkdownPath = path.join(runHome, "01-specify", "specify.md");
44
+ const obligations = acceptedSpecifyObligations(context, projectRoot, run.id, specifyPath);
45
+ fs.mkdirSync(stageRoot, { recursive: true });
46
+ const resultPath = path.join(stageRoot, "protocolize-result.json");
47
+ const promptPath = path.join(stageRoot, "prompt.md");
48
+ const policy = provisionWorkspaceForProtocolize(context, projectRoot, project.id, run, stageRoot);
49
+ const routedRun = requireRun(context, projectRoot, run.id);
50
+ writeJson(path.join(stageRoot, "work-context.json"), { schema_id: "dd-flow/vnext-protocolize-context@2", system: { run_id: run.id, work_id: work.work_id, stage, handoff: input.handoff }, input: { accepted_specify_json: specifyPath, accepted_specify_markdown: specifyMarkdownPath }, catalog: catalog(projectRoot), workspace: { project_root: projectRoot, workspace_root: routedRun.workspace_root, stage_root: stageRoot, git_policy: policy } });
51
+ if (!fs.existsSync(resultPath))
52
+ writeJson(resultPath, resultTemplate(obligations));
53
+ const templatePath = path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "protocolize.md");
54
+ if (!fs.existsSync(templatePath))
55
+ throw new AppError("not_found", "vNext PROTOCOLIZE prompt template is missing", 1, { path: templatePath });
56
+ const pauseCommand = stagePauseCommand(context, { runId: run.id, stage, workId: work.work_id, projectRoot });
57
+ const prompt = renderPrompt({ projectRoot, runId: run.id, workId: work.work_id, specifyPath, obligations, resultPath, handoff: input.handoff, catalog: catalog(projectRoot), policy, template: fs.readFileSync(templatePath, "utf8"), pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), flow: flowCommand(context) });
58
+ fs.writeFileSync(promptPath, prompt);
59
+ // Preparation is deliberately non-productive: the standalone stage-start
60
+ // command atomically installs external context, binds the worker and opens
61
+ // the lifecycle Stage. Do not make this prompt an implicit running stage.
62
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_prepared", work_id: work.work_id, stage, handoff_mode: input.handoff.stage_handoff.effective });
63
+ const directive = input.handoff.stage_handoff.effective === "same_session" ? { kind: "continue_current_session", stage, prompt_path: promptPath, worker_prompt_markdown: prompt } : { kind: "await_new_session", stage, work_id: work.work_id, prompt_path: promptPath, resume_command: `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json` };
64
+ return { directive, prompt_path: promptPath };
65
+ }
66
+ export function startVnextProtocolize(context, input) {
67
+ const projectRoot = resolveProjectRoot(input.projectRoot);
68
+ const project = requireProjectByRoot(context, projectRoot);
69
+ const run = requireRun(context, projectRoot, input.runId);
70
+ assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
71
+ 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, run.id]);
72
+ if (!work || work.status !== "running")
73
+ throw new AppError("invalid_work_state", "PROTOCOLIZE is not ready for an Agent WorkSession", 1, { run_id: run.id });
74
+ const protocolizeRoot = path.join(requireRunHome(run), "02-protocolize");
75
+ const contextPath = path.join(protocolizeRoot, "work-context.json");
76
+ if (!fs.existsSync(contextPath)) {
77
+ const handoff = specifyHandoff(path.join(requireRunHome(run), "01-specify", "work-context.json"), work.work_id);
78
+ prepareVnextProtocolize(context, { projectRoot, runId: run.id, workId: work.work_id, handoff });
79
+ }
80
+ const handoff = JSON.parse(fs.readFileSync(contextPath, "utf8"));
81
+ const frozenContext = JSON.parse(fs.readFileSync(contextPath, "utf8"));
82
+ const previous = context.db.get("SELECT ws.session_id, s.stop_reason FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at DESC LIMIT 1", [project.id, work.work_id]);
83
+ const activeWorkSession = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
84
+ const stageAttached = (() => {
85
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
86
+ try {
87
+ const index = JSON.parse(row?.index_json ?? "{}");
88
+ return index.stage_runs?.some((item) => item.stage === stage) ?? false;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ })();
94
+ const mode = handoff.system?.handoff?.stage_handoff.effective;
95
+ const promptPath = path.join(protocolizeRoot, "prompt.md");
96
+ const externalContext = applyExternalStageContext({ stageRoot: protocolizeRoot, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
97
+ const sessionId = hookSessionIdentity(context, project.id, input.hookEventId).sessionId;
98
+ // A stage-entry eval snapshot deliberately stops the canonical binding. Its
99
+ // Subject starts from a fork of the captured starter Session, so the stale
100
+ // canonical ID must not be treated as the live same-session owner.
101
+ const restoredStarter = previous?.stop_reason === "snapshot_restored";
102
+ if (mode === "same_session" && previous?.session_id && previous.session_id !== sessionId && !restoredStarter)
103
+ throw new AppError("handoff_session_mismatch", "Project policy requires PROTOCOLIZE to continue in the SPECIFY session", 1);
104
+ if (mode === "new_session" && previous?.session_id === sessionId)
105
+ throw new AppError("handoff_session_mismatch", "Project policy requires PROTOCOLIZE to start in a fresh session", 1);
106
+ const binding = bindStageCoordinatorWork(context, { workId: work.work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(requireRunHome(run), "02-protocolize", "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
107
+ // Same-session handoff deliberately reuses the root Work/Session that
108
+ // SPECIFY opened. That is not evidence that PROTOCOLIZE is already attached.
109
+ // Attach only once; a repeated `stage start` must not archive a live attempt.
110
+ if (!stageAttached)
111
+ attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: "02-protocolize", status: "running", dataSchemaId: "dd-flow/vnext-protocolize-result@3" });
112
+ if (mode === "same_session" && activeWorkSession?.session_id === sessionId) {
113
+ return { ok: true, outcome: "work_session_required", run_id: run.id, work_id: work.work_id, id: activeWorkSession.id, prompt_path: promptPath, git_policy: frozenContext.workspace?.git_policy ?? null, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}) };
114
+ }
115
+ const workSessionId = String(binding.work_session_id);
116
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", work_id: work.work_id, id: workSessionId, stage, session_id: sessionId });
117
+ return { ok: true, outcome: "work_session_required", run_id: run.id, work_id: work.work_id, id: workSessionId, prompt_path: promptPath, git_policy: frozenContext.workspace?.git_policy ?? null, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}) };
118
+ }
119
+ function specifyHandoff(file, workId) {
120
+ try {
121
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
122
+ if (value.execution?.stage_handoff?.effective === "same_session" || value.execution?.stage_handoff?.effective === "new_session")
123
+ return value.execution;
124
+ }
125
+ catch { /* reported below */ }
126
+ throw new AppError("runtime_context_invalid", "SPECIFY Work is missing its immutable handoff policy", 1, { work_id: workId });
127
+ }
128
+ export function finishVnextProtocolize(context, input) {
129
+ const projectRoot = resolveProjectRoot(input.projectRoot);
130
+ const project = requireProjectByRoot(context, projectRoot);
131
+ const run = requireRun(context, projectRoot, input.runId);
132
+ 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, run.id]);
133
+ if (!work)
134
+ throw new AppError("not_found", "RUN has no PROTOCOLIZE Work", 1, { run_id: run.id });
135
+ const root = path.join(requireRunHome(run), "02-protocolize");
136
+ const resultFile = path.resolve(input.resultFile);
137
+ inside(root, resultFile);
138
+ validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
139
+ const result = readResult(resultFile);
140
+ if (result.outcome !== "protocolized")
141
+ throw new AppError("validation", "PROTOCOLIZE may finish only as protocolized; use stage pause for every user question", 2);
142
+ const acceptedObligations = acceptedSpecifyObligations(context, projectRoot, run.id, path.join(requireRunHome(run), "01-specify", "specify.json"));
143
+ const obligations = effectiveObligations(result, acceptedObligations);
144
+ validateDelivery(projectRoot, result, obligations);
145
+ const protocolIds = result.delivery.members.map((member) => allocateProtocolId(context, projectRoot, member.slug));
146
+ const psetId = result.delivery.kind === "protocol_set" ? allocatePsetId(projectRoot, result.delivery.slug) : null;
147
+ const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
148
+ const receipt = readWorkspaceRouteReceipt(root);
149
+ const workspaceRoot = run.workspace_root;
150
+ const changedFiles = materialize(workspaceRoot, run.id, requireRunHome(run), result, obligations, protocolIds, psetId);
151
+ for (const protocolId of protocolIds)
152
+ registerProtocol(context, { handshakeId: protocolId, projectRoot, workspacePath: workspaceRoot });
153
+ if (receipt.created && receipt.bootstrap) {
154
+ for (const protocolId of protocolIds)
155
+ recordManagedWorktree(context, { protocolId, projectRoot, branch: requiredPolicyValue(resolvedPolicy.feature_branch, "feature branch"), base: requiredPolicyValue(resolvedPolicy.base_ref, "base ref"), created: receipt.created, bootstrap: receipt.bootstrap });
156
+ }
157
+ const now = context.now();
158
+ const workSession = completeWorkSession(context, work.work_id, resultFile, now);
159
+ const routedRun = requireRun(context, projectRoot, run.id);
160
+ const report = stageReport(context, routedRun, resultFile, protocolIds, changedFiles, workSession?.session_id ?? null, now, resolvedPolicy);
161
+ const reportJson = path.join(root, "stage-report.json");
162
+ const reportMarkdown = path.join(root, "stage-report.md");
163
+ const reportHtml = path.join(root, "stage-report.html");
164
+ writeStageReport(root, report);
165
+ validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
166
+ completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "protocolize-result.json", dataSchemaId: "dd-flow/vnext-protocolize-result@3", report: "stage-report.md", stageReport: "stage-report.html" });
167
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "protocol_documents_materialized", work_id: work.work_id, protocol_ids: protocolIds, stage });
168
+ advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
169
+ refreshRunWorkProjection(context, project.id, run.id);
170
+ const nextCommand = `${flowCommand(context)} stage start ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`;
171
+ return { ok: true, outcome: "protocolized", run_id: run.id, protocol_ids: protocolIds, workspace_root: workspaceRoot, git_policy: resolvedPolicy, next_action: "await_plan", next_command: nextCommand, next: { kind: "start_stage", stage: "plan", command: nextCommand, cwd: workspaceRoot, session_mode: resolvedPolicy.stage_session_mode }, artifacts: { result: resultFile, report_json: reportJson, report_markdown: reportMarkdown, report_html: reportHtml, protocols: protocolIds.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "summary.md")) } };
172
+ }
173
+ function materialize(projectRoot, runId, runHome, result, obligations, ids, psetId) {
174
+ const psetPath = psetId ? path.join(projectRoot, ".memory-bank", "protocol", "_set", `${psetId}.md`) : null;
175
+ const created = [];
176
+ const changed = new Set();
177
+ const originals = new Map();
178
+ const remember = (file) => { if (!originals.has(file))
179
+ originals.set(file, fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null); };
180
+ const idsByMemberKey = new Map(result.delivery.members.map((member, index) => [member.key, ids[index]]));
181
+ try {
182
+ const featurePath = materializeFeature(projectRoot, result.feature, ids, created, remember, changed);
183
+ result.delivery.members.forEach((member, index) => {
184
+ const protocolRoot = path.join(projectRoot, ".memory-bank", "protocol", ids[index]);
185
+ fs.mkdirSync(protocolRoot, { recursive: false });
186
+ created.push(protocolRoot);
187
+ const rawIntakePath = result.durable_links.preserve_raw_intake && index === 0 ? copyRawIntake(runHome, protocolRoot) : null;
188
+ const blockedBy = member.blocked_by.map((key) => idsByMemberKey.get(key) ?? key);
189
+ fs.writeFileSync(path.join(protocolRoot, "summary.md"), protocolMarkdown(ids[index], member, blockedBy, runId, psetPath, featurePath, result, obligations, rawIntakePath));
190
+ fs.writeFileSync(path.join(protocolRoot, "index.md"), protocolIndexMarkdown(ids[index]));
191
+ changed.add(relativeMemory(path.join(protocolRoot, "summary.md")));
192
+ changed.add(relativeMemory(path.join(protocolRoot, "index.md")));
193
+ if (rawIntakePath)
194
+ changed.add(relativeMemory(rawIntakePath));
195
+ });
196
+ if (psetPath && psetId) {
197
+ fs.mkdirSync(path.dirname(psetPath), { recursive: true });
198
+ remember(psetPath);
199
+ fs.writeFileSync(psetPath, psetMarkdown(psetId, result, obligations, ids));
200
+ changed.add(relativeMemory(psetPath));
201
+ }
202
+ const protocolIndex = path.join(projectRoot, ".memory-bank", "protocol", "index.md");
203
+ remember(protocolIndex);
204
+ ids.forEach((id, index) => appendIndexEntry(protocolIndex, `.memory-bank/protocol/${id}/index.md`, `${id} — ${result.delivery.members[index].title}`));
205
+ changed.add(relativeMemory(protocolIndex));
206
+ const files = [...changed].sort();
207
+ const materializedFiles = files.filter((file) => {
208
+ const absolute = path.resolve(projectRoot, file);
209
+ return created.some((directory) => absolute.startsWith(`${directory}${path.sep}`)) || absolute === psetPath;
210
+ });
211
+ runTargetedMemoryBankLint(projectRoot, materializedFiles);
212
+ return files;
213
+ }
214
+ catch (error) {
215
+ for (const [file, content] of originals) {
216
+ if (content === null)
217
+ fs.rmSync(file, { force: true });
218
+ else
219
+ fs.writeFileSync(file, content);
220
+ }
221
+ for (const dir of created.reverse())
222
+ fs.rmSync(dir, { recursive: true, force: true });
223
+ throw error;
224
+ }
225
+ }
226
+ function protocolMarkdown(id, member, blockedBy, runId, psetPath, featurePath, result, obligations, rawIntakePath) {
227
+ const a = member.primary_acceptance;
228
+ const links = result.durable_links;
229
+ const allFeatures = unique([...(featurePath ? [relativeMemory(featurePath)] : []), ...links.related_features]);
230
+ const owned = result.obligation_ownership.filter((item) => item.member_keys.includes(member.key)).map((item) => obligations.find((obligation) => obligation.id === item.obligation_id));
231
+ const ownedRequirements = owned.filter((obligation) => obligation.kind === "requirement");
232
+ const ownedAcceptance = owned.filter((obligation) => obligation.kind === "acceptance");
233
+ return ["---", `file: '.memory-bank/protocol/${id}/summary.md'`, `description: '${yaml(member.goal)}'`, `purpose: '${yaml("Records the executable protocol scope and acceptance.")}'`, "version: '0.1.0'", `date: '${today()}'`, "status: 'ACTIVE'", "c4_level: 'product'", `parent: '.memory-bank/protocol/${id}/index.md'`, `protocol_set: ${psetPath ? `'${relativeMemory(psetPath)}'` : "null"}`, `blocked_by_protocols: ${yamlList(blockedBy)}`, `source_user_input: ${rawIntakePath ? `'${relativeMemory(rawIntakePath)}'` : "null"}`, `related_epics: ${yamlList(links.related_epics)}`, `related_features: ${yamlList(allFeatures)}`, `related_specs: ${yamlList(links.related_specs)}`, `related_adrs: ${yamlList(links.related_adrs)}`, `related_scenarios: ${yamlList(links.related_scenarios)}`, "tags: [protocol]", "---", "", `# ${id} — ${member.title}`, "", "## Goal", "", member.goal, "", "## Delivery role", "", member.role, "", "## Scope", "", "### In scope", ...member.in_scope.map((x) => `- ${x}`), "", "### Out of scope", ...member.out_of_scope.map((x) => `- ${x}`), "", "## Primary acceptance", "", `- Actor: ${a.actor}`, `- Initial state: ${a.initial_state}`, `- Action: ${a.action}`, `- Expected result: ${a.expected_result}`, `- Verification: ${a.verification}`, `- Proof limits: ${a.proof_limits}`, "", "## Owned requirements", "", ...ownedRequirements.map((obligation) => `- ${obligation.id}: ${obligation.statement}`), "", "## Owned acceptance criteria", "", ...ownedAcceptance.map((obligation) => `- ${obligation.id}: ${obligation.statement}`), "", "## Flow handoff", "", `- Source RUN: ${runId}`, "- Next action: PLAN", ""].join("\n");
234
+ }
235
+ function protocolIndexMarkdown(id) { return ["---", `file: '.memory-bank/protocol/${id}/index.md'`, "description: 'Protocol navigation.'", "purpose: 'Links the protocol summary and durable intake.'", "version: '0.1.0'", `date: '${today()}'`, "status: 'ACTIVE'", "c4_level: 'product'", "parent: '.memory-bank/protocol/index.md'", `children:\n - .memory-bank/protocol/${id}/summary.md`, "tags: [protocol]", "---", "", `# ${id}`, "", "- [Summary](summary.md)", ""].join("\n"); }
236
+ function psetMarkdown(id, result, obligations, members) {
237
+ const topology = result.delivery.pset;
238
+ const ids = new Map(result.delivery.members.map((member, index) => [member.key, members[index]]));
239
+ const replaceKeys = (value) => [...ids.entries()].reduce((text, [key, protocolId]) => text.replaceAll(key, protocolId), value);
240
+ return ["---", `file: '.memory-bank/protocol/_set/${id}.md'`, `description: '${yaml(result.delivery.title)}'`, "purpose: 'Records the protocol-set decomposition and execution topology.'", "version: '0.1.0'", `date: '${today()}'`, "status: 'ACTIVE'", "c4_level: 'product'", `members: ${yamlList(members.map((member) => `.memory-bank/protocol/${member}/index.md`))}`, "---", "", `# ${id} — ${result.delivery.title}`, "", "## Decomposition rationale", "", result.delivery.decomposition_rationale, "", "## Members", "", ...members.map((member, i) => `- ${member} — ${result.delivery.members[i].role}: ${result.delivery.members[i].title} (${result.delivery.members[i].blocked_by.length ? `blocked by ${result.delivery.members[i].blocked_by.map((key) => ids.get(key) ?? key).join(", ")}` : "startable now"})`), "", "## Obligation ownership", "", ...result.obligation_ownership.map((ownership) => `- ${ownership.obligation_id}: ${obligations.find((obligation) => obligation.id === ownership.obligation_id).statement} → ${ownership.member_keys.map((key) => ids.get(key) ?? key).join(", ")}`), "", "## Execution topology", "", `- Selected: ${topology.selected}`, `- Workspace owner: ${topology.workspace_owner === "not_applicable" ? "not_applicable" : ids.get(topology.workspace_owner) ?? topology.workspace_owner}`, `- Confirmation gate: ${topology.confirmation_gate}`, "", "### Dependency graph", "", ...topology.dependency_graph.map((edge) => `- ${replaceKeys(edge)}`), "", "### Feasible modes", "", ...topology.feasible_modes.map((mode) => `- ${mode.mode}: ${replaceKeys(mode.graph)}; ${mode.relative_duration}; ${mode.reason}`), "", "### Excluded modes", "", ...(topology.excluded_modes.length ? topology.excluded_modes.map((mode) => `- ${mode.mode}: ${mode.reason}`) : ["- None"]), ""].join("\n");
241
+ }
242
+ function materializeFeature(projectRoot, feature, protocolIds, created, remember, changed) {
243
+ if (feature.action === "not_applicable")
244
+ return null;
245
+ if (feature.action === "link" || feature.action === "update") {
246
+ if (!feature.epic_path || !feature.slug)
247
+ throw new AppError("validation", "feature.link requires epic_path and slug", 2);
248
+ const index = featureIndex(epicRootFor(projectRoot, feature.epic_path), feature.slug);
249
+ if (!fs.existsSync(index))
250
+ throw new AppError("not_found", "feature link/update must reference an existing feature", 1, { feature: relativeMemory(index) });
251
+ remember(index);
252
+ fs.writeFileSync(index, linkProtocols(fs.readFileSync(index, "utf8"), protocolIds));
253
+ changed.add(relativeMemory(index));
254
+ return index;
255
+ }
256
+ if (!feature.epic_path || !feature.title || !feature.slug)
257
+ throw new AppError("validation", "feature.create requires epic_path, title and slug", 2);
258
+ const epicRoot = epicRootFor(projectRoot, feature.epic_path);
259
+ if (!fs.existsSync(path.join(epicRoot, "index.md")))
260
+ throw new AppError("not_found", "feature epic_path must reference an existing epic", 1, { epic_path: feature.epic_path });
261
+ const featuresRoot = path.join(epicRoot, "features");
262
+ fs.mkdirSync(featuresRoot, { recursive: true });
263
+ const number = fs.readdirSync(featuresRoot).filter((name) => /^FT-\d+-/.test(name)).length + 1;
264
+ const id = `FT-${String(number).padStart(3, "0")}-${slug(feature.slug)}`;
265
+ const root = path.join(featuresRoot, id);
266
+ if (fs.existsSync(root))
267
+ throw new AppError("conflict", "Feature already exists", 1, { id });
268
+ fs.mkdirSync(root);
269
+ created.push(root);
270
+ const index = path.join(root, "index.md");
271
+ const summary = feature.summary?.trim() || feature.title;
272
+ fs.writeFileSync(index, `---\nfile: '${relativeMemory(index)}'\ndescription: '${yaml(summary)}'\npurpose: 'Records the user-facing feature and its protocol links.'\nversion: '0.1.0'\ndate: '${today()}'\nstatus: 'ACTIVE'\nparent: '${relativeMemory(path.join(epicRoot, "index.md"))}'\nrelated_protocols: [${protocolIds.map((id) => `'${id}'`).join(", ")}]\n---\n\n# ${id} — ${feature.title}\n\n${summary}\n`);
273
+ changed.add(relativeMemory(index));
274
+ const epicIndex = path.join(epicRoot, "index.md");
275
+ remember(epicIndex);
276
+ appendIndexEntry(epicIndex, relativeMemory(index), `${id} — ${feature.title}`);
277
+ changed.add(relativeMemory(epicIndex));
278
+ return index;
279
+ }
280
+ function copyRawIntake(runHome, protocolRoot) {
281
+ const source = path.join(runHome, "intake", "discussion.md");
282
+ if (!fs.existsSync(source))
283
+ return null;
284
+ const target = path.join(protocolRoot, "intake", "user-input.md");
285
+ fs.mkdirSync(path.dirname(target), { recursive: true });
286
+ const body = fs.readFileSync(source, "utf8");
287
+ fs.writeFileSync(target, ["---", `file: '${relativeMemory(target)}'`, "description: 'Raw user input retained for protocol traceability.'", "purpose: 'Preserves the original intake used to form this protocol.'", "version: '0.1.0'", `date: '${today()}'`, "status: 'ACTIVE'", "c4_level: 'product'", `parent: '${relativeMemory(path.join(protocolRoot, "index.md"))}'`, "tags: [intake]", "---", "", body.trimEnd(), ""].join("\n"));
288
+ return target;
289
+ }
290
+ function today() { return new Date().toISOString().slice(0, 10); }
291
+ function appendIndexEntry(indexPath, childPath, label) {
292
+ if (!fs.existsSync(indexPath))
293
+ throw new AppError("not_found", "Owning Memory Bank index is missing", 1, { index: indexPath });
294
+ const existing = fs.readFileSync(indexPath, "utf8");
295
+ const linkPath = path.posix.relative(path.posix.dirname(relativeMemory(indexPath)), childPath);
296
+ if (existing.includes(`](${linkPath})`) || existing.includes(childPath))
297
+ return;
298
+ let next = existing;
299
+ if (/^children:\s*\[\s*\]\s*$/m.test(next)) {
300
+ next = next.replace(/^children:\s*\[\s*\]\s*$/m, `children:\n - ${childPath}`);
301
+ }
302
+ else if (/^children:\s*$/m.test(next)) {
303
+ next = next.replace(/^(children:\s*\n(?:\s+- .*\n)*)/m, `$1 - ${childPath}\n`);
304
+ }
305
+ else if (next.startsWith("---\n")) {
306
+ next = next.replace(/^---\n([\s\S]*?)\n---/, `---\n$1\nchildren:\n - ${childPath}\n---`);
307
+ }
308
+ fs.writeFileSync(indexPath, `${next.trimEnd()}\n\n- [${label}](${linkPath})\n`);
309
+ }
310
+ function epicRootFor(projectRoot, value) { const resolved = path.resolve(projectRoot, value); const relative = path.relative(projectRoot, resolved); if (relative.startsWith("..") || path.isAbsolute(relative))
311
+ throw new AppError("path_escape", "epic_path must stay inside the project", 2, { epic_path: value }); return path.basename(resolved) === "index.md" ? path.dirname(resolved) : resolved; }
312
+ function readResult(file) {
313
+ if (!fs.existsSync(file))
314
+ throw new AppError("not_found", "PROTOCOLIZE result is missing", 1, { file });
315
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
316
+ return value;
317
+ }
318
+ function acceptedSpecifyObligations(context, projectRoot, runId, specifyPath) {
319
+ if (!fs.existsSync(specifyPath))
320
+ throw new AppError("not_found", "PROTOCOLIZE requires accepted specify.json", 1, { path: specifyPath });
321
+ validateSchema({ schemaName: "vnext-specify", file: specifyPath, projectRoot, ddFlowHome: context.ddFlowHome, runId });
322
+ const specify = readVnextSpecifyResult(specifyPath);
323
+ return [
324
+ ...specify.requirements.map((obligation) => ({ ...obligation, kind: "requirement" })),
325
+ ...specify.acceptance_criteria.map((obligation) => ({ ...obligation, kind: "acceptance" }))
326
+ ];
327
+ }
328
+ /** SPECIFY is historical input; only a HITL-backed protocol amendment may alter its effective wording. */
329
+ function effectiveObligations(result, accepted) {
330
+ const byId = new Map(accepted.map((obligation) => [obligation.id, obligation]));
331
+ const amended = new Set();
332
+ for (const amendment of result.obligation_amendments ?? []) {
333
+ const target = byId.get(amendment.target_id);
334
+ if (!target)
335
+ throw new AppError("validation", "Obligation amendment names an obligation absent from SPECIFY", 2, { target_id: amendment.target_id });
336
+ if (amended.has(amendment.target_id))
337
+ throw new AppError("validation", "An obligation may be amended only once", 2, { target_id: amendment.target_id });
338
+ if (!/^HITL-[A-Za-z0-9._-]+$/.test(amendment.hitl_ref))
339
+ throw new AppError("validation", "Obligation amendment needs a HITL reference", 2, { hitl_ref: amendment.hitl_ref });
340
+ byId.set(target.id, { ...target, statement: amendment.replacement });
341
+ amended.add(target.id);
342
+ }
343
+ return accepted.map((obligation) => byId.get(obligation.id));
344
+ }
345
+ function validateDelivery(projectRoot, result, obligations) {
346
+ const members = result.delivery.members;
347
+ const isSet = result.delivery.kind === "protocol_set";
348
+ if (!members.length || (!isSet && members.length !== 1) || (isSet && members.length < 2))
349
+ throw new AppError("validation", "Delivery kind and members must agree", 2);
350
+ if (!isSet && !["single_executable_protocol", "single_compact_protocol"].includes(result.scope_sizing_verdict))
351
+ throw new AppError("validation", "A single protocol requires a single-protocol scope verdict", 2);
352
+ if (isSet && result.scope_sizing_verdict !== "specification_with_slices_required")
353
+ throw new AppError("validation", "A PSET requires specification_with_slices_required", 2);
354
+ if (isSet && (!result.delivery.decomposition_rationale?.trim() || !result.delivery.pset))
355
+ throw new AppError("validation", "A PSET requires decomposition rationale and execution topology", 2);
356
+ const keys = new Set();
357
+ for (const member of members) {
358
+ if (![member.key, member.title, member.slug, member.goal, member.role, member.primary_acceptance?.actor, member.primary_acceptance?.action, member.primary_acceptance?.expected_result, member.primary_acceptance?.verification].every((x) => typeof x === "string" && x.trim()))
359
+ throw new AppError("validation", "Every protocol member needs key, role, title, slug, goal and primary acceptance", 2);
360
+ if (keys.has(member.key))
361
+ throw new AppError("validation", "Protocol member keys must be unique", 2, { key: member.key });
362
+ keys.add(member.key);
363
+ if (!Array.isArray(member.in_scope) || !Array.isArray(member.out_of_scope) || !Array.isArray(member.blocked_by))
364
+ throw new AppError("validation", "Protocol member scope and blocked_by must be arrays", 2);
365
+ for (const blocked of member.blocked_by)
366
+ if (!keys.has(blocked) && !members.some((candidate) => candidate.key === blocked))
367
+ throw new AppError("validation", "blocked_by must name a member key", 2, { member: member.key, blocked_by: blocked });
368
+ }
369
+ if (!obligations.length)
370
+ throw new AppError("validation", "SPECIFY has no accepted obligations", 2);
371
+ if (!result.obligation_ownership.length)
372
+ throw new AppError("validation", "A protocolized delivery needs obligation ownership", 2);
373
+ const covered = new Set();
374
+ const membersWithAcceptance = new Set();
375
+ for (const ownership of result.obligation_ownership) {
376
+ if (!obligations.some((obligation) => obligation.id === ownership.obligation_id))
377
+ throw new AppError("validation", "Obligation ownership names an obligation absent from SPECIFY", 2, { obligation_id: ownership.obligation_id });
378
+ if (covered.has(ownership.obligation_id))
379
+ throw new AppError("validation", "Each SPECIFY obligation needs one ownership entry", 2, { obligation_id: ownership.obligation_id });
380
+ covered.add(ownership.obligation_id);
381
+ for (const key of ownership.member_keys) {
382
+ if (!keys.has(key))
383
+ throw new AppError("validation", "Obligation ownership names an unknown member", 2, { key });
384
+ if (ownership.obligation_id.startsWith("AC-"))
385
+ membersWithAcceptance.add(key);
386
+ }
387
+ }
388
+ for (const obligation of obligations)
389
+ if (!covered.has(obligation.id))
390
+ throw new AppError("validation", "Every SPECIFY obligation must be owned by a protocol member", 2, { obligation_id: obligation.id });
391
+ for (const key of keys)
392
+ if (!membersWithAcceptance.has(key))
393
+ throw new AppError("validation", "Every protocol member must own at least one acceptance criterion", 2, { key });
394
+ validateDurableLinks(projectRoot, result.durable_links);
395
+ if (isSet)
396
+ validateTopology(result);
397
+ }
398
+ function validateDurableLinks(projectRoot, links) {
399
+ if (typeof links.preserve_raw_intake !== "boolean")
400
+ throw new AppError("validation", "durable_links.preserve_raw_intake must be boolean", 2);
401
+ for (const [kind, values] of Object.entries(links)) {
402
+ if (kind === "preserve_raw_intake")
403
+ continue;
404
+ if (!Array.isArray(values))
405
+ throw new AppError("validation", `durable_links.${kind} must be an array`, 2);
406
+ for (const value of values) {
407
+ const resolved = path.resolve(projectRoot, value);
408
+ inside(path.join(projectRoot, ".memory-bank"), resolved);
409
+ if (!fs.existsSync(resolved))
410
+ throw new AppError("not_found", "Durable link must point to an existing Memory Bank document", 1, { kind, path: value });
411
+ }
412
+ }
413
+ }
414
+ function validateTopology(result) {
415
+ const topology = result.delivery.pset;
416
+ if (!Array.isArray(topology.dependency_graph) || !Array.isArray(topology.feasible_modes) || !Array.isArray(topology.excluded_modes) || topology.confirmation_gate !== "before_first_code")
417
+ throw new AppError("validation", "PSET topology must define graph, modes and before_first_code confirmation", 2);
418
+ if (!["shared_serial_bundle", "isolated_parallel", "isolated_dependency_waves"].includes(topology.selected))
419
+ throw new AppError("validation", "PSET selected execution mode is invalid", 2);
420
+ const keys = new Set(result.delivery.members.map((member) => member.key));
421
+ if (topology.workspace_owner !== "not_applicable" && !keys.has(topology.workspace_owner))
422
+ throw new AppError("validation", "PSET workspace_owner must be a member key or not_applicable", 2);
423
+ }
424
+ function resultTemplate(obligations = []) {
425
+ return {
426
+ schema_id: "dd-flow/vnext-protocolize-result@3",
427
+ outcome: "protocolized",
428
+ scope_sizing_verdict: "single_executable_protocol",
429
+ delivery: { kind: "single_protocol", title: "", slug: "", members: [{ key: "primary", title: "", slug: "", goal: "", role: "Delivers the request end to end.", in_scope: [], out_of_scope: [], primary_acceptance: { actor: "", initial_state: "", action: "", expected_result: "", verification: "", proof_limits: "" }, blocked_by: [] }] },
430
+ obligation_ownership: obligations.map((obligation) => ({ obligation_id: obligation.id, member_keys: ["primary"] })),
431
+ obligation_amendments: [],
432
+ durable_links: { preserve_raw_intake: false, related_epics: [], related_features: [], related_specs: [], related_adrs: [], related_scenarios: [] },
433
+ feature: { action: "not_applicable", epic_path: "", title: "", slug: "", summary: "", reason: "" }
434
+ };
435
+ }
436
+ function renderPrompt(input) {
437
+ const obligationList = input.obligations.map((obligation) => `- ${obligation.id}: ${obligation.statement}`).join("\n");
438
+ return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(), null, 2)}\n\`\`\``, "Allocate every supplied R-* and AC-* exactly once in obligation_ownership. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.flow} stage finish ${input.runId} --stage protocolize --project-root ${JSON.stringify(input.projectRoot)} --result-file ${JSON.stringify(input.resultPath)} --json`, "</output_contract>", ""].join("\n");
439
+ }
440
+ function provisionWorkspaceForProtocolize(context, projectRoot, projectId, run, stageRoot) {
441
+ const receiptPath = path.join(stageRoot, "workspace-route.json");
442
+ if (fs.existsSync(receiptPath))
443
+ return readWorkspaceRouteReceipt(stageRoot).policy;
444
+ const config = loadVnextWorkspacePolicy(projectRoot);
445
+ const stableFacts = gitFacts(projectRoot);
446
+ if (config.workspace.route === "integration_branch_direct") {
447
+ const policy = { route: "integration_branch_direct", integration_branch: config.workspace.integration_branch, base_ref: stableFacts.head, feature_branch: null, worktree_path: null, reason: "Project workspace configuration explicitly selects the integration checkout.", provision_stage: "protocolize_start", stage_session_mode: stageSessionMode(context, run), provisioned: true, bootstrap_status: "not_applicable" };
448
+ writeJson(receiptPath, { schema_id: "dd-flow/vnext-workspace-route@1", policy, created: null, bootstrap: null });
449
+ return policy;
450
+ }
451
+ if (stableFacts.status === "unavailable" || !stableFacts.head || stableFacts.branch !== config.workspace.integration_branch) {
452
+ throw new AppError("workspace_route_invalid", "Configured feature worktree requires a clean, known integration checkout", 1, { expected_integration_branch: config.workspace.integration_branch, actual: stableFacts, project_root: projectRoot });
453
+ }
454
+ const branch = vnextRunBranch(config, run);
455
+ const holder = path.basename(projectRoot).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
456
+ const worktreePath = projectFeatureWorktreePath(context.ddFlowHome, projectId, `RUN-${run.short_id}`, holder);
457
+ const created = createManagedWorktree(context, { projectRoot, branch, base: stableFacts.head, path: worktreePath });
458
+ const bootstrap = bootstrapManagedWorktree(context, { projectRoot, worktreePath: created.worktreePath });
459
+ rebindFlowRunWorkspace(context, { projectRoot, runId: run.id, workspaceRoot: created.worktreePath, reason: "PROTOCOLIZE start provisioned the configured feature-worktree route" });
460
+ const policy = { route: "feature_worktree", integration_branch: config.workspace.integration_branch, base_ref: stableFacts.head, feature_branch: branch, worktree_path: created.worktreePath, reason: "Project workspace configuration requires a managed feature worktree before durable protocol documents exist.", provision_stage: "protocolize_start", stage_session_mode: stageSessionMode(context, run), provisioned: true, bootstrap_status: bootstrap.status };
461
+ writeJson(receiptPath, { schema_id: "dd-flow/vnext-workspace-route@1", policy, created, bootstrap });
462
+ return policy;
463
+ }
464
+ function readWorkspaceRouteReceipt(root) {
465
+ const file = path.join(root, "workspace-route.json");
466
+ try {
467
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
468
+ if (value.schema_id !== "dd-flow/vnext-workspace-route@1" || !value.policy || !value.policy.provisioned)
469
+ throw new Error("invalid receipt");
470
+ return value;
471
+ }
472
+ catch (error) {
473
+ throw new AppError("workspace_route_missing", "PROTOCOLIZE workspace route receipt is missing or invalid", 1, { file, cause: String(error) });
474
+ }
475
+ }
476
+ function requiredPolicyValue(value, label) { if (!value)
477
+ throw new AppError("runtime_missing", `PROTOCOLIZE Git policy is missing ${label}`, 1); return value; }
478
+ function allocateProtocolId(context, projectRoot, name) { return previewNextEntityId(context, { projectRoot, type: "protocol", slug: name }).entity.id; }
479
+ function allocatePsetId(projectRoot, value) { const root = path.join(projectRoot, ".memory-bank", "protocol", "_set"); const used = fs.existsSync(root) ? fs.readdirSync(root).map((file) => /^PSET-(\d+)-/.exec(file)?.[1]).filter((item) => Boolean(item)).map(Number) : []; return `PSET-${String(Math.max(0, ...used) + 1).padStart(3, "0")}-${slug(value)}`; }
480
+ function catalog(projectRoot) {
481
+ const memory = path.join(projectRoot, ".memory-bank");
482
+ const candidates = [
483
+ path.join(memory, "epics", "index.md"),
484
+ path.join(memory, "specs", "index.md"),
485
+ path.join(memory, "adr", "index.md"),
486
+ path.join(memory, "scenarios", "index.md"),
487
+ ...(fs.existsSync(path.join(memory, "epics")) ? fs.readdirSync(path.join(memory, "epics")).filter((name) => name.startsWith("EP-")).map((name) => path.join(memory, "epics", name, "index.md")) : [])
488
+ ];
489
+ return candidates.filter((file) => fs.existsSync(file)).map((file) => `${relativeMemory(file)}\n${fs.readFileSync(file, "utf8").trim()}`).sort();
490
+ }
491
+ function featureIndex(epicRoot, featureSlug) {
492
+ const direct = path.join(epicRoot, "features", featureSlug, "index.md");
493
+ if (fs.existsSync(direct))
494
+ return direct;
495
+ const features = path.join(epicRoot, "features");
496
+ const match = fs.existsSync(features) ? fs.readdirSync(features).find((name) => name === featureSlug || name.endsWith(`-${featureSlug}`)) : undefined;
497
+ return path.join(features, match ?? featureSlug, "index.md");
498
+ }
499
+ function requireWork(context, id) { const work = context.db.get("SELECT * FROM works WHERE work_id = ?", [id]); if (!work)
500
+ throw new AppError("not_found", "Work is not registered", 1, { work_id: id }); return work; }
501
+ function requireRun(context, projectRoot, id) { const project = requireProjectByRoot(context, projectRoot); const run = context.db.get("SELECT id, short_id, slug, project_id, workspace_root, run_home_path FROM runs WHERE project_id = ? AND id = ?", [project.id, id]); if (!run)
502
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: id }); return run; }
503
+ function requireRunHome(run) { if (!run.run_home_path)
504
+ throw new AppError("runtime_missing", "RUN has no portable workspace", 1); return run.run_home_path; }
505
+ function inside(root, file) { const relative = path.relative(root, file); if (relative.startsWith("..") || path.isAbsolute(relative))
506
+ throw new AppError("path_escape", "Result must be inside the protocolize workspace", 2); }
507
+ function writeJson(file, value) { const tmp = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(tmp, file); }
508
+ function slug(value) { const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); if (!normalized)
509
+ throw new AppError("validation", "slug must contain ASCII letters or digits", 2); return normalized; }
510
+ function yaml(value) { return value.replace(/'/g, "''"); }
511
+ function yamlList(values) { return `[${values.map((value) => `'${yaml(value)}'`).join(", ")}]`; }
512
+ function unique(values) { return [...new Set(values)]; }
513
+ function linkProtocols(markdown, protocolIds) {
514
+ const line = /^related_protocols:\s*\[(.*)\]\s*$/m;
515
+ const existing = line.exec(markdown);
516
+ const ids = existing ? [...(existing[1] ?? "").matchAll(/'([^']+)'/g)].map((match) => match[1]) : [];
517
+ const next = `related_protocols: ${yamlList(unique([...ids, ...protocolIds]))}`;
518
+ if (existing)
519
+ return markdown.replace(line, next);
520
+ return markdown.replace(/^---\n([\s\S]*?)\n---/, `---\n$1\n${next}\n---`);
521
+ }
522
+ function relativeMemory(file) { const marker = `${path.sep}.memory-bank${path.sep}`; const at = file.indexOf(marker); return at >= 0 ? `.memory-bank/${file.slice(at + marker.length)}` : file; }
523
+ function stageReport(context, run, resultFile, protocolIds, changedFiles, sessionId, now, policy) {
524
+ const startedAt = stageStartedAt(requireRunHome(run), now);
525
+ return { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage, generated_at: now, verdict: "done", semantic: { result: `Materialized ${protocolIds.length} executable protocol${protocolIds.length === 1 ? "" : "s"}.`, acceptance: protocolIds, changed_files: changedFiles, checks: ["result contract", "stage-report schema"], evidence: [resultFile], next_action: "start_plan" }, mechanical: { started_at: startedAt, finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(startedAt)), git: gitFacts(run.workspace_root), ...(policy ? { workspace_route: policy } : {}), observability: "query dd-flow stat after all RUN sessions have settled" }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html", summary: "stage-report.md" }, validation: { permission_scope: "known_targets_only", memory_bank_scope: "changed_files_and_links_only", status: "passed" } };
526
+ }
527
+ function stageStartedAt(runHome, fallback) {
528
+ try {
529
+ const index = JSON.parse(fs.readFileSync(path.join(runHome, "run.json"), "utf8"));
530
+ return index.stage_runs?.find((item) => item.stage === stage)?.started_at ?? fallback;
531
+ }
532
+ catch {
533
+ return fallback;
534
+ }
535
+ }
536
+ function completeWorkSession(context, workId, resultFile, now) {
537
+ const workSession = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [workId]);
538
+ if (!workSession)
539
+ return null;
540
+ context.db.run("UPDATE work_sessions SET status = 'completed', result_path = ?, updated_at = ?, completed_at = ? WHERE id = ?", [resultFile, now, now, workSession.id]);
541
+ return { ...workSession, status: "completed", result_path: resultFile, updated_at: now, completed_at: now };
542
+ }