@deksden-com/dd-flow-cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +666 -0
- package/README.md +7 -2
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +88 -10
- package/dist/cli/run-cli.js +523 -28
- package/dist/domain/stage-catalog.js +22 -0
- package/dist/domain/validation.js +1 -1
- package/dist/schemas/code-review-decision.schema.json +26 -0
- package/dist/schemas/code-review-result.schema.json +14 -0
- package/dist/schemas/code-verification.schema.json +14 -0
- package/dist/schemas/code-work-batch.schema.json +24 -0
- package/dist/schemas/code-work-result.schema.json +16 -0
- package/dist/schemas/compatibility.schema.json +32 -0
- package/dist/schemas/flow-contract.schema.json +9 -5
- package/dist/schemas/flow-run.schema.json +16 -123
- package/dist/schemas/plan-aspect-map.schema.json +22 -0
- package/dist/schemas/plan-review-decision.schema.json +14 -0
- package/dist/schemas/plan-review-result.schema.json +42 -0
- package/dist/schemas/protocol-plan.schema.json +15 -182
- package/dist/schemas/stage-finish-input.schema.json +16 -2
- package/dist/schemas/stage-report.schema.json +8 -7
- package/dist/schemas/stage-start-response.schema.json +4 -2
- package/dist/schemas/status-report.schema.json +76 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
- package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
- package/dist/schemas/vnext-specify.schema.json +45 -0
- package/dist/services/branch-context.js +1 -1
- package/dist/services/cleanup.js +8 -8
- package/dist/services/cli-operation-classifier.js +10 -2
- package/dist/services/code-checks.js +244 -0
- package/dist/services/config.js +7 -1
- package/dist/services/dashboard.js +12 -12
- package/dist/services/engines.js +1 -1
- package/dist/services/eval-snapshots.js +404 -0
- package/dist/services/hooks.js +774 -18
- package/dist/services/ids.js +16 -6
- package/dist/services/lanes.js +1 -1
- package/dist/services/merge-queue.js +5 -5
- package/dist/services/merge-worker.js +2 -2
- package/dist/services/migrations.js +2 -2
- package/dist/services/plan-runtime.js +1 -1
- package/dist/services/projects.js +4 -4
- package/dist/services/prompts.js +17 -11
- package/dist/services/protocols.js +8 -8
- package/dist/services/run-projection.js +49 -13
- package/dist/services/runs.js +504 -51
- package/dist/services/schema-validation.js +21 -3
- package/dist/services/sessions.js +51 -12
- package/dist/services/stage-blocker.js +57 -0
- package/dist/services/stage-context.js +90 -0
- package/dist/services/stage-lifecycle.js +198 -75
- package/dist/services/stage-pause.js +175 -0
- package/dist/services/stage-report-renderer.js +65 -0
- package/dist/services/usage.js +526 -18
- package/dist/services/vnext-code-review.js +305 -0
- package/dist/services/vnext-code.js +686 -0
- package/dist/services/vnext-contracts.js +1 -0
- package/dist/services/vnext-execution-profile.js +27 -0
- package/dist/services/vnext-fanout.js +79 -0
- package/dist/services/vnext-plan-review.js +499 -0
- package/dist/services/vnext-plan.js +552 -0
- package/dist/services/vnext-protocolize.js +542 -0
- package/dist/services/vnext-specify.js +595 -0
- package/dist/services/vnext-workspace-policy.js +87 -0
- package/dist/services/work-registry.js +522 -0
- package/dist/services/worktrees.js +58 -37
- package/dist/storage/database.js +263 -34
- package/dist/storage/paths.js +47 -1
- package/package.json +12 -12
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
|
|
6
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
7
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
8
|
+
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
9
|
+
import { validateSchema } from "./schema-validation.js";
|
|
10
|
+
import { bindRunningWorkSession, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, validateWorkBatchFile } from "./work-registry.js";
|
|
11
|
+
import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
|
|
12
|
+
import { assertStageStartHookEvent } from "./hooks.js";
|
|
13
|
+
import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
14
|
+
import { readVnextSpecifyResult } from "./vnext-specify.js";
|
|
15
|
+
import { nextWorkId } from "./ids.js";
|
|
16
|
+
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
17
|
+
import { writeStageReport } from "./stage-report-renderer.js";
|
|
18
|
+
import { applyExternalStageContext } from "./stage-context.js";
|
|
19
|
+
import { subagentCapacityKey } from "./vnext-fanout.js";
|
|
20
|
+
export function isVnextPlanRun(context, input) {
|
|
21
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
22
|
+
return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
|
|
23
|
+
}
|
|
24
|
+
export function startVnextPlan(context, input) {
|
|
25
|
+
ensureWorkRegistry(context);
|
|
26
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
27
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
28
|
+
const run = requireRun(context, projectRoot, input.runId);
|
|
29
|
+
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
|
+
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
|
+
const protocols = protocolIds(home);
|
|
35
|
+
if (!protocols.length)
|
|
36
|
+
throw new AppError("not_found", "PLAN requires accepted PROTOCOLIZE protocols", 1);
|
|
37
|
+
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
|
+
throw new AppError("invalid_work_state", "PLAN already has a running Work", 1, { run_id: run.id });
|
|
39
|
+
const now = context.now();
|
|
40
|
+
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
|
+
if (!rootWork)
|
|
42
|
+
throw new AppError("runtime_missing", "vNext RUN has no root Work", 1);
|
|
43
|
+
// Imported stage fixtures have a synthetic running root with no prior agent.
|
|
44
|
+
// Bind it on the first real stage start so its child PLAN Work has a real parent.
|
|
45
|
+
if (!context.db.get("SELECT 1 FROM work_sessions WHERE work_id = ? LIMIT 1", [rootWork.work_id])) {
|
|
46
|
+
bindRunningWorkSession(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, promptPath: path.join(root, "fixture-root.prompt.md") });
|
|
47
|
+
}
|
|
48
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
49
|
+
let planWorkId;
|
|
50
|
+
try {
|
|
51
|
+
planWorkId = nextWorkId(context, project.id, "plan");
|
|
52
|
+
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
|
+
context.db.exec("COMMIT");
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
context.db.exec("ROLLBACK");
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan.md"));
|
|
60
|
+
// A Desktop task may start above the materialized repository. Lifecycle
|
|
61
|
+
// prompts therefore hand agents write targets as absolute paths: relative
|
|
62
|
+
// `.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
|
+
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`;
|
|
71
|
+
const pauseCommand = stagePauseCommand(context, { runId: run.id, stage: "plan", workId: planWorkId, projectRoot });
|
|
72
|
+
const pauseCommandTemplate = stagePauseCommandTemplate(pauseCommand);
|
|
73
|
+
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`
|
|
76
|
+
]);
|
|
77
|
+
const runVariables = getFlowRunVariables(context, { projectRoot, runId: run.id });
|
|
78
|
+
const measuredCapacity = runVariables.variables[subagentCapacityKey];
|
|
79
|
+
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. Existing @check aliases use the profile and do not repeat a definition. 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
|
+
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
|
+
const promptPath = path.join(root, "stage-prompt.md");
|
|
87
|
+
fs.writeFileSync(promptPath, prompt);
|
|
88
|
+
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
89
|
+
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
|
+
const binding = bindStageCoordinatorWork(context, { workId: planWorkId, hookEventId: input.hookEventId, stage: "plan", promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
91
|
+
attachFlowRunStage(context, { projectRoot, runId: run.id, stage: "plan", dir: "03-plan", status: "running", dataSchemaId: "dd-flow/protocol-plan@6" });
|
|
92
|
+
const workSessionId = String(binding.work_session_id);
|
|
93
|
+
const sessionId = String(binding.session_id);
|
|
94
|
+
refreshRunWorkProjection(context, project.id, run.id);
|
|
95
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage: "plan", work_id: planWorkId, id: workSessionId, session_id: sessionId });
|
|
96
|
+
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
|
+
}
|
|
98
|
+
export function finishVnextPlan(context, input) {
|
|
99
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
100
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
101
|
+
const run = requireRun(context, projectRoot, input.runId);
|
|
102
|
+
const home = requireHome(run);
|
|
103
|
+
const root = path.join(home, "03-plan");
|
|
104
|
+
requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan" });
|
|
105
|
+
const protocols = protocolIds(home);
|
|
106
|
+
if (!protocols.length)
|
|
107
|
+
throw new AppError("not_found", "PLAN requires accepted PROTOCOLIZE protocols", 1);
|
|
108
|
+
assertProtocolWorkspace(run.workspace_root, protocols);
|
|
109
|
+
const planFiles = protocols.map((id) => path.join(run.workspace_root, ".memory-bank", "protocol", id, "plan.json"));
|
|
110
|
+
const mapFiles = protocols.map((id) => path.join(root, id, "aspect-map.json"));
|
|
111
|
+
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 });
|
|
115
|
+
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
|
+
if (!work)
|
|
117
|
+
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
|
+
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
|
+
if (!workSession?.session_id)
|
|
123
|
+
throw new AppError("trusted_session_binding_required", "PLAN finish requires its bound Agent WorkSession", 1, { work_id: work.work_id });
|
|
124
|
+
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
|
+
refreshRunWorkProjection(context, project.id, run.id);
|
|
126
|
+
const reviewCommand = `${flowCommand(context)} stage start ${run.id} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --json`;
|
|
127
|
+
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
|
+
const reportJson = writeStageReport(root, report).json;
|
|
129
|
+
validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
|
|
130
|
+
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" });
|
|
132
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_accepted", work_id: work.work_id, protocols, id: workSession.id, next_stage: "plan-review" });
|
|
133
|
+
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
|
+
}
|
|
135
|
+
export function validateVnextPlanArtifacts(context, input) {
|
|
136
|
+
const workspaceRoot = input.workspaceRoot ?? input.projectRoot;
|
|
137
|
+
const planFiles = input.protocols.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "plan.json"));
|
|
138
|
+
const mapFiles = input.protocols.map((id) => path.join(input.home, "03-plan", id, "aspect-map.json"));
|
|
139
|
+
const batch = path.join(input.home, "03-plan", "code-work-batch.json");
|
|
140
|
+
const ownership = protocolOwnership(input.home, input.protocols);
|
|
141
|
+
const obligations = acceptedObligations(input.home);
|
|
142
|
+
const frozenDocumentBaselines = input.publishBatch === false && fs.existsSync(batch)
|
|
143
|
+
? new Map(readJson(batch).works?.flatMap((work) => typeof work.key === "string" ? (work.document_updates ?? []).flatMap((update) => typeof update.path === "string" && (typeof update.baseline_sha256 === "string" || update.baseline_sha256 === null)
|
|
144
|
+
? [[`${work.key}\u0000${update.path}`, update.baseline_sha256]]
|
|
145
|
+
: []) : []) ?? [])
|
|
146
|
+
: undefined;
|
|
147
|
+
const failures = [];
|
|
148
|
+
const plans = [];
|
|
149
|
+
for (const [index, file] of planFiles.entries()) {
|
|
150
|
+
const protocolId = input.protocols[index];
|
|
151
|
+
try {
|
|
152
|
+
validateSchema({ schemaName: "vnext-protocol-plan", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId });
|
|
153
|
+
const value = readPlan(file);
|
|
154
|
+
assertPlanIdentity(value, planIdentity(input.home, input.runId, protocolId, ownership.get(protocolId) ?? []), file);
|
|
155
|
+
validatePlanSemantics(file, new Set(ownership.get(protocolId) ?? []), obligations);
|
|
156
|
+
validateCodeCheckCommands(workspaceRoot, value.checks.filter((check) => check.availability === "available").map((check) => check.command));
|
|
157
|
+
// Placement is a PLAN-time rule. A Work may legitimately materialize a
|
|
158
|
+
// new aggregate alias later, so rechecking against the changed profile
|
|
159
|
+
// at CODE entry would retroactively invalidate an accepted plan.
|
|
160
|
+
if (input.publishBatch !== false)
|
|
161
|
+
validateCheckPlacement(workspaceRoot, value.checks);
|
|
162
|
+
plans.push({ protocolId, file, value });
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
failures.push(validationFailure(file, error));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const file of mapFiles) {
|
|
169
|
+
try {
|
|
170
|
+
normalizeAspectMapRefs(file, workspaceRoot, input.home, input.runId);
|
|
171
|
+
validateSchema({ schemaName: "plan-aspect-map", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome });
|
|
172
|
+
validateAspectMap(file, input.protocols, workspaceRoot);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
failures.push(validationFailure(file, error));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!failures.length) {
|
|
179
|
+
const temporaryBatch = `${batch}.tmp-${crypto.randomUUID()}`;
|
|
180
|
+
try {
|
|
181
|
+
const projection = projectCodeWorkBatch({ home: input.home, workspaceRoot, runId: input.runId, plans, protocols: input.protocols, ...(frozenDocumentBaselines ? { frozenDocumentBaselines } : {}) });
|
|
182
|
+
validateProjectedPaths(projection, workspaceRoot, input.home, input.runId);
|
|
183
|
+
fs.writeFileSync(temporaryBatch, `${JSON.stringify(projection, null, 2)}\n`);
|
|
184
|
+
validateSchema({ schemaName: "code-work-batch", file: temporaryBatch, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId });
|
|
185
|
+
validateWorkBatchFile(temporaryBatch);
|
|
186
|
+
if (input.publishBatch !== false) {
|
|
187
|
+
fs.renameSync(temporaryBatch, batch);
|
|
188
|
+
}
|
|
189
|
+
else if (!fs.existsSync(batch) || checksum(temporaryBatch) !== checksum(batch)) {
|
|
190
|
+
throw new AppError("stale_code_work_batch", "CODE batch no longer matches the accepted semantic PLAN", 2, { batch });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
failures.push(validationFailure(batch, error));
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
fs.rmSync(temporaryBatch, { force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return failures;
|
|
201
|
+
}
|
|
202
|
+
/** CODE entry validates the same PLAN closure without changing an accepted batch. */
|
|
203
|
+
export function validateVnextCodeHandoff(context, input) {
|
|
204
|
+
return validateVnextPlanArtifacts(context, {
|
|
205
|
+
...input,
|
|
206
|
+
protocols: protocolIds(input.home),
|
|
207
|
+
publishBatch: false
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const planTask = "Produce accepted plan.json and aspect-map.json artifacts.";
|
|
211
|
+
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" }] }; }
|
|
212
|
+
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: [] }] }; }
|
|
213
|
+
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)
|
|
214
|
+
throw new AppError("not_found", "RUN is not registered", 1); return run; }
|
|
215
|
+
function requireHome(run) { if (!run.run_home_path)
|
|
216
|
+
throw new AppError("runtime_missing", "RUN workspace is unavailable", 1); return run.run_home_path; }
|
|
217
|
+
function protocolIds(home) {
|
|
218
|
+
const report = JSON.parse(fs.readFileSync(path.join(home, "02-protocolize", "stage-report.json"), "utf8"));
|
|
219
|
+
const ids = report.semantic?.acceptance;
|
|
220
|
+
if (!Array.isArray(ids) || !ids.length || !ids.every((id) => typeof id === "string" && id.startsWith("PRT-")))
|
|
221
|
+
throw new AppError("runtime_missing", "Accepted PROTOCOLIZE protocol ids are missing", 1);
|
|
222
|
+
return ids;
|
|
223
|
+
}
|
|
224
|
+
function assertProtocolWorkspace(workspaceRoot, protocols) {
|
|
225
|
+
for (const id of protocols) {
|
|
226
|
+
const summary = path.join(workspaceRoot, ".memory-bank", "protocol", id, "summary.md");
|
|
227
|
+
if (!fs.existsSync(summary))
|
|
228
|
+
throw new AppError("workspace_protocol_missing", "PLAN requires protocol documents in the registered RUN workspace", 1, { workspace_root: workspaceRoot, protocol_id: id, expected: summary });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function read(file) { if (!fs.existsSync(file))
|
|
232
|
+
throw new AppError("not_found", "vNext PLAN prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
|
|
233
|
+
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
234
|
+
function checksum(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
235
|
+
function validateAspectMap(file, protocols, projectRoot) {
|
|
236
|
+
let value;
|
|
237
|
+
try {
|
|
238
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
throw new AppError("schema_validation", "aspect-map.json must be valid JSON", 2, { file });
|
|
242
|
+
}
|
|
243
|
+
const map = value;
|
|
244
|
+
if (map.schema_id !== "dd-flow/plan-aspect-map@3" || typeof map.protocol_id !== "string" || !protocols.includes(map.protocol_id) || !Array.isArray(map.aspects))
|
|
245
|
+
throw new AppError("schema_validation", "aspect-map.json does not match v3 contract", 2, { file });
|
|
246
|
+
const actual = map.aspects.map((aspect) => aspect.aspect_id).filter((id) => Boolean(id));
|
|
247
|
+
if (new Set(actual).size !== actual.length)
|
|
248
|
+
throw new AppError("validation", "aspect-map has duplicate aspect_id values", 2, { file });
|
|
249
|
+
const catalog = aspectCatalog(projectRoot);
|
|
250
|
+
if (catalog.length && (catalog.length !== actual.length || catalog.some((id) => !actual.includes(id))))
|
|
251
|
+
throw new AppError("validation", "aspect-map must cover the current aspect catalog exactly once", 2, { file, missing: catalog.filter((id) => !actual.includes(id)), unknown: actual.filter((id) => !catalog.includes(id)) });
|
|
252
|
+
for (const aspect of map.aspects)
|
|
253
|
+
if (aspect.applicability === "applicable" && !aspect.reason?.trim())
|
|
254
|
+
throw new AppError("validation", "Applicable aspect needs a concrete reason", 2, { file, aspect_id: aspect.aspect_id });
|
|
255
|
+
const groups = map.routing?.groups ?? [];
|
|
256
|
+
const grouped = groups.flatMap((group) => group.aspect_ids ?? []);
|
|
257
|
+
if (new Set(grouped).size !== grouped.length || grouped.some((id) => !actual.includes(id)))
|
|
258
|
+
throw new AppError("validation", "Aspect routing groups must reference each listed aspect at most once", 2, { file });
|
|
259
|
+
if (map.routing?.selected_route === "single_wave_grouped" && !groups.length)
|
|
260
|
+
throw new AppError("validation", "single_wave_grouped requires one or more explicit groups", 2, { file });
|
|
261
|
+
if (map.routing?.selected_route === "local_compact" && groups.length)
|
|
262
|
+
throw new AppError("validation", "local_compact cannot declare delegated groups", 2, { file });
|
|
263
|
+
if (map.routing?.selected_route === "single_wave_grouped" || map.routing?.selected_route === "multi_wave_grouped")
|
|
264
|
+
for (const aspectId of grouped)
|
|
265
|
+
if (map.aspects.find((item) => item.aspect_id === aspectId)?.applicability !== "applicable")
|
|
266
|
+
throw new AppError("validation", "Grouped route may contain only applicable aspects", 2, { file, aspect_id: aspectId });
|
|
267
|
+
const applicable = map.aspects.filter((aspect) => aspect.applicability === "applicable").map((aspect) => aspect.aspect_id).sort();
|
|
268
|
+
const reviewed = (map.review_groups ?? []).flatMap((group) => group.aspect_ids ?? []).sort();
|
|
269
|
+
if (new Set(reviewed).size !== reviewed.length || applicable.length !== reviewed.length || applicable.some((id, index) => reviewed[index] !== id)) {
|
|
270
|
+
throw new AppError("validation", "review_groups must cover every applicable aspect exactly once", 2, { file, applicable, reviewed });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function aspectCatalog(projectRoot) {
|
|
274
|
+
const root = path.join(projectRoot, ".memory-bank", "dd-flow", "mb-sdlc", "plan-aspects", "aspects");
|
|
275
|
+
if (!fs.existsSync(root))
|
|
276
|
+
return [];
|
|
277
|
+
return fs.readdirSync(root).filter((name) => name.endsWith(".md") && name !== "index.md" && !name.startsWith("00-")).map((name) => name.slice(0, -3)).sort();
|
|
278
|
+
}
|
|
279
|
+
function stageStartedAt(home, fallback) { try {
|
|
280
|
+
const run = JSON.parse(fs.readFileSync(path.join(home, "run.json"), "utf8"));
|
|
281
|
+
return run.stage_runs?.find((item) => item.stage === "plan")?.started_at ?? fallback;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return fallback;
|
|
285
|
+
} }
|
|
286
|
+
function validationFailure(file, error) { return { file, message: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { details: error.details } : {}) }; }
|
|
287
|
+
function acceptedObligations(home) {
|
|
288
|
+
const file = path.join(home, "01-specify", "specify.json");
|
|
289
|
+
if (!fs.existsSync(file))
|
|
290
|
+
throw new AppError("runtime_missing", "PLAN requires accepted specify.json", 1, { file });
|
|
291
|
+
const result = readVnextSpecifyResult(file);
|
|
292
|
+
return new Set([...result.requirements, ...result.acceptance_criteria].map((obligation) => obligation.id));
|
|
293
|
+
}
|
|
294
|
+
function protocolOwnership(home, protocols) {
|
|
295
|
+
const file = path.join(home, "02-protocolize", "protocolize-result.json");
|
|
296
|
+
const result = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
297
|
+
const idsByMember = new Map(result.delivery.members.map((member, index) => [member.key, protocols[index]]));
|
|
298
|
+
const output = new Map(protocols.map((protocol) => [protocol, []]));
|
|
299
|
+
for (const item of result.obligation_ownership)
|
|
300
|
+
for (const member of item.member_keys)
|
|
301
|
+
output.get(idsByMember.get(member) ?? "")?.push(item.obligation_id);
|
|
302
|
+
return output;
|
|
303
|
+
}
|
|
304
|
+
function planIdentity(home, runId, protocolId, owned) {
|
|
305
|
+
const specify = path.join(home, "01-specify", "specify.json");
|
|
306
|
+
return {
|
|
307
|
+
schema_id: "dd-flow/protocol-plan@6",
|
|
308
|
+
plan_id: `PLAN-${protocolId.slice(4)}`,
|
|
309
|
+
protocol_id: protocolId,
|
|
310
|
+
revision: 1,
|
|
311
|
+
source_refs: [
|
|
312
|
+
{ kind: "specify", id: "SPECIFY", path: runRef(runId, home, specify), requirement_ids: [...acceptedObligations(home)].sort(), sha256: checksum(specify) },
|
|
313
|
+
{ kind: "protocol", id: protocolId, path: `.memory-bank/protocol/${protocolId}/summary.md`, requirement_ids: [...owned].sort() }
|
|
314
|
+
]
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function ensurePlanSkeleton(file, protocolId, identity) {
|
|
318
|
+
if (fs.existsSync(file))
|
|
319
|
+
return;
|
|
320
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
321
|
+
writeJson(file, { ...identity, title: "", summary: "", goal: { outcome: "", constraints: [], non_goals: [] }, assessment: {}, decisions: [], document_updates: [], checks: [], items: [], acceptance: [] });
|
|
322
|
+
}
|
|
323
|
+
function ensureAspectMapSkeleton(file, protocolId, identity, workspaceRoot) {
|
|
324
|
+
if (fs.existsSync(file))
|
|
325
|
+
return;
|
|
326
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
327
|
+
writeJson(file, {
|
|
328
|
+
$schema: "plan-aspect-map.schema.json",
|
|
329
|
+
schema_id: "dd-flow/plan-aspect-map@3",
|
|
330
|
+
protocol_id: protocolId,
|
|
331
|
+
plan_id: identity.plan_id,
|
|
332
|
+
plan_revision: identity.revision,
|
|
333
|
+
catalog_ref: { path: ".memory-bank/dd-flow/mb-sdlc/plan-aspects/aspects" },
|
|
334
|
+
routing: { initial_state: "orchestrator_local", groups: [] },
|
|
335
|
+
review_groups: [],
|
|
336
|
+
aspects: aspectCatalog(workspaceRoot).map((aspectId) => ({ aspect_id: aspectId, planned_artifact_refs: [] }))
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
function readPlan(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
340
|
+
function assertPlanIdentity(value, identity, file) {
|
|
341
|
+
for (const key of ["schema_id", "plan_id", "protocol_id"])
|
|
342
|
+
if (value[key] !== identity[key])
|
|
343
|
+
throw new AppError("validation", `PLAN must preserve CLI-owned ${key}`, 2, { file, expected: identity[key], actual: value[key] });
|
|
344
|
+
if (!Number.isInteger(value.revision) || value.revision < identity.revision)
|
|
345
|
+
throw new AppError("validation", "PLAN revision must not precede the CLI-issued initial revision", 2, { file, initial_revision: identity.revision, actual: value.revision });
|
|
346
|
+
if (JSON.stringify(value.source_refs) !== JSON.stringify(identity.source_refs))
|
|
347
|
+
throw new AppError("validation", "PLAN must preserve CLI-owned source_refs", 2, { file });
|
|
348
|
+
}
|
|
349
|
+
function normalizeAspectMapRefs(file, projectRoot, runHome, runId) {
|
|
350
|
+
const map = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
351
|
+
let changed = false;
|
|
352
|
+
const normalize = (value) => {
|
|
353
|
+
if (!path.isAbsolute(value))
|
|
354
|
+
return value;
|
|
355
|
+
const source = path.resolve(value);
|
|
356
|
+
const inProject = source.startsWith(`${projectRoot}${path.sep}`);
|
|
357
|
+
const inRun = source.startsWith(`${runHome}${path.sep}`);
|
|
358
|
+
if (!inProject && !inRun)
|
|
359
|
+
throw new AppError("path_escape", "Aspect-map artifact reference must stay in the project or RUN", 2, { file, path: value });
|
|
360
|
+
changed = true;
|
|
361
|
+
return inRun ? runRef(runId, runHome, source) : path.relative(projectRoot, source);
|
|
362
|
+
};
|
|
363
|
+
if (map.catalog_ref?.path)
|
|
364
|
+
map.catalog_ref.path = normalize(map.catalog_ref.path);
|
|
365
|
+
for (const aspect of map.aspects ?? [])
|
|
366
|
+
if (Array.isArray(aspect.planned_artifact_refs))
|
|
367
|
+
aspect.planned_artifact_refs = aspect.planned_artifact_refs.map(normalize);
|
|
368
|
+
if (changed)
|
|
369
|
+
fs.writeFileSync(file, `${JSON.stringify(map, null, 2)}\n`);
|
|
370
|
+
}
|
|
371
|
+
function projectCodeWorkBatch(input) {
|
|
372
|
+
const obligationMap = acceptedObligationMap(input.home);
|
|
373
|
+
const orientation = projectOrientation(input.workspaceRoot);
|
|
374
|
+
const works = input.plans.flatMap(({ protocolId, file, value }) => value.items.map((item) => {
|
|
375
|
+
const workKey = `${protocolId}:${item.id}`;
|
|
376
|
+
const documentUpdates = value.document_updates.filter((entry) => entry.owner === item.id).map((entry) => { const filePath = path.join(input.workspaceRoot, entry.path); const baselineKey = `${workKey}\u0000${entry.path}`; const frozen = input.frozenDocumentBaselines; return { ...entry, baseline_sha256: frozen?.has(baselineKey) ? frozen.get(baselineKey) : (fs.existsSync(filePath) && fs.statSync(filePath).isFile() ? checksum(filePath) : null) }; });
|
|
377
|
+
const documentPaths = documentUpdates.map((entry) => entry.path);
|
|
378
|
+
const existingDocumentPaths = documentUpdates.filter((entry) => entry.action === "update").map((entry) => entry.path);
|
|
379
|
+
return ({
|
|
380
|
+
schema_id: "dd-flow/code-work-packet@5",
|
|
381
|
+
key: workKey,
|
|
382
|
+
launch_policy: "fresh_agent_required",
|
|
383
|
+
source: { plan_id: value.plan_id, protocol_id: protocolId, plan_item_id: item.id, revision: value.revision, sha256: checksum(file) },
|
|
384
|
+
task: renderCodeTask(item, value.checks),
|
|
385
|
+
semantic_spine: item.semantic_spine,
|
|
386
|
+
requirements: item.requirement_refs.map((id) => ({ id, statement: obligationMap.get(id) ?? "" })),
|
|
387
|
+
acceptance: value.acceptance.filter((entry) => entry.plan_item_ids.includes(item.id)).map(({ plan_item_ids: _ids, changed_surfaces: _surfaces, ...entry }) => entry),
|
|
388
|
+
document_updates: documentUpdates,
|
|
389
|
+
// The profile is mutable by the Work which materializes a planned alias.
|
|
390
|
+
// Give it to that provider, not to every CODE Worker: consumers already
|
|
391
|
+
// receive the resolved check declaration in their packet, while making the
|
|
392
|
+
// profile a universal input would make an otherwise accepted batch look
|
|
393
|
+
// stale as soon as its provider performs its declared work.
|
|
394
|
+
required_read: [...new Set([
|
|
395
|
+
...orientation,
|
|
396
|
+
...item.execution_context.required_read,
|
|
397
|
+
...existingDocumentPaths,
|
|
398
|
+
...(value.checks.some((check) => check.availability === "planned" && check.provided_by === item.id)
|
|
399
|
+
&& fs.existsSync(path.join(input.workspaceRoot, ".memory-bank/spec/engineering/code-check-profile.json"))
|
|
400
|
+
? [".memory-bank/spec/engineering/code-check-profile.json"]
|
|
401
|
+
: [])
|
|
402
|
+
])],
|
|
403
|
+
discovery_boundary: item.execution_context.discovery_boundary,
|
|
404
|
+
planned_write_areas: [...new Set([...item.execution_context.planned_write_areas, ...documentPaths])],
|
|
405
|
+
checks: value.checks.filter((check) => item.verification.check_refs.includes(check.id)),
|
|
406
|
+
provides_checks: value.checks.filter((check) => check.availability === "planned" && check.provided_by === item.id),
|
|
407
|
+
stop_conditions: item.execution_context.stop_conditions,
|
|
408
|
+
depends_on: item.depends_on.map((dependency) => `${protocolId}:${dependency}`),
|
|
409
|
+
result_schema: "dd-flow/code-work-result@2"
|
|
410
|
+
});
|
|
411
|
+
}));
|
|
412
|
+
const byProtocol = new Map(input.plans.map((plan) => [plan.protocolId, plan.value.items]));
|
|
413
|
+
const result = JSON.parse(fs.readFileSync(path.join(input.home, "02-protocolize", "protocolize-result.json"), "utf8"));
|
|
414
|
+
const memberToProtocol = new Map(result.delivery.members.map((member, index) => [member.key, input.protocols[index]]));
|
|
415
|
+
for (const member of result.delivery.members) {
|
|
416
|
+
const protocolId = memberToProtocol.get(member.key);
|
|
417
|
+
const local = byProtocol.get(protocolId) ?? [];
|
|
418
|
+
const roots = local.filter((item) => item.depends_on.length === 0);
|
|
419
|
+
for (const blockedMember of member.blocked_by) {
|
|
420
|
+
const predecessor = memberToProtocol.get(blockedMember);
|
|
421
|
+
const predecessorItems = predecessor ? byProtocol.get(predecessor) ?? [] : [];
|
|
422
|
+
const dependedOn = new Set(predecessorItems.flatMap((item) => item.depends_on));
|
|
423
|
+
const terminals = predecessorItems.filter((item) => !dependedOn.has(item.id));
|
|
424
|
+
for (const root of roots) {
|
|
425
|
+
const work = works.find((item) => item.key === `${protocolId}:${root.id}`);
|
|
426
|
+
work.depends_on.push(...terminals.map((item) => `${predecessor}:${item.id}`));
|
|
427
|
+
work.depends_on = [...new Set(work.depends_on)].sort();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const ordered = [...works].sort((a, b) => a.key.localeCompare(b.key));
|
|
432
|
+
return { schema_id: "dd-flow/code-work-batch@5", sources: input.plans.map(({ protocolId, file, value }) => ({ plan_id: value.plan_id, protocol_id: protocolId, revision: value.revision, sha256: checksum(file) })), works: ordered };
|
|
433
|
+
}
|
|
434
|
+
function renderCodeTask(item, catalog) { const checks = catalog.filter((check) => item.verification.check_refs.includes(check.id)); return [item.title, item.summary, item.details, `Preserve: ${item.semantic_spine.must_preserve.join("; ")}`, `Checks: ${checks.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Stop: ${item.execution_context.stop_conditions.join("; ")}`].join("\n\n"); }
|
|
435
|
+
function validateProjectedPaths(batch, workspaceRoot, home, runId) {
|
|
436
|
+
for (const work of batch.works) {
|
|
437
|
+
for (const write of work.planned_write_areas)
|
|
438
|
+
assertProjectPath(write, workspaceRoot, "planned_write_areas");
|
|
439
|
+
for (const read of work.required_read) {
|
|
440
|
+
const existing = resolvePortablePath(read, workspaceRoot, home, runId);
|
|
441
|
+
if (existing && fs.existsSync(existing))
|
|
442
|
+
continue;
|
|
443
|
+
throw new AppError("validation", "CODE required_read path must exist at Work entry", 2, { work: work.key, read_path: read });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function acceptedObligationMap(home) {
|
|
448
|
+
const specify = readVnextSpecifyResult(path.join(home, "01-specify", "specify.json"));
|
|
449
|
+
const effective = new Map([...specify.requirements, ...specify.acceptance_criteria].map((item) => [item.id, item.statement]));
|
|
450
|
+
const protocolize = JSON.parse(fs.readFileSync(path.join(home, "02-protocolize", "protocolize-result.json"), "utf8"));
|
|
451
|
+
for (const amendment of protocolize.obligation_amendments ?? [])
|
|
452
|
+
effective.set(amendment.target_id, amendment.replacement);
|
|
453
|
+
return effective;
|
|
454
|
+
}
|
|
455
|
+
function projectOrientation(workspaceRoot) {
|
|
456
|
+
return [".memory-bank/index.md", ".memory-bank/spec/engineering/index.md", ".memory-bank/spec/engineering/coding-standards.md"]
|
|
457
|
+
.filter((item) => fs.existsSync(path.join(workspaceRoot, item)));
|
|
458
|
+
}
|
|
459
|
+
function assertProjectPath(value, workspaceRoot, label) {
|
|
460
|
+
if (!value || path.isAbsolute(value) || value.startsWith("run://") || value.split(/[\\/]/).includes(".."))
|
|
461
|
+
throw new AppError("path_escape", `${label} must be a project-relative path`, 2, { path: value });
|
|
462
|
+
const resolved = path.resolve(workspaceRoot, value);
|
|
463
|
+
if (!resolved.startsWith(`${path.resolve(workspaceRoot)}${path.sep}`))
|
|
464
|
+
throw new AppError("path_escape", `${label} escapes the project workspace`, 2, { path: value });
|
|
465
|
+
}
|
|
466
|
+
function resolvePortablePath(value, workspaceRoot, home, runId) {
|
|
467
|
+
if (value.startsWith("run://")) {
|
|
468
|
+
const prefix = `run://${runId}/`;
|
|
469
|
+
if (!value.startsWith(prefix) || value.slice(prefix.length).split("/").includes(".."))
|
|
470
|
+
throw new AppError("path_escape", "RUN read path must stay in this RUN", 2, { path: value });
|
|
471
|
+
return path.join(home, value.slice(prefix.length));
|
|
472
|
+
}
|
|
473
|
+
assertProjectPath(value, workspaceRoot, "required_read");
|
|
474
|
+
return path.join(workspaceRoot, value);
|
|
475
|
+
}
|
|
476
|
+
function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
477
|
+
function runRef(runId, runHome, file) { return `run://${runId}/${path.relative(runHome, file).split(path.sep).join("/")}`; }
|
|
478
|
+
function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
|
|
479
|
+
const plan = readPlan(file);
|
|
480
|
+
const items = plan.items ?? [];
|
|
481
|
+
const ids = new Set(items.map((item) => item.id).filter((id) => Boolean(id)));
|
|
482
|
+
if (ids.size !== items.length)
|
|
483
|
+
throw new AppError("validation", "PLAN item ids must be unique", 2, { file });
|
|
484
|
+
const invalidDocumentOwners = (plan.document_updates ?? []).filter((entry) => !ids.has(entry.owner));
|
|
485
|
+
if (invalidDocumentOwners.length)
|
|
486
|
+
throw new AppError("document_update_owner_invalid", "Every PLAN document update must have exactly one existing PLAN item id as owner", 2, { file, invalid: invalidDocumentOwners.map((entry) => ({ path: entry.path, owner: entry.owner })), allowed_owners: [...ids].sort() });
|
|
487
|
+
const realized = new Set();
|
|
488
|
+
const checks = new Map();
|
|
489
|
+
for (const check of plan.checks ?? []) {
|
|
490
|
+
if (checks.has(check.id))
|
|
491
|
+
throw new AppError("validation", "PLAN check ids must be unique", 2, { file, check_id: check.id });
|
|
492
|
+
if (check.availability === "planned" && (!check.provided_by || !ids.has(check.provided_by)))
|
|
493
|
+
throw new AppError("planned_check_provider_missing", "A planned check must name a PLAN item that provides it", 2, { file, check_id: check.id, provided_by: check.provided_by ?? null });
|
|
494
|
+
if (check.availability === "planned" && !check.command.startsWith("@check/"))
|
|
495
|
+
throw new AppError("planned_check_requires_alias", "A planned check must declare a new @check/... alias", 2, { file, check_id: check.id, command: check.command });
|
|
496
|
+
if (check.availability === "planned" && !check.definition)
|
|
497
|
+
throw new AppError("planned_check_definition_missing", "A planned check must declare its exact alias definition", 2, { file, check_id: check.id });
|
|
498
|
+
checks.set(check.id, check);
|
|
499
|
+
}
|
|
500
|
+
const ancestors = (itemId, seen = new Set()) => {
|
|
501
|
+
for (const dependency of items.find((item) => item.id === itemId)?.depends_on ?? [])
|
|
502
|
+
if (!seen.has(dependency)) {
|
|
503
|
+
seen.add(dependency);
|
|
504
|
+
ancestors(dependency, seen);
|
|
505
|
+
}
|
|
506
|
+
return seen;
|
|
507
|
+
};
|
|
508
|
+
for (const item of items) {
|
|
509
|
+
for (const dependency of item.depends_on ?? [])
|
|
510
|
+
if (!ids.has(dependency))
|
|
511
|
+
throw new AppError("validation", "PLAN item depends_on references an unknown item", 2, { file, item: item.id, dependency });
|
|
512
|
+
for (const ref of item.requirement_refs ?? []) {
|
|
513
|
+
if (!acceptedRefs.has(ref))
|
|
514
|
+
throw new AppError("validation", "PLAN item references an unknown SPECIFY obligation", 2, { file, item: item.id, ref });
|
|
515
|
+
if (!ownedRefs.has(ref))
|
|
516
|
+
throw new AppError("validation", "PLAN item references an obligation owned by another protocol", 2, { file, item: item.id, ref });
|
|
517
|
+
realized.add(ref);
|
|
518
|
+
}
|
|
519
|
+
for (const checkId of item.verification.check_refs ?? []) {
|
|
520
|
+
const check = checks.get(checkId);
|
|
521
|
+
if (!check)
|
|
522
|
+
throw new AppError("check_reference_unknown", "PLAN item references an unknown check", 2, { file, item: item.id, check_id: checkId });
|
|
523
|
+
if (check.availability === "planned" && check.provided_by !== item.id && !ancestors(item.id).has(check.provided_by))
|
|
524
|
+
throw new AppError("check_consumer_not_ordered_after_provider", "A Work may consume a planned check only after its provider", 2, { file, item: item.id, check_id: checkId, provider: check.provided_by });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const visiting = new Set();
|
|
528
|
+
const done = new Set();
|
|
529
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
530
|
+
const visit = (id) => { if (done.has(id))
|
|
531
|
+
return; if (visiting.has(id))
|
|
532
|
+
throw new AppError("validation", "PLAN item dependencies contain a cycle", 2, { file, item: id }); visiting.add(id); for (const dependency of byId.get(id)?.depends_on ?? [])
|
|
533
|
+
visit(dependency); visiting.delete(id); done.add(id); };
|
|
534
|
+
for (const id of ids)
|
|
535
|
+
visit(id);
|
|
536
|
+
for (const acceptance of plan.acceptance ?? []) {
|
|
537
|
+
if (!acceptance.criterion_id || !acceptance.criterion_id.startsWith("AC-") || !ownedRefs.has(acceptance.criterion_id))
|
|
538
|
+
throw new AppError("validation", "PLAN acceptance references an AC-* owned by this protocol", 2, { file, criterion_id: acceptance.criterion_id });
|
|
539
|
+
for (const id of acceptance.plan_item_ids ?? [])
|
|
540
|
+
if (!ids.has(id))
|
|
541
|
+
throw new AppError("validation", "PLAN acceptance references an unknown item", 2, { file, criterion_id: acceptance.criterion_id, plan_item_id: id });
|
|
542
|
+
for (const id of acceptance.check_refs ?? [])
|
|
543
|
+
if (!checks.has(id))
|
|
544
|
+
throw new AppError("check_reference_unknown", "PLAN acceptance references an unknown check", 2, { file, criterion_id: acceptance.criterion_id, check_id: id });
|
|
545
|
+
}
|
|
546
|
+
for (const obligation of ownedRefs)
|
|
547
|
+
if (!realized.has(obligation))
|
|
548
|
+
throw new AppError("validation", "Every protocol-owned obligation must be realized by a PLAN item", 2, { file, obligation_id: obligation });
|
|
549
|
+
for (const obligation of [...ownedRefs].filter((id) => id.startsWith("AC-")))
|
|
550
|
+
if (!plan.acceptance.some((acceptance) => acceptance.criterion_id === obligation))
|
|
551
|
+
throw new AppError("validation", "Every owned AC-* needs an observable PLAN acceptance entry", 2, { file, criterion_id: obligation });
|
|
552
|
+
}
|