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

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 (153) hide show
  1. package/CHANGELOG.md +561 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +91 -15
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1506 -334
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  11. package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
  12. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  13. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  14. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  15. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  16. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
  19. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  21. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  22. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  23. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  24. package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
  25. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
  26. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  27. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
  28. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  29. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  30. package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  33. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  34. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  35. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  36. package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
  37. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  38. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  39. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  40. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  41. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  42. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  43. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  44. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  45. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  46. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  47. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  48. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  49. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  50. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  51. package/dist/runtime/context.js +3 -1
  52. package/dist/schemas/agent-profile.schema.json +1 -1
  53. package/dist/schemas/code-review-result.schema.json +2 -2
  54. package/dist/schemas/code-work-batch.schema.json +4 -3
  55. package/dist/schemas/code-work-result.schema.json +4 -4
  56. package/dist/schemas/harness-config.schema.json +23 -0
  57. package/dist/schemas/plan-review-decision.schema.json +1 -1
  58. package/dist/schemas/plan-review-result.schema.json +2 -2
  59. package/dist/schemas/run-control-receipt.schema.json +99 -0
  60. package/dist/schemas/run-control-request.schema.json +36 -0
  61. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  62. package/dist/services/canon.js +11 -8
  63. package/dist/services/cleanup.js +75 -21
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +676 -79
  66. package/dist/services/codex-hook-delivery.js +28 -0
  67. package/dist/services/command-context.js +80 -0
  68. package/dist/services/config.js +11 -8
  69. package/dist/services/continuation-outcome.js +20 -0
  70. package/dist/services/controller-fanout.js +134 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +125 -80
  73. package/dist/services/eval-snapshots.js +838 -74
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +110 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +68 -0
  78. package/dist/services/hooks.js +609 -310
  79. package/dist/services/lanes.js +62 -53
  80. package/dist/services/lifecycle-command.js +103 -17
  81. package/dist/services/lifecycle-contract.js +30 -0
  82. package/dist/services/lifecycle-invocations.js +1128 -0
  83. package/dist/services/managed-daemon-binding.js +38 -0
  84. package/dist/services/managed-processes.js +425 -0
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +78 -27
  87. package/dist/services/migrations.js +14 -9
  88. package/dist/services/native-daemon-history.js +49 -0
  89. package/dist/services/native-session-control.js +39 -0
  90. package/dist/services/plan-runtime.js +19 -8
  91. package/dist/services/plans.js +28 -25
  92. package/dist/services/portable-refs.js +57 -0
  93. package/dist/services/projects.js +18 -5
  94. package/dist/services/prompts.js +27 -18
  95. package/dist/services/protocols.js +64 -20
  96. package/dist/services/recovery-observation-budget.js +61 -0
  97. package/dist/services/recovery-snapshot-database.js +107 -0
  98. package/dist/services/repair-intents.js +40 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +363 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +222 -0
  103. package/dist/services/run-controller-capture.js +136 -0
  104. package/dist/services/run-controller-process.js +189 -0
  105. package/dist/services/run-controller-recovery.js +222 -0
  106. package/dist/services/run-controller-state.js +36 -0
  107. package/dist/services/run-controller.js +803 -0
  108. package/dist/services/run-engine-bindings.js +20 -62
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-projection.js +10 -8
  112. package/dist/services/run-recovery-runtime.js +69 -0
  113. package/dist/services/run-recovery.js +343 -0
  114. package/dist/services/runs.js +437 -81
  115. package/dist/services/runtime-budget.js +439 -0
  116. package/dist/services/runtime-command.js +43 -0
  117. package/dist/services/runtime-scope-capture.js +50 -0
  118. package/dist/services/runtime-scope-control.js +450 -0
  119. package/dist/services/runtime-scope-resume.js +565 -0
  120. package/dist/services/runtime-scope-stop.js +99 -0
  121. package/dist/services/runtime-scope-worker.js +243 -0
  122. package/dist/services/runtime-service.js +97 -0
  123. package/dist/services/schema-validation.js +21 -19
  124. package/dist/services/session-identity.js +19 -0
  125. package/dist/services/sessions.js +61 -80
  126. package/dist/services/stage-blocker.js +17 -7
  127. package/dist/services/stage-context.js +44 -18
  128. package/dist/services/stage-lifecycle.js +112 -108
  129. package/dist/services/stage-pause.js +117 -73
  130. package/dist/services/stage-work-graph.js +35 -0
  131. package/dist/services/usage.js +108 -132
  132. package/dist/services/vnext-code-review.js +245 -98
  133. package/dist/services/vnext-code.js +355 -257
  134. package/dist/services/vnext-execution-profile.js +5 -3
  135. package/dist/services/vnext-fanout.js +166 -26
  136. package/dist/services/vnext-merge.js +413 -147
  137. package/dist/services/vnext-plan-review.js +182 -109
  138. package/dist/services/vnext-plan.js +199 -82
  139. package/dist/services/vnext-protocolize.js +133 -60
  140. package/dist/services/vnext-specify.js +94 -71
  141. package/dist/services/work-registry.js +813 -160
  142. package/dist/services/workspace-bootstrap.js +76 -0
  143. package/dist/services/worktrees.js +85 -35
  144. package/dist/shared/entity-references.js +8 -0
  145. package/dist/shared/errors.js +12 -0
  146. package/dist/storage/database.js +569 -16
  147. package/dist/storage/paths.js +17 -4
  148. package/dist/storage/work-references.js +23 -0
  149. package/dist/storage/writer-contract.js +77 -0
  150. package/dist/storage/writer-migration.js +102 -0
  151. package/package.json +24 -13
  152. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
  153. package/tools/repair-paused-run-status.mjs +59 -0
@@ -1,13 +1,16 @@
1
+ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
+ import { prepareJsonSchema, prepareOutputFile } from "../cli/input-preparation.js";
1
3
  import crypto from "node:crypto";
4
+ import { withStageSettlement, prepareFlowRunStageAttachment } from "./runs.js";
2
5
  import fs from "node:fs";
3
6
  import path from "node:path";
4
7
  import { AppError } from "../shared/errors.js";
5
- import { validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
8
+ import { effectiveCheckDeclarations, readCodeCheckProfile, validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
6
9
  import { requireProjectByRoot } from "./projects.js";
7
- import { resolveProjectRoot } from "../storage/paths.js";
10
+ import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
8
11
  import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, getFlowRunVariables, gitFacts } from "./runs.js";
9
12
  import { validateSchema } from "./schema-validation.js";
10
- import { bindRunningWorkSession, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, validateWorkBatchFile } from "./work-registry.js";
13
+ import { bindRunningWorkSession, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, validateWorkBatch } from "./work-registry.js";
11
14
  import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
12
15
  import { assertStageStartHookEvent } from "./hooks.js";
13
16
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
@@ -15,39 +18,63 @@ import { readVnextSpecifyResult } from "./vnext-specify.js";
15
18
  import { nextWorkId } from "./ids.js";
16
19
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
17
20
  import { writeStageReport } from "./stage-report-renderer.js";
18
- import { applyExternalStageContext } from "./stage-context.js";
21
+ import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
19
22
  import { subagentCapacityKey } from "./vnext-fanout.js";
20
23
  export function isVnextPlanRun(context, input) {
21
24
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
22
25
  return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
23
26
  }
24
- export function startVnextPlan(context, input) {
25
- ensureWorkRegistry(context);
27
+ export function prepareVnextPlanStart(context, input) {
26
28
  const projectRoot = resolveProjectRoot(input.projectRoot);
27
29
  const project = requireProjectByRoot(context, projectRoot);
28
30
  const run = requireRun(context, projectRoot, input.runId);
29
31
  const home = requireHome(run);
30
- assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage: "plan", projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
31
32
  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
33
  const protocols = protocolIds(home);
35
34
  if (!protocols.length)
36
- throw new AppError("not_found", "PLAN requires accepted PROTOCOLIZE protocols", 1);
35
+ throw new AppError("runtime_artifact_missing", "PLAN requires accepted PROTOCOLIZE protocols", 1);
36
+ // Static inputs are checked before PLAN creates a Work or materializes a
37
+ // draft. Retained corruption is still an infrastructure failure, not a typo.
38
+ const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan.md"));
39
+ assertProtocolWorkspace(run.workspace_root, protocols);
40
+ const { profile: codeCheckProfile } = readCodeCheckProfile(run.workspace_root);
37
41
  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
42
  throw new AppError("invalid_work_state", "PLAN already has a running Work", 1, { run_id: run.id });
39
- const now = context.now();
43
+ prepareFlowRunStageAttachment(context, { projectRoot, runId: run.id, stage: "plan", dir: "03-plan", status: "running", dataSchemaId: "dd-flow/protocol-plan@6" });
44
+ const root = path.join(home, vnextStageDirectory("plan"));
45
+ if (input.externalContext)
46
+ prepareExternalStageContext({ stageRoot: root, loaded: input.externalContext });
40
47
  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
48
  if (!rootWork)
42
49
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1);
50
+ const planPaths = protocols.map(id => path.join(run.workspace_root, ".memory-bank", "protocol", id, "plan.json"));
51
+ const mapPaths = protocols.map(id => path.join(root, id, "aspect-map.json"));
52
+ const owned = protocolOwnership(home, protocols);
53
+ const identities = protocols.map(protocolId => planIdentity(home, run.id, protocolId, owned.get(protocolId) ?? []));
54
+ const aspects = aspectCatalog(run.workspace_root);
55
+ const runVariables = getFlowRunVariables(context, { projectRoot, runId: run.id });
56
+ const mergeRequired = runEndsAtMerge(context, projectRoot, run.id);
57
+ const git = gitFacts(run.workspace_root);
58
+ for (const file of [...planPaths, ...mapPaths, ...["stage-prompt.md", "work-context.json", "fixture-root.prompt.md"].map(name => path.join(root, name))])
59
+ prepareOutputFile(file);
60
+ return { projectRoot, project, run, home, root, rootWork, workspaceRoute, protocols, template, codeCheckProfile, planPaths, mapPaths, owned, identities, aspects, runVariables, mergeRequired, git };
61
+ }
62
+ export function startVnextPlan(context, input, prepared = prepareVnextPlanStart(context, input)) {
63
+ const { projectRoot, project, run, root, rootWork, workspaceRoute, protocols, template, codeCheckProfile, planPaths, mapPaths, owned, identities, aspects, runVariables, mergeRequired, git, home } = prepared;
64
+ ensureWorkRegistry(context);
65
+ assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage: "plan", projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
66
+ fs.mkdirSync(root, { recursive: true });
67
+ const now = context.now();
43
68
  // Imported stage fixtures have a synthetic running root with no prior agent.
44
69
  // Bind it on the first real stage start so its child PLAN Work has a real parent.
45
70
  if (!context.db.get("SELECT 1 FROM work_sessions WHERE work_id = ? LIMIT 1", [rootWork.work_id])) {
46
71
  bindRunningWorkSession(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, promptPath: path.join(root, "fixture-root.prompt.md") });
47
72
  }
48
- context.db.exec("BEGIN IMMEDIATE");
73
+ context.db.beginWriteTransaction();
49
74
  let planWorkId;
50
75
  try {
76
+ if (context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND task = ? AND status = 'running'", [project.id, run.id, planTask]))
77
+ throw new AppError("invalid_work_state", "PLAN already has a running Work", 1, { run_id: run.id });
51
78
  planWorkId = nextWorkId(context, project.id, "plan");
52
79
  context.db.run(`INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, depends_on_json, status, result, started_at, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, 'reuse_allowed', NULL, '[]', 'running', NULL, ?, ?, ?, NULL)`, [planWorkId, project.id, run.id, rootWork.work_id, planTask, now, now, now]);
53
80
  context.db.exec("COMMIT");
@@ -56,35 +83,36 @@ export function startVnextPlan(context, input) {
56
83
  context.db.exec("ROLLBACK");
57
84
  throw error;
58
85
  }
59
- const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan.md"));
60
86
  // A Desktop task may start above the materialized repository. Lifecycle
61
87
  // prompts therefore hand agents write targets as absolute paths: relative
62
88
  // `.memory-bank/...` paths would otherwise silently land in the parent cwd.
63
- assertProtocolWorkspace(run.workspace_root, protocols);
64
- const planPaths = protocols.map((id) => path.join(run.workspace_root, ".memory-bank", "protocol", id, "plan.json"));
65
- const mapPaths = protocols.map((id) => `${path.join(root, id, "aspect-map.json")}`);
66
- const owned = protocolOwnership(home, protocols);
67
- const identities = protocols.map((protocolId) => planIdentity(home, run.id, protocolId, owned.get(protocolId) ?? []));
68
89
  planPaths.forEach((file, index) => ensurePlanSkeleton(file, protocols[index], identities[index]));
69
- mapPaths.forEach((file, index) => ensureAspectMapSkeleton(file, protocols[index], identities[index], run.workspace_root));
70
- const finishCommand = `${flowCommand(context)} stage finish ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`;
90
+ mapPaths.forEach((file, index) => ensureAspectMapSkeleton(file, protocols[index], identities[index], aspects));
91
+ const finishCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`);
71
92
  const pauseCommand = stagePauseCommand(context, { runId: run.id, stage: "plan", workId: planWorkId, projectRoot });
72
93
  const pauseCommandTemplate = stagePauseCommandTemplate(pauseCommand);
73
94
  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`
95
+ `${flowCommand(context)} schema validate --schema vnext-protocol-plan --file ${JSON.stringify(planPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --run ${run.id} --json`,
96
+ `${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
97
  ]);
77
- const runVariables = getFlowRunVariables(context, { projectRoot, runId: run.id });
78
98
  const measuredCapacity = runVariables.variables[subagentCapacityKey];
79
99
  const capacityContext = typeof measuredCapacity === "number" && Number.isInteger(measuredCapacity) && measuredCapacity >= 0
80
- ? `- 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
- : "- Reviewer capacity is not measured yet. PLAN must not probe or launch reviewers; PLAN-REVIEW will measure it once if review is enabled.";
82
- 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
- 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");
100
+ ? `- The qualified reviewer capacity is ${measuredCapacity}. This is a runtime fact for later PLAN-REVIEW dispatch; do not repeat qualification or invent a different value.`
101
+ : "- Reviewer capacity is not qualified yet. PLAN must not qualify or launch reviewers; an external harness controller supplies it before fan-out.";
102
+ 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 uses externally qualified capacity to schedule these semantic groups into waves; do not invent a capacity value here.";
103
+ const policyMergeAliases = codeCheckProfile?.mandatory_by_gate.merge ?? [];
104
+ const mergeContract = mergeRequired
105
+ ? ["<merge_gate_contract>", ...(policyMergeAliases.length
106
+ ? [`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.`]
107
+ : ["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>", ""]
108
+ : [];
109
+ 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(git)}`, 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>", "", ...(codeCheckProfile ? ["<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.", JSON.stringify(codeCheckProfile, null, 2), "</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 autonomous executable check. 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, write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one 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
110
  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
111
  const promptPath = path.join(root, "stage-prompt.md");
87
- fs.writeFileSync(promptPath, prompt);
112
+ // This explicit final rule supersedes historical pack wording: a flow gate
113
+ // has only executable autonomous evidence. Human or external confirmation
114
+ // is neither a check nor a permitted completion condition.
115
+ fs.writeFileSync(promptPath, `${prompt}\n<verification_rule>Every flow check must be an autonomous executable command. Do not declare external/manual proof or a human review as a check, evidence substitute, DEF, or gate. The only user interaction supported by this flow is an explicit stage pause for a material unanswered question.</verification_rule>\n`);
88
116
  const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
89
117
  fs.writeFileSync(path.join(root, "work-context.json"), JSON.stringify({ schema_id: "dd-flow/work-context@1", system: { run_id: run.id, work_id: planWorkId, stage: "plan" }, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root }, artifacts: artifactMaterialization, input: { protocols, owned_obligations: Object.fromEntries(owned) } }, null, 2));
90
118
  const binding = bindStageCoordinatorWork(context, { workId: planWorkId, hookEventId: input.hookEventId, stage: "plan", promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
@@ -95,7 +123,21 @@ export function startVnextPlan(context, input) {
95
123
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage: "plan", work_id: planWorkId, id: workSessionId, session_id: sessionId });
96
124
  return { ok: true, run_id: run.id, work_id: planWorkId, id: workSessionId, artifact_materialization: artifactMaterialization, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), prompt_path: promptPath, next_command: finishCommand, ...(externalContext ? { external_context: externalContext } : {}) };
97
125
  }
126
+ export function prepareVnextPlanFinish(context, input) {
127
+ const projectRoot = resolveProjectRoot(input.projectRoot);
128
+ const run = requireRun(context, projectRoot, input.runId), home = requireHome(run);
129
+ requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan" });
130
+ const protocols = protocolIds(home);
131
+ assertProtocolWorkspace(run.workspace_root, protocols);
132
+ const prepared = prepareVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols });
133
+ if (prepared.failures.length)
134
+ throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures });
135
+ return prepared;
136
+ }
98
137
  export function finishVnextPlan(context, input) {
138
+ return withStageSettlement(context, () => finishVnextPlanOwned(context, input));
139
+ }
140
+ function finishVnextPlanOwned(context, input) {
99
141
  const projectRoot = resolveProjectRoot(input.projectRoot);
100
142
  const project = requireProjectByRoot(context, projectRoot);
101
143
  const run = requireRun(context, projectRoot, input.runId);
@@ -104,35 +146,42 @@ export function finishVnextPlan(context, input) {
104
146
  requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan" });
105
147
  const protocols = protocolIds(home);
106
148
  if (!protocols.length)
107
- throw new AppError("not_found", "PLAN requires accepted PROTOCOLIZE protocols", 1);
149
+ throw new AppError("runtime_artifact_missing", "PLAN requires accepted PROTOCOLIZE protocols", 1);
108
150
  assertProtocolWorkspace(run.workspace_root, protocols);
109
151
  const planFiles = protocols.map((id) => path.join(run.workspace_root, ".memory-bank", "protocol", id, "plan.json"));
110
152
  const mapFiles = protocols.map((id) => path.join(root, id, "aspect-map.json"));
111
153
  const batch = path.join(root, "code-work-batch.json");
112
- const failures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols });
113
- if (failures.length)
114
- throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: failures });
154
+ const prepared = input.prepared ?? prepareVnextPlanFinish(context, input);
155
+ if (prepared.failures.length)
156
+ throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures });
115
157
  const work = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND task = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [project.id, run.id, planTask]);
116
158
  if (!work)
117
159
  throw new AppError("invalid_work_state", "PLAN has no running Work", 1);
118
- const now = context.now();
119
- const batchChecksum = checksum(batch);
120
- context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", ["PLAN accepted; awaiting PLAN-REVIEW", now, now, work.work_id]);
121
160
  const workSession = context.db.get("SELECT id, session_id FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
122
161
  if (!workSession?.session_id)
123
162
  throw new AppError("trusted_session_binding_required", "PLAN finish requires its bound Agent WorkSession", 1, { work_id: work.work_id });
163
+ prepared.publish();
164
+ const now = context.now();
165
+ const batchChecksum = checksum(batch);
166
+ context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", ["PLAN accepted; awaiting PLAN-REVIEW", now, now, work.work_id]);
124
167
  context.db.run("UPDATE work_sessions SET status = 'completed', result_path = ?, updated_at = ?, completed_at = ? WHERE id = ?", [path.join(root, "stage-report.json"), now, now, workSession.id]);
125
168
  refreshRunWorkProjection(context, project.id, run.id);
126
- const reviewCommand = `${flowCommand(context)} stage start ${run.id} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --json`;
169
+ const reviewCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --json`);
127
170
  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
171
  const reportJson = writeStageReport(root, report).json;
129
- validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
172
+ validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
130
173
  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
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "planned", nextAction: "start_plan_review" });
174
+ advanceFlowRun(context, { settlementStage: "plan", projectRoot, runId: run.id, status: "running", verdict: "planned", nextAction: "start_plan_review" });
132
175
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_accepted", work_id: work.work_id, protocols, id: workSession.id, next_stage: "plan-review" });
133
176
  return { ok: true, run_id: run.id, protocols, next_action: "start_plan_review", next_command: reviewCommand, next: { kind: "start_stage", stage: "plan-review", command: reviewCommand } };
134
177
  }
135
178
  export function validateVnextPlanArtifacts(context, input) {
179
+ // Validation never repairs or republishes accepted artifacts. PLAN and
180
+ // PLAN-REVIEW finish explicitly publish their prepared projection on accept.
181
+ return prepareVnextPlanArtifacts(context, { ...input, publishBatch: false }).failures;
182
+ }
183
+ /** Validate in memory; publish only after the caller accepts its inputs. */
184
+ export function prepareVnextPlanArtifacts(context, input) {
136
185
  const workspaceRoot = input.workspaceRoot ?? input.projectRoot;
137
186
  const planFiles = input.protocols.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "plan.json"));
138
187
  const mapFiles = input.protocols.map((id) => path.join(input.home, "03-plan", id, "aspect-map.json"));
@@ -145,14 +194,14 @@ export function validateVnextPlanArtifacts(context, input) {
145
194
  : []) : []) ?? [])
146
195
  : undefined;
147
196
  const failures = [];
197
+ const publications = new Map();
148
198
  const plans = [];
149
199
  for (const [index, file] of planFiles.entries()) {
150
200
  const protocolId = input.protocols[index];
151
201
  try {
152
- validateSchema({ schemaName: "vnext-protocol-plan", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId });
153
- const value = readPlan(file);
202
+ const { value } = prepareJsonSchema(file, "PLAN", { schemaName: "vnext-protocol-plan", projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
154
203
  assertPlanIdentity(value, planIdentity(input.home, input.runId, protocolId, ownership.get(protocolId) ?? []), file);
155
- validatePlanSemantics(file, new Set(ownership.get(protocolId) ?? []), obligations);
204
+ validatePlanSemantics(file, value, new Set(ownership.get(protocolId) ?? []), obligations);
156
205
  validateCodeCheckCommands(workspaceRoot, value.checks.filter((check) => check.availability === "available").map((check) => check.command));
157
206
  for (const check of value.checks.filter((item) => item.availability === "available"))
158
207
  validateCheckDeclaration(workspaceRoot, check);
@@ -169,53 +218,83 @@ export function validateVnextPlanArtifacts(context, input) {
169
218
  }
170
219
  for (const file of mapFiles) {
171
220
  try {
172
- normalizeAspectMapRefs(file, workspaceRoot, input.home, input.runId);
173
- validateSchema({ schemaName: "plan-aspect-map", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome });
174
- validateAspectMap(file, input.protocols, workspaceRoot);
221
+ const source = prepareJsonSchema(file, "PLAN aspect map", { schemaName: "plan-aspect-map", projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home }).text;
222
+ const bytes = normalizedAspectMapRefs(file, source, workspaceRoot, input.home, input.runId);
223
+ if (input.publishBatch === false && bytes !== source)
224
+ throw new AppError("validation", "Accepted aspect-map requires normalization; regenerate it in PLAN", 2, { file });
225
+ const value = JSON.parse(bytes);
226
+ validateAspectMap(file, input.protocols, workspaceRoot, value);
227
+ publications.set(file, bytes);
175
228
  }
176
229
  catch (error) {
177
230
  failures.push(validationFailure(file, error));
178
231
  }
179
232
  }
180
233
  if (!failures.length) {
181
- const temporaryBatch = `${batch}.tmp-${crypto.randomUUID()}`;
234
+ try {
235
+ validatePsetCheckIdentity(plans);
236
+ validateRequiredMergeGate(context, input, plans);
237
+ }
238
+ catch (error) {
239
+ failures.push(validationFailure(batch, error));
240
+ }
241
+ }
242
+ if (!failures.length) {
182
243
  try {
183
244
  const projection = projectCodeWorkBatch({ home: input.home, workspaceRoot, runId: input.runId, plans, protocols: input.protocols, ...(frozenDocumentBaselines ? { frozenDocumentBaselines } : {}) });
184
245
  validateProjectedPaths(projection, workspaceRoot, input.home, input.runId);
185
- 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 });
187
- validateWorkBatchFile(temporaryBatch);
246
+ const bytes = `${JSON.stringify(projection, null, 2)}\n`;
247
+ validateSchema({ schemaName: "code-work-batch", file: batch, data: projection, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
248
+ validateWorkBatch(projection);
188
249
  if (input.publishBatch !== false) {
189
- fs.renameSync(temporaryBatch, batch);
250
+ publications.set(batch, bytes);
190
251
  }
191
- else if (!fs.existsSync(batch) || checksum(temporaryBatch) !== checksum(batch)) {
252
+ else if (!fs.existsSync(batch) || crypto.createHash("sha256").update(bytes).digest("hex") !== checksum(batch)) {
192
253
  throw new AppError("stale_code_work_batch", "CODE batch no longer matches the accepted semantic PLAN", 2, { batch });
193
254
  }
194
255
  }
195
256
  catch (error) {
196
257
  failures.push(validationFailure(batch, error));
197
258
  }
198
- finally {
199
- fs.rmSync(temporaryBatch, { force: true });
200
- }
201
259
  }
202
- return failures;
260
+ return { failures, batchChecksum: publications.has(batch) ? crypto.createHash("sha256").update(publications.get(batch)).digest("hex") : null,
261
+ publish: () => {
262
+ if (failures.length)
263
+ throw new AppError("validation", "Cannot publish invalid PLAN artifacts", 2, { errors: failures });
264
+ const published = [];
265
+ try {
266
+ for (const [file, bytes] of publications) {
267
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
268
+ try {
269
+ fs.writeFileSync(temporary, bytes);
270
+ fs.renameSync(temporary, file);
271
+ published.push(file);
272
+ }
273
+ finally {
274
+ fs.rmSync(temporary, { force: true });
275
+ }
276
+ }
277
+ }
278
+ catch (cause) {
279
+ throw new AppError("plan_publication_failed", cause instanceof Error ? cause.message : String(cause), 1, { effect: "unknown", published_files: published, business_commit: false });
280
+ }
281
+ }
282
+ };
203
283
  }
204
284
  /** CODE entry validates the same PLAN closure without changing an accepted batch. */
205
285
  export function validateVnextCodeHandoff(context, input) {
206
286
  return validateVnextPlanArtifacts(context, {
207
287
  ...input,
208
- protocols: protocolIds(input.home),
209
- publishBatch: false
288
+ protocols: protocolIds(input.home)
210
289
  });
211
290
  }
212
291
  const planTask = "Produce accepted plan.json and aspect-map.json artifacts.";
213
292
  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
293
  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)
294
+ 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
295
  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; }
296
+ function requireHome(run) { if (!run.run_root)
297
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
219
298
  function protocolIds(home) {
220
299
  const report = JSON.parse(fs.readFileSync(path.join(home, "02-protocolize", "stage-report.json"), "utf8"));
221
300
  const ids = report.semantic?.acceptance;
@@ -231,16 +310,18 @@ function assertProtocolWorkspace(workspaceRoot, protocols) {
231
310
  }
232
311
  }
233
312
  function read(file) { if (!fs.existsSync(file))
234
- throw new AppError("not_found", "vNext PLAN prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
313
+ throw new AppError("runtime_artifact_missing", "vNext PLAN prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
235
314
  function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
236
315
  function checksum(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
237
- function validateAspectMap(file, protocols, projectRoot) {
238
- let value;
239
- try {
240
- value = JSON.parse(fs.readFileSync(file, "utf8"));
241
- }
242
- catch {
243
- throw new AppError("schema_validation", "aspect-map.json must be valid JSON", 2, { file });
316
+ function validateAspectMap(file, protocols, projectRoot, prepared) {
317
+ let value = prepared;
318
+ if (value === undefined) {
319
+ try {
320
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
321
+ }
322
+ catch {
323
+ throw new AppError("schema_validation", "aspect-map.json must be valid JSON", 2, { file });
324
+ }
244
325
  }
245
326
  const map = value;
246
327
  if (map.schema_id !== "dd-flow/plan-aspect-map@3" || typeof map.protocol_id !== "string" || !protocols.includes(map.protocol_id) || !Array.isArray(map.aspects))
@@ -285,7 +366,13 @@ function stageStartedAt(home, fallback) { try {
285
366
  catch {
286
367
  return fallback;
287
368
  } }
288
- function validationFailure(file, error) { return { file, message: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { details: error.details } : {}) }; }
369
+ function validationFailure(file, error) {
370
+ // Aggregate diagnosed artifact mistakes only. Runtime/schema/storage failures
371
+ // must retain their original code rather than becoming a safe PLAN retry.
372
+ if (!(error instanceof AppError) || error.exitCode !== 2 || ["schema_not_found", "invalid_code_check_profile"].includes(error.code))
373
+ throw error;
374
+ return { file, message: error.message, code: error.code, details: error.details };
375
+ }
289
376
  function acceptedObligations(home) {
290
377
  const file = path.join(home, "01-specify", "specify.json");
291
378
  if (!fs.existsSync(file))
@@ -322,7 +409,7 @@ function ensurePlanSkeleton(file, protocolId, identity) {
322
409
  fs.mkdirSync(path.dirname(file), { recursive: true });
323
410
  writeJson(file, { ...identity, title: "", summary: "", goal: { outcome: "", constraints: [], non_goals: [] }, assessment: {}, decisions: [], document_updates: [], checks: [], items: [], acceptance: [] });
324
411
  }
325
- function ensureAspectMapSkeleton(file, protocolId, identity, workspaceRoot) {
412
+ function ensureAspectMapSkeleton(file, protocolId, identity, aspects) {
326
413
  if (fs.existsSync(file))
327
414
  return;
328
415
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -335,10 +422,9 @@ function ensureAspectMapSkeleton(file, protocolId, identity, workspaceRoot) {
335
422
  catalog_ref: { path: ".memory-bank/dd-flow/mb-sdlc/plan-aspects/aspects" },
336
423
  routing: { initial_state: "orchestrator_local", groups: [] },
337
424
  review_groups: [],
338
- aspects: aspectCatalog(workspaceRoot).map((aspectId) => ({ aspect_id: aspectId, planned_artifact_refs: [] }))
425
+ aspects: aspects.map((aspectId) => ({ aspect_id: aspectId, planned_artifact_refs: [] }))
339
426
  });
340
427
  }
341
- function readPlan(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
342
428
  function assertPlanIdentity(value, identity, file) {
343
429
  for (const key of ["schema_id", "plan_id", "protocol_id"])
344
430
  if (value[key] !== identity[key])
@@ -348,13 +434,17 @@ function assertPlanIdentity(value, identity, file) {
348
434
  if (JSON.stringify(value.source_refs) !== JSON.stringify(identity.source_refs))
349
435
  throw new AppError("validation", "PLAN must preserve CLI-owned source_refs", 2, { file });
350
436
  }
351
- function normalizeAspectMapRefs(file, projectRoot, runHome, runId) {
352
- const map = JSON.parse(fs.readFileSync(file, "utf8"));
437
+ function normalizedAspectMapRefs(file, bytes, projectRoot, runHome, runId) {
438
+ projectRoot = canonicalPath(projectRoot);
439
+ runHome = canonicalPath(runHome);
440
+ const map = JSON.parse(bytes);
441
+ if (!map || typeof map !== "object" || Array.isArray(map))
442
+ throw new AppError("schema_validation", "PLAN aspect map must be an object", 2, { file });
353
443
  let changed = false;
354
444
  const normalize = (value) => {
355
445
  if (!path.isAbsolute(value))
356
446
  return value;
357
- const source = path.resolve(value);
447
+ const source = canonicalPath(value, false);
358
448
  const inProject = source.startsWith(`${projectRoot}${path.sep}`);
359
449
  const inRun = source.startsWith(`${runHome}${path.sep}`);
360
450
  if (!inProject && !inRun)
@@ -367,8 +457,7 @@ function normalizeAspectMapRefs(file, projectRoot, runHome, runId) {
367
457
  for (const aspect of map.aspects ?? [])
368
458
  if (Array.isArray(aspect.planned_artifact_refs))
369
459
  aspect.planned_artifact_refs = aspect.planned_artifact_refs.map(normalize);
370
- if (changed)
371
- fs.writeFileSync(file, `${JSON.stringify(map, null, 2)}\n`);
460
+ return changed ? `${JSON.stringify(map, null, 2)}\n` : bytes;
372
461
  }
373
462
  function projectCodeWorkBatch(input) {
374
463
  const obligationMap = acceptedObligationMap(input.home);
@@ -395,6 +484,7 @@ function projectCodeWorkBatch(input) {
395
484
  // stale as soon as its provider performs its declared work.
396
485
  required_read: [...new Set([
397
486
  ...orientation,
487
+ path.relative(input.workspaceRoot, file).split(path.sep).join("/"),
398
488
  ...item.execution_context.required_read,
399
489
  ...existingDocumentPaths,
400
490
  ...(value.checks.some((check) => check.availability === "planned" && check.provided_by === item.id)
@@ -411,7 +501,7 @@ function projectCodeWorkBatch(input) {
411
501
  provides_checks: value.checks.filter((check) => check.availability === "planned" && check.provided_by === item.id),
412
502
  stop_conditions: item.execution_context.stop_conditions,
413
503
  depends_on: item.depends_on.map((dependency) => `${protocolId}:${dependency}`),
414
- result_schema: "dd-flow/code-work-result@2"
504
+ result_schema: "dd-flow/code-work-result@3"
415
505
  });
416
506
  }));
417
507
  const byProtocol = new Map(input.plans.map((plan) => [plan.protocolId, plan.value.items]));
@@ -483,8 +573,7 @@ function resolvePortablePath(value, workspaceRoot, home, runId) {
483
573
  }
484
574
  function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
485
575
  function runRef(runId, runHome, file) { return `run://${runId}/${path.relative(runHome, file).split(path.sep).join("/")}`; }
486
- function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
487
- const plan = readPlan(file);
576
+ function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
488
577
  const items = plan.items ?? [];
489
578
  const ids = new Set(items.map((item) => item.id).filter((id) => Boolean(id)));
490
579
  if (ids.size !== items.length)
@@ -547,9 +636,14 @@ function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
547
636
  for (const id of acceptance.plan_item_ids ?? [])
548
637
  if (!ids.has(id))
549
638
  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))
639
+ for (const id of acceptance.check_refs ?? []) {
640
+ const check = checks.get(id);
641
+ if (!check)
552
642
  throw new AppError("check_reference_unknown", "PLAN acceptance references an unknown check", 2, { file, criterion_id: acceptance.criterion_id, check_id: id });
643
+ for (const itemId of acceptance.plan_item_ids ?? [])
644
+ if (check.availability === "planned" && check.provided_by !== itemId && !ancestors(itemId).has(check.provided_by))
645
+ 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 });
646
+ }
553
647
  }
554
648
  for (const obligation of ownedRefs)
555
649
  if (!realized.has(obligation))
@@ -558,3 +652,26 @@ function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
558
652
  if (!plan.acceptance.some((acceptance) => acceptance.criterion_id === obligation))
559
653
  throw new AppError("validation", "Every owned AC-* needs an observable PLAN acceptance entry", 2, { file, criterion_id: obligation });
560
654
  }
655
+ function validatePsetCheckIdentity(plans) {
656
+ const owners = new Map();
657
+ for (const plan of plans)
658
+ for (const check of plan.value.checks) {
659
+ const prior = owners.get(check.id);
660
+ if (prior)
661
+ 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] });
662
+ owners.set(check.id, plan.protocolId);
663
+ }
664
+ }
665
+ function runEndsAtMerge(context, projectRoot, runId) {
666
+ const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
667
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]);
668
+ return JSON.parse(row?.index_json ?? "{}").execution_profile?.settings?.stop_target === "merge_completed";
669
+ }
670
+ function validateRequiredMergeGate(context, input, plans) {
671
+ if (!runEndsAtMerge(context, input.projectRoot, input.runId))
672
+ return;
673
+ const declared = plans.flatMap(({ value }) => value.checks);
674
+ if (effectiveCheckDeclarations(input.workspaceRoot ?? input.projectRoot, declared, ["merge"]).length === 0) {
675
+ 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 });
676
+ }
677
+ }