@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.8

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 (41) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +3 -3
  4. package/dist/cli/run-cli.js +127 -8
  5. package/dist/runtime/context.js +3 -1
  6. package/dist/schemas/code-work-batch.schema.json +3 -3
  7. package/dist/schemas/harness-config.schema.json +23 -0
  8. package/dist/schemas/plan-review-decision.schema.json +1 -1
  9. package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
  10. package/dist/services/cleanup.js +18 -8
  11. package/dist/services/code-checks.js +194 -44
  12. package/dist/services/engines.js +4 -4
  13. package/dist/services/eval-snapshots.js +10 -5
  14. package/dist/services/harness-config.js +66 -0
  15. package/dist/services/hooks.js +25 -22
  16. package/dist/services/lanes.js +1 -0
  17. package/dist/services/managed-processes.js +169 -0
  18. package/dist/services/merge-server.js +8 -2
  19. package/dist/services/portable-refs.js +57 -0
  20. package/dist/services/prompts.js +4 -2
  21. package/dist/services/run-engine-bindings.js +19 -61
  22. package/dist/services/run-projection.js +10 -8
  23. package/dist/services/runs.js +71 -9
  24. package/dist/services/schema-validation.js +11 -11
  25. package/dist/services/session-identity.js +19 -0
  26. package/dist/services/sessions.js +26 -11
  27. package/dist/services/stage-lifecycle.js +15 -8
  28. package/dist/services/stage-pause.js +35 -20
  29. package/dist/services/usage.js +74 -42
  30. package/dist/services/vnext-code-review.js +73 -33
  31. package/dist/services/vnext-code.js +98 -34
  32. package/dist/services/vnext-fanout.js +4 -4
  33. package/dist/services/vnext-merge.js +144 -65
  34. package/dist/services/vnext-plan-review.js +36 -18
  35. package/dist/services/vnext-plan.js +66 -18
  36. package/dist/services/vnext-protocolize.js +6 -6
  37. package/dist/services/vnext-specify.js +6 -6
  38. package/dist/services/work-registry.js +137 -34
  39. package/dist/storage/database.js +128 -2
  40. package/package.json +1 -1
  41. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
5
+ import { effectiveCheckDeclarations, readCodeCheckProfile, validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
6
6
  import { requireProjectByRoot } from "./projects.js";
7
7
  import { resolveProjectRoot } from "../storage/paths.js";
8
8
  import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, getFlowRunVariables, gitFacts } from "./runs.js";
@@ -29,13 +29,18 @@ export function startVnextPlan(context, input) {
29
29
  const home = requireHome(run);
30
30
  assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage: "plan", projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
31
31
  const workspaceRoute = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan" });
32
- const root = path.join(home, vnextStageDirectory("plan"));
33
- fs.mkdirSync(root, { recursive: true });
34
32
  const protocols = protocolIds(home);
35
33
  if (!protocols.length)
36
34
  throw new AppError("not_found", "PLAN requires accepted PROTOCOLIZE protocols", 1);
35
+ // Static inputs are checked before PLAN creates a Work or materializes a
36
+ // draft. A rejected start is therefore side-effect free and safe to retry.
37
+ const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan.md"));
38
+ assertProtocolWorkspace(run.workspace_root, protocols);
39
+ const { profile: codeCheckProfile } = readCodeCheckProfile(run.workspace_root);
37
40
  if (context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND task = ? AND status = 'running'", [project.id, run.id, planTask]))
38
41
  throw new AppError("invalid_work_state", "PLAN already has a running Work", 1, { run_id: run.id });
42
+ const root = path.join(home, vnextStageDirectory("plan"));
43
+ fs.mkdirSync(root, { recursive: true });
39
44
  const now = context.now();
40
45
  const rootWork = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [project.id, run.id]);
41
46
  if (!rootWork)
@@ -56,11 +61,9 @@ export function startVnextPlan(context, input) {
56
61
  context.db.exec("ROLLBACK");
57
62
  throw error;
58
63
  }
59
- const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan.md"));
60
64
  // A Desktop task may start above the materialized repository. Lifecycle
61
65
  // prompts therefore hand agents write targets as absolute paths: relative
62
66
  // `.memory-bank/...` paths would otherwise silently land in the parent cwd.
63
- assertProtocolWorkspace(run.workspace_root, protocols);
64
67
  const planPaths = protocols.map((id) => path.join(run.workspace_root, ".memory-bank", "protocol", id, "plan.json"));
65
68
  const mapPaths = protocols.map((id) => `${path.join(root, id, "aspect-map.json")}`);
66
69
  const owned = protocolOwnership(home, protocols);
@@ -71,17 +74,24 @@ export function startVnextPlan(context, input) {
71
74
  const pauseCommand = stagePauseCommand(context, { runId: run.id, stage: "plan", workId: planWorkId, projectRoot });
72
75
  const pauseCommandTemplate = stagePauseCommandTemplate(pauseCommand);
73
76
  const validationCommands = protocols.flatMap((_, index) => [
74
- `${flowCommand(context)} schema validate --schema vnext-protocol-plan --file ${JSON.stringify(planPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --json`,
75
- `${flowCommand(context)} schema validate --schema plan-aspect-map --file ${JSON.stringify(mapPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --json`
77
+ `${flowCommand(context)} schema validate --schema vnext-protocol-plan --file ${JSON.stringify(planPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --run ${run.id} --json`,
78
+ `${flowCommand(context)} schema validate --schema plan-aspect-map --file ${JSON.stringify(mapPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --run ${run.id} --json`
76
79
  ]);
77
80
  const runVariables = getFlowRunVariables(context, { projectRoot, runId: run.id });
78
81
  const measuredCapacity = runVariables.variables[subagentCapacityKey];
82
+ const mergeRequired = runEndsAtMerge(context, projectRoot, run.id);
79
83
  const capacityContext = typeof measuredCapacity === "number" && Number.isInteger(measuredCapacity) && measuredCapacity >= 0
80
84
  ? `- The measured reviewer capacity is ${measuredCapacity}. This is a runtime fact for later PLAN-REVIEW dispatch; do not repeat the probe or invent a different value.`
81
85
  : "- Reviewer capacity is not measured yet. PLAN must not probe or launch reviewers; PLAN-REVIEW will measure it once if review is enabled.";
82
86
  const reviewGroupingRule = "Group only semantically compatible applicable aspects, preserving real trust, irreversible, high-risk and hard-dependency boundaries. Prefer the fewest groups that retain independent review value, normally one review wave. Put two or three compatible aspects in a group; do not create one group per aspect merely for convenience. A later PLAN-REVIEW dispatch measures current capacity once and schedules these semantic groups into waves; do not invent a capacity value here.";
83
87
  const checkProfile = path.join(run.workspace_root, ".memory-bank", "spec", "engineering", "code-check-profile.json");
84
- const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an honestly limited external/manual proof. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, 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", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
88
+ const policyMergeAliases = codeCheckProfile?.mandatory_by_gate.merge ?? [];
89
+ const mergeContract = mergeRequired
90
+ ? ["<merge_gate_contract>", ...(policyMergeAliases.length
91
+ ? [`This RUN must reach MERGE. Project policy already supplies the mandatory merge gate${policyMergeAliases.length === 1 ? "" : "s"}: ${policyMergeAliases.join(", ")}. Do not duplicate them in semantic checks[]. Add another merge check only when the task genuinely needs additional evidence.`]
92
+ : ["This RUN must reach MERGE and project policy supplies no merge gate. Select at least one real top-level checks[] entry with run_at: merge. It may use an existing project alias or a planned alias materialised by a named P* provider Work. This is a planning obligation: do not defer it to CODE-REVIEW or MERGE."]), "The CLI validates the effective merge gate but never invents one or migrates an incompatible project policy.", "</merge_gate_contract>", ""]
93
+ : [];
94
+ const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), ...mergeContract, "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an honestly limited external/manual proof. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, 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", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
85
95
  const artifactMaterialization = { status: "materialized", completeness: "partially_filled", plan_paths: planPaths, aspect_map_paths: mapPaths, cli_owned_plan_fields: ["schema_id", "plan_id", "protocol_id", "revision", "source_refs"], cli_owned_aspect_map_fields: ["schema_id", "protocol_id", "plan_id", "plan_revision", "catalog_ref", "aspects[].aspect_id"], validation_commands: validationCommands };
86
96
  const promptPath = path.join(root, "stage-prompt.md");
87
97
  fs.writeFileSync(promptPath, prompt);
@@ -126,7 +136,7 @@ export function finishVnextPlan(context, input) {
126
136
  const reviewCommand = `${flowCommand(context)} stage start ${run.id} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --json`;
127
137
  const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage: "plan", generated_at: now, verdict: "done", semantic: { result: `Accepted ${protocols.length} executable PLAN artifact${protocols.length === 1 ? "" : "s"}.`, acceptance: protocols, changed_files: [...planFiles.map((file) => path.relative(projectRoot, file)), ...mapFiles.map((file) => runRef(run.id, home, file)), runRef(run.id, home, batch)], checks: ["protocol-plan schema", "aspect-map schema", "cross-artifact references", "generated CODE batch"], evidence: [runRef(run.id, home, path.join(root, "stage-report.json"))], next_action: "start_plan_review", plans: planFiles.map((file) => path.relative(projectRoot, file)), aspect_maps: mapFiles.map((file) => runRef(run.id, home, file)), code_work_batch: runRef(run.id, home, batch), batch_checksum: batchChecksum }, mechanical: { started_at: stageStartedAt(home, now), finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(stageStartedAt(home, now))), git: gitFacts(run.workspace_root), session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, next_command: reviewCommand }, 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" } };
128
138
  const reportJson = writeStageReport(root, report).json;
129
- validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
139
+ validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
130
140
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage: "plan", status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
131
141
  advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "planned", nextAction: "start_plan_review" });
132
142
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_accepted", work_id: work.work_id, protocols, id: workSession.id, next_stage: "plan-review" });
@@ -149,7 +159,7 @@ export function validateVnextPlanArtifacts(context, input) {
149
159
  for (const [index, file] of planFiles.entries()) {
150
160
  const protocolId = input.protocols[index];
151
161
  try {
152
- validateSchema({ schemaName: "vnext-protocol-plan", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId });
162
+ validateSchema({ schemaName: "vnext-protocol-plan", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
153
163
  const value = readPlan(file);
154
164
  assertPlanIdentity(value, planIdentity(input.home, input.runId, protocolId, ownership.get(protocolId) ?? []), file);
155
165
  validatePlanSemantics(file, new Set(ownership.get(protocolId) ?? []), obligations);
@@ -170,20 +180,29 @@ export function validateVnextPlanArtifacts(context, input) {
170
180
  for (const file of mapFiles) {
171
181
  try {
172
182
  normalizeAspectMapRefs(file, workspaceRoot, input.home, input.runId);
173
- validateSchema({ schemaName: "plan-aspect-map", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome });
183
+ validateSchema({ schemaName: "plan-aspect-map", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
174
184
  validateAspectMap(file, input.protocols, workspaceRoot);
175
185
  }
176
186
  catch (error) {
177
187
  failures.push(validationFailure(file, error));
178
188
  }
179
189
  }
190
+ if (!failures.length) {
191
+ try {
192
+ validatePsetCheckIdentity(plans);
193
+ validateRequiredMergeGate(context, input, plans);
194
+ }
195
+ catch (error) {
196
+ failures.push(validationFailure(batch, error));
197
+ }
198
+ }
180
199
  if (!failures.length) {
181
200
  const temporaryBatch = `${batch}.tmp-${crypto.randomUUID()}`;
182
201
  try {
183
202
  const projection = projectCodeWorkBatch({ home: input.home, workspaceRoot, runId: input.runId, plans, protocols: input.protocols, ...(frozenDocumentBaselines ? { frozenDocumentBaselines } : {}) });
184
203
  validateProjectedPaths(projection, workspaceRoot, input.home, input.runId);
185
204
  fs.writeFileSync(temporaryBatch, `${JSON.stringify(projection, null, 2)}\n`);
186
- validateSchema({ schemaName: "code-work-batch", file: temporaryBatch, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId });
205
+ validateSchema({ schemaName: "code-work-batch", file: temporaryBatch, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
187
206
  validateWorkBatchFile(temporaryBatch);
188
207
  if (input.publishBatch !== false) {
189
208
  fs.renameSync(temporaryBatch, batch);
@@ -212,10 +231,10 @@ export function validateVnextCodeHandoff(context, input) {
212
231
  const planTask = "Produce accepted plan.json and aspect-map.json artifacts.";
213
232
  function planExample(protocolId) { return { schema_id: "dd-flow/protocol-plan@6", plan_id: "PLAN-001", protocol_id: protocolId, revision: 1, title: "Example", summary: "A compact executable plan.", source_refs: [{ kind: "specify", id: "SPECIFY", path: "run://RUN-000/01-specify/specify.json", requirement_ids: ["R-001", "AC-001"] }], goal: { outcome: "Deliver the accepted behavior.", constraints: ["Keep the accepted scope."], non_goals: [] }, assessment: { scope_breadth: { level: "narrow", surfaces: ["one surface"], reason: "One vertical slice." }, solution_novelty: { level: "established", surfaces: ["existing pattern"], reason: "Reuse project practice." }, solution_uncertainty: { level: "low", surfaces: ["known behavior"], reason: "No open technical question." }, failure_impact: { level: "low", surfaces: ["local feature"], reason: "Reversible local change." }, selected_depth: "compact_plan", depth_trigger: "none" }, decisions: [], document_updates: [], checks: [{ id: "CHK-P1-TEST", command: "pnpm test", purpose: "Proves the changed behavior.", run_at: "work", availability: "available" }], items: [{ id: "P1", title: "Implement behavior", summary: "Change the owning surface.", details: "Follow the accepted requirement and project conventions.", depends_on: [], requirement_refs: ["R-001", "AC-001"], semantic_spine: { user_outcome: "The requested behavior is available.", component_responsibility: "Own the behavior.", must_preserve: ["Existing behavior."], non_goals: [], acceptance_contribution: "Makes AC-001 observable." }, execution_context: { required_read: ["apps/api/src/example.ts"], discovery_boundary: ["Related tests only."], planned_write_areas: ["apps/api/src/"], stop_conditions: ["Stop if accepted scope conflicts with current truth."] }, verification: { check_refs: ["CHK-P1-TEST"] } }], acceptance: [{ criterion_id: "AC-001", plan_item_ids: ["P1"], changed_surfaces: ["apps/api/src/example.ts"], path: "Exercise the accepted user path.", environment: "Local test environment.", fixtures: [], cleanup: "No persistent fixture.", check_refs: ["CHK-P1-TEST"], expected_evidence: ["Focused check passes."], proof_limits: ["Manual production evidence is not claimed."], gate: "work" }] }; }
214
233
  function aspectMapExample(protocolId) { return { $schema: "plan-aspect-map.schema.json", schema_id: "dd-flow/plan-aspect-map@3", protocol_id: protocolId, plan_id: "PLAN-001", plan_revision: 1, catalog_ref: { path: ".memory-bank/dd-flow/mb-sdlc/plan-aspects/aspects" }, routing: { initial_state: "orchestrator_local", selected_route: "local_compact", reason: "One genuinely small semantic unit.", groups: [] }, review_groups: [], aspects: [{ aspect_id: "example_aspect", applicability: "not_applicable", reason: "Only an example; use the supplied real catalog.", planned_artifact_refs: [] }] }; }
215
- function requireRun(context, root, id) { const project = requireProjectByRoot(context, root); const run = context.db.get("SELECT id, project_id, workspace_root, run_home_path FROM runs WHERE project_id = ? AND id = ?", [project.id, id]); if (!run)
234
+ function requireRun(context, root, id) { const project = requireProjectByRoot(context, root); const run = context.db.get("SELECT id, project_id, workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, id]); if (!run)
216
235
  throw new AppError("not_found", "RUN is not registered", 1); return run; }
217
- function requireHome(run) { if (!run.run_home_path)
218
- throw new AppError("runtime_missing", "RUN workspace is unavailable", 1); return run.run_home_path; }
236
+ function requireHome(run) { if (!run.run_root)
237
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
219
238
  function protocolIds(home) {
220
239
  const report = JSON.parse(fs.readFileSync(path.join(home, "02-protocolize", "stage-report.json"), "utf8"));
221
240
  const ids = report.semantic?.acceptance;
@@ -285,7 +304,7 @@ function stageStartedAt(home, fallback) { try {
285
304
  catch {
286
305
  return fallback;
287
306
  } }
288
- function validationFailure(file, error) { return { file, message: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { details: error.details } : {}) }; }
307
+ function validationFailure(file, error) { return { file, message: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { code: error.code, details: error.details } : {}) }; }
289
308
  function acceptedObligations(home) {
290
309
  const file = path.join(home, "01-specify", "specify.json");
291
310
  if (!fs.existsSync(file))
@@ -395,6 +414,7 @@ function projectCodeWorkBatch(input) {
395
414
  // stale as soon as its provider performs its declared work.
396
415
  required_read: [...new Set([
397
416
  ...orientation,
417
+ path.relative(input.workspaceRoot, file).split(path.sep).join("/"),
398
418
  ...item.execution_context.required_read,
399
419
  ...existingDocumentPaths,
400
420
  ...(value.checks.some((check) => check.availability === "planned" && check.provided_by === item.id)
@@ -547,9 +567,14 @@ function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
547
567
  for (const id of acceptance.plan_item_ids ?? [])
548
568
  if (!ids.has(id))
549
569
  throw new AppError("validation", "PLAN acceptance references an unknown item", 2, { file, criterion_id: acceptance.criterion_id, plan_item_id: id });
550
- for (const id of acceptance.check_refs ?? [])
551
- if (!checks.has(id))
570
+ for (const id of acceptance.check_refs ?? []) {
571
+ const check = checks.get(id);
572
+ if (!check)
552
573
  throw new AppError("check_reference_unknown", "PLAN acceptance references an unknown check", 2, { file, criterion_id: acceptance.criterion_id, check_id: id });
574
+ for (const itemId of acceptance.plan_item_ids ?? [])
575
+ if (check.availability === "planned" && check.provided_by !== itemId && !ancestors(itemId).has(check.provided_by))
576
+ throw new AppError("check_consumer_not_ordered_after_provider", "Acceptance may consume a planned check only after its provider", 2, { file, criterion_id: acceptance.criterion_id, item: itemId, check_id: id, provider: check.provided_by });
577
+ }
553
578
  }
554
579
  for (const obligation of ownedRefs)
555
580
  if (!realized.has(obligation))
@@ -558,3 +583,26 @@ function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
558
583
  if (!plan.acceptance.some((acceptance) => acceptance.criterion_id === obligation))
559
584
  throw new AppError("validation", "Every owned AC-* needs an observable PLAN acceptance entry", 2, { file, criterion_id: obligation });
560
585
  }
586
+ function validatePsetCheckIdentity(plans) {
587
+ const owners = new Map();
588
+ for (const plan of plans)
589
+ for (const check of plan.value.checks) {
590
+ const prior = owners.get(check.id);
591
+ if (prior)
592
+ throw new AppError("duplicate_pset_check_id", "PLAN check ids must be unique across the whole PSET", 2, { check_id: check.id, protocols: [prior, plan.protocolId] });
593
+ owners.set(check.id, plan.protocolId);
594
+ }
595
+ }
596
+ function runEndsAtMerge(context, projectRoot, runId) {
597
+ const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
598
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]);
599
+ return JSON.parse(row?.index_json ?? "{}").execution_profile?.settings?.stop_target === "merge_completed";
600
+ }
601
+ function validateRequiredMergeGate(context, input, plans) {
602
+ if (!runEndsAtMerge(context, input.projectRoot, input.runId))
603
+ return;
604
+ const declared = plans.flatMap(({ value }) => value.checks);
605
+ if (effectiveCheckDeclarations(input.workspaceRoot ?? input.projectRoot, declared, ["merge"]).length === 0) {
606
+ throw new AppError("merge_gate_plan_missing", "PLAN for a RUN ending in MERGE must declare at least one semantic or project-policy merge check", 2, { run_id: input.runId });
607
+ }
608
+ }
@@ -135,7 +135,7 @@ export function finishVnextProtocolize(context, input) {
135
135
  const root = path.join(requireRunHome(run), "02-protocolize");
136
136
  const resultFile = path.resolve(input.resultFile);
137
137
  inside(root, resultFile);
138
- validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
138
+ validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
139
139
  const result = readResult(resultFile);
140
140
  if (result.outcome !== "protocolized")
141
141
  throw new AppError("validation", "PROTOCOLIZE may finish only as protocolized; use stage pause for every user question", 2);
@@ -162,7 +162,7 @@ export function finishVnextProtocolize(context, input) {
162
162
  const reportMarkdown = path.join(root, "stage-report.md");
163
163
  const reportHtml = path.join(root, "stage-report.html");
164
164
  writeStageReport(root, report);
165
- validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
165
+ validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
166
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
167
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "protocol_documents_materialized", work_id: work.work_id, protocol_ids: protocolIds, stage });
168
168
  advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
@@ -318,7 +318,7 @@ function readResult(file) {
318
318
  function acceptedSpecifyObligations(context, projectRoot, runId, specifyPath) {
319
319
  if (!fs.existsSync(specifyPath))
320
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 });
321
+ validateSchema({ schemaName: "vnext-specify", file: specifyPath, projectRoot, ddFlowHome: context.ddFlowHome, runId, runRoot: requireRunHome(requireRun(context, projectRoot, runId)) });
322
322
  const specify = readVnextSpecifyResult(specifyPath);
323
323
  return [
324
324
  ...specify.requirements.map((obligation) => ({ ...obligation, kind: "requirement" })),
@@ -498,10 +498,10 @@ function featureIndex(epicRoot, featureSlug) {
498
498
  }
499
499
  function requireWork(context, id) { const work = context.db.get("SELECT * FROM works WHERE work_id = ?", [id]); if (!work)
500
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)
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_root FROM runs WHERE project_id = ? AND id = ?", [project.id, id]); if (!run)
502
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; }
503
+ function requireRunHome(run) { if (!run.run_root)
504
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
505
505
  function inside(root, file) { const relative = path.relative(root, file); if (relative.startsWith("..") || path.isAbsolute(relative))
506
506
  throw new AppError("path_escape", "Result must be inside the protocolize workspace", 2); }
507
507
  function writeJson(file, value) { const tmp = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(tmp, file); }
@@ -141,7 +141,7 @@ export function submitVnextSpecify(context, input) {
141
141
  throw new AppError("not_found", "--result-file must point to an existing file inside the SPECIFY workspace", 1, { result_file: input.resultFile });
142
142
  }
143
143
  try {
144
- validateSchema({ schemaName: "vnext-specify", file: candidateFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
144
+ validateSchema({ schemaName: "vnext-specify", file: candidateFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: runHome });
145
145
  const result = readVnextSpecifyResult(candidateFile);
146
146
  validateObligations(result, candidateFile);
147
147
  const normalizedResult = `${JSON.stringify(result, null, 2)}\n`;
@@ -169,7 +169,7 @@ export function submitVnextSpecify(context, input) {
169
169
  const htmlPath = path.join(stageRoot, "stage-report.html");
170
170
  const report = buildStageReport({ run, work, workSession, outcome, result, resultMarkdown: renderedMarkdown, now, stageRoot, resultFile });
171
171
  writeStageReport(stageRoot, report);
172
- validateSchema({ schemaName: "stage-report", file: reportPath, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
172
+ validateSchema({ schemaName: "stage-report", file: reportPath, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: runHome });
173
173
  completeFlowRunStage(context, {
174
174
  projectRoot,
175
175
  runId: run.id,
@@ -268,7 +268,7 @@ function readIntake(input) {
268
268
  }
269
269
  function requireRun(context, projectRoot, runId) {
270
270
  const project = requireProjectByRoot(context, projectRoot);
271
- const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_home_path FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]);
271
+ const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]);
272
272
  if (!run)
273
273
  throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
274
274
  return run;
@@ -280,9 +280,9 @@ function runStatus(context, projectId, runId) {
280
280
  return run.status;
281
281
  }
282
282
  function requiredRunHome(run) {
283
- if (!run.run_home_path)
284
- throw new AppError("runtime_missing", "RUN has no portable workspace", 1, { run_id: run.id });
285
- return run.run_home_path;
283
+ if (!run.run_root)
284
+ throw new AppError("runtime_missing", "RUN has no portable artifact root", 1, { run_id: run.id });
285
+ return run.run_root;
286
286
  }
287
287
  function requireWork(context, workId) {
288
288
  const work = context.db.get("SELECT * FROM works WHERE work_id = ?", [workId]);