@deksden-com/dd-flow-cli 0.9.0-beta.74 → 0.9.0-beta.79
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 +8 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +7 -11
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +85 -42
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +207 -94
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
2
|
+
import { prepareJsonSchema, prepareOutputFile } from "../cli/input-preparation.js";
|
|
2
3
|
import crypto from "node:crypto";
|
|
3
|
-
import { withStageSettlement } from "./runs.js";
|
|
4
|
+
import { withStageSettlement, prepareFlowRunStageAttachment } from "./runs.js";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { AppError } from "../shared/errors.js";
|
|
@@ -9,7 +10,7 @@ import { requireProjectByRoot } from "./projects.js";
|
|
|
9
10
|
import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
|
|
10
11
|
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
11
12
|
import { validateSchema } from "./schema-validation.js";
|
|
12
|
-
import { bindRunningWorkSession, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection,
|
|
13
|
+
import { bindRunningWorkSession, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, validateWorkBatch } from "./work-registry.js";
|
|
13
14
|
import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
|
|
14
15
|
import { assertStageStartHookEvent } from "./hooks.js";
|
|
15
16
|
import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
@@ -17,36 +18,53 @@ import { readVnextSpecifyResult } from "./vnext-specify.js";
|
|
|
17
18
|
import { nextWorkId } from "./ids.js";
|
|
18
19
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
19
20
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
20
|
-
import { applyExternalStageContext } from "./stage-context.js";
|
|
21
|
+
import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
|
|
21
22
|
import { subagentCapacityKey } from "./vnext-fanout.js";
|
|
22
23
|
export function isVnextPlanRun(context, input) {
|
|
23
24
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
24
25
|
return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
|
|
25
26
|
}
|
|
26
|
-
export function
|
|
27
|
-
ensureWorkRegistry(context);
|
|
27
|
+
export function prepareVnextPlanStart(context, input) {
|
|
28
28
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
29
29
|
const project = requireProjectByRoot(context, projectRoot);
|
|
30
30
|
const run = requireRun(context, projectRoot, input.runId);
|
|
31
31
|
const home = requireHome(run);
|
|
32
|
-
assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage: "plan", projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
33
32
|
const workspaceRoute = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan" });
|
|
34
33
|
const protocols = protocolIds(home);
|
|
35
34
|
if (!protocols.length)
|
|
36
|
-
throw new AppError("
|
|
35
|
+
throw new AppError("runtime_artifact_missing", "PLAN requires accepted PROTOCOLIZE protocols", 1);
|
|
37
36
|
// Static inputs are checked before PLAN creates a Work or materializes a
|
|
38
|
-
// draft.
|
|
37
|
+
// draft. Retained corruption is still an infrastructure failure, not a typo.
|
|
39
38
|
const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan.md"));
|
|
40
39
|
assertProtocolWorkspace(run.workspace_root, protocols);
|
|
41
40
|
const { profile: codeCheckProfile } = readCodeCheckProfile(run.workspace_root);
|
|
42
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]))
|
|
43
42
|
throw new AppError("invalid_work_state", "PLAN already has a running Work", 1, { run_id: run.id });
|
|
43
|
+
prepareFlowRunStageAttachment(context, { projectRoot, runId: run.id, stage: "plan", dir: "03-plan", status: "running", dataSchemaId: "dd-flow/protocol-plan@6" });
|
|
44
44
|
const root = path.join(home, vnextStageDirectory("plan"));
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
if (input.externalContext)
|
|
46
|
+
prepareExternalStageContext({ stageRoot: root, loaded: input.externalContext });
|
|
47
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]);
|
|
48
48
|
if (!rootWork)
|
|
49
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();
|
|
50
68
|
// Imported stage fixtures have a synthetic running root with no prior agent.
|
|
51
69
|
// Bind it on the first real stage start so its child PLAN Work has a real parent.
|
|
52
70
|
if (!context.db.get("SELECT 1 FROM work_sessions WHERE work_id = ? LIMIT 1", [rootWork.work_id])) {
|
|
@@ -55,6 +73,8 @@ export function startVnextPlan(context, input) {
|
|
|
55
73
|
context.db.beginWriteTransaction();
|
|
56
74
|
let planWorkId;
|
|
57
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 });
|
|
58
78
|
planWorkId = nextWorkId(context, project.id, "plan");
|
|
59
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]);
|
|
60
80
|
context.db.exec("COMMIT");
|
|
@@ -66,12 +86,8 @@ export function startVnextPlan(context, input) {
|
|
|
66
86
|
// A Desktop task may start above the materialized repository. Lifecycle
|
|
67
87
|
// prompts therefore hand agents write targets as absolute paths: relative
|
|
68
88
|
// `.memory-bank/...` paths would otherwise silently land in the parent cwd.
|
|
69
|
-
const planPaths = protocols.map((id) => path.join(run.workspace_root, ".memory-bank", "protocol", id, "plan.json"));
|
|
70
|
-
const mapPaths = protocols.map((id) => `${path.join(root, id, "aspect-map.json")}`);
|
|
71
|
-
const owned = protocolOwnership(home, protocols);
|
|
72
|
-
const identities = protocols.map((protocolId) => planIdentity(home, run.id, protocolId, owned.get(protocolId) ?? []));
|
|
73
89
|
planPaths.forEach((file, index) => ensurePlanSkeleton(file, protocols[index], identities[index]));
|
|
74
|
-
mapPaths.forEach((file, index) => ensureAspectMapSkeleton(file, protocols[index], identities[index],
|
|
90
|
+
mapPaths.forEach((file, index) => ensureAspectMapSkeleton(file, protocols[index], identities[index], aspects));
|
|
75
91
|
const finishCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`);
|
|
76
92
|
const pauseCommand = stagePauseCommand(context, { runId: run.id, stage: "plan", workId: planWorkId, projectRoot });
|
|
77
93
|
const pauseCommandTemplate = stagePauseCommandTemplate(pauseCommand);
|
|
@@ -79,21 +95,18 @@ export function startVnextPlan(context, input) {
|
|
|
79
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`,
|
|
80
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`
|
|
81
97
|
]);
|
|
82
|
-
const runVariables = getFlowRunVariables(context, { projectRoot, runId: run.id });
|
|
83
98
|
const measuredCapacity = runVariables.variables[subagentCapacityKey];
|
|
84
|
-
const mergeRequired = runEndsAtMerge(context, projectRoot, run.id);
|
|
85
99
|
const capacityContext = typeof measuredCapacity === "number" && Number.isInteger(measuredCapacity) && measuredCapacity >= 0
|
|
86
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.`
|
|
87
101
|
: "- Reviewer capacity is not qualified yet. PLAN must not qualify or launch reviewers; an external harness controller supplies it before fan-out.";
|
|
88
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.";
|
|
89
|
-
const checkProfile = path.join(run.workspace_root, ".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
90
103
|
const policyMergeAliases = codeCheckProfile?.mandatory_by_gate.merge ?? [];
|
|
91
104
|
const mergeContract = mergeRequired
|
|
92
105
|
? ["<merge_gate_contract>", ...(policyMergeAliases.length
|
|
93
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.`]
|
|
94
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>", ""]
|
|
95
108
|
: [];
|
|
96
|
-
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(
|
|
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, 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");
|
|
97
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 };
|
|
98
111
|
const promptPath = path.join(root, "stage-prompt.md");
|
|
99
112
|
// This explicit final rule supersedes historical pack wording: a flow gate
|
|
@@ -110,6 +123,17 @@ export function startVnextPlan(context, input) {
|
|
|
110
123
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage: "plan", work_id: planWorkId, id: workSessionId, session_id: sessionId });
|
|
111
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 } : {}) };
|
|
112
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
|
+
}
|
|
113
137
|
export function finishVnextPlan(context, input) {
|
|
114
138
|
return withStageSettlement(context, () => finishVnextPlanOwned(context, input));
|
|
115
139
|
}
|
|
@@ -122,23 +146,24 @@ function finishVnextPlanOwned(context, input) {
|
|
|
122
146
|
requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan" });
|
|
123
147
|
const protocols = protocolIds(home);
|
|
124
148
|
if (!protocols.length)
|
|
125
|
-
throw new AppError("
|
|
149
|
+
throw new AppError("runtime_artifact_missing", "PLAN requires accepted PROTOCOLIZE protocols", 1);
|
|
126
150
|
assertProtocolWorkspace(run.workspace_root, protocols);
|
|
127
151
|
const planFiles = protocols.map((id) => path.join(run.workspace_root, ".memory-bank", "protocol", id, "plan.json"));
|
|
128
152
|
const mapFiles = protocols.map((id) => path.join(root, id, "aspect-map.json"));
|
|
129
153
|
const batch = path.join(root, "code-work-batch.json");
|
|
130
|
-
const
|
|
131
|
-
if (failures.length)
|
|
132
|
-
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 });
|
|
133
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]);
|
|
134
158
|
if (!work)
|
|
135
159
|
throw new AppError("invalid_work_state", "PLAN has no running Work", 1);
|
|
136
|
-
const now = context.now();
|
|
137
|
-
const batchChecksum = checksum(batch);
|
|
138
|
-
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]);
|
|
139
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]);
|
|
140
161
|
if (!workSession?.session_id)
|
|
141
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]);
|
|
142
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]);
|
|
143
168
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
144
169
|
const reviewCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --json`);
|
|
@@ -151,12 +176,11 @@ function finishVnextPlanOwned(context, input) {
|
|
|
151
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 } };
|
|
152
177
|
}
|
|
153
178
|
export function validateVnextPlanArtifacts(context, input) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return prepared.failures;
|
|
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;
|
|
158
182
|
}
|
|
159
|
-
/** Validate in
|
|
183
|
+
/** Validate in memory; publish only after the caller accepts its inputs. */
|
|
160
184
|
export function prepareVnextPlanArtifacts(context, input) {
|
|
161
185
|
const workspaceRoot = input.workspaceRoot ?? input.projectRoot;
|
|
162
186
|
const planFiles = input.protocols.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "plan.json"));
|
|
@@ -175,10 +199,9 @@ export function prepareVnextPlanArtifacts(context, input) {
|
|
|
175
199
|
for (const [index, file] of planFiles.entries()) {
|
|
176
200
|
const protocolId = input.protocols[index];
|
|
177
201
|
try {
|
|
178
|
-
|
|
179
|
-
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 });
|
|
180
203
|
assertPlanIdentity(value, planIdentity(input.home, input.runId, protocolId, ownership.get(protocolId) ?? []), file);
|
|
181
|
-
validatePlanSemantics(file, new Set(ownership.get(protocolId) ?? []), obligations);
|
|
204
|
+
validatePlanSemantics(file, value, new Set(ownership.get(protocolId) ?? []), obligations);
|
|
182
205
|
validateCodeCheckCommands(workspaceRoot, value.checks.filter((check) => check.availability === "available").map((check) => check.command));
|
|
183
206
|
for (const check of value.checks.filter((item) => item.availability === "available"))
|
|
184
207
|
validateCheckDeclaration(workspaceRoot, check);
|
|
@@ -194,22 +217,18 @@ export function prepareVnextPlanArtifacts(context, input) {
|
|
|
194
217
|
}
|
|
195
218
|
}
|
|
196
219
|
for (const file of mapFiles) {
|
|
197
|
-
const temporary = `${file}.tmp-${crypto.randomUUID()}`;
|
|
198
220
|
try {
|
|
199
|
-
const
|
|
200
|
-
|
|
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)
|
|
201
224
|
throw new AppError("validation", "Accepted aspect-map requires normalization; regenerate it in PLAN", 2, { file });
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
validateAspectMap(temporary, input.protocols, workspaceRoot);
|
|
225
|
+
const value = JSON.parse(bytes);
|
|
226
|
+
validateAspectMap(file, input.protocols, workspaceRoot, value);
|
|
205
227
|
publications.set(file, bytes);
|
|
206
228
|
}
|
|
207
229
|
catch (error) {
|
|
208
230
|
failures.push(validationFailure(file, error));
|
|
209
231
|
}
|
|
210
|
-
finally {
|
|
211
|
-
fs.rmSync(temporary, { force: true });
|
|
212
|
-
}
|
|
213
232
|
}
|
|
214
233
|
if (!failures.length) {
|
|
215
234
|
try {
|
|
@@ -221,26 +240,22 @@ export function prepareVnextPlanArtifacts(context, input) {
|
|
|
221
240
|
}
|
|
222
241
|
}
|
|
223
242
|
if (!failures.length) {
|
|
224
|
-
const temporaryBatch = `${batch}.tmp-${crypto.randomUUID()}`;
|
|
225
243
|
try {
|
|
226
244
|
const projection = projectCodeWorkBatch({ home: input.home, workspaceRoot, runId: input.runId, plans, protocols: input.protocols, ...(frozenDocumentBaselines ? { frozenDocumentBaselines } : {}) });
|
|
227
245
|
validateProjectedPaths(projection, workspaceRoot, input.home, input.runId);
|
|
228
|
-
|
|
229
|
-
validateSchema({ schemaName: "code-work-batch", file:
|
|
230
|
-
|
|
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);
|
|
231
249
|
if (input.publishBatch !== false) {
|
|
232
|
-
publications.set(batch,
|
|
250
|
+
publications.set(batch, bytes);
|
|
233
251
|
}
|
|
234
|
-
else if (!fs.existsSync(batch) ||
|
|
252
|
+
else if (!fs.existsSync(batch) || crypto.createHash("sha256").update(bytes).digest("hex") !== checksum(batch)) {
|
|
235
253
|
throw new AppError("stale_code_work_batch", "CODE batch no longer matches the accepted semantic PLAN", 2, { batch });
|
|
236
254
|
}
|
|
237
255
|
}
|
|
238
256
|
catch (error) {
|
|
239
257
|
failures.push(validationFailure(batch, error));
|
|
240
258
|
}
|
|
241
|
-
finally {
|
|
242
|
-
fs.rmSync(temporaryBatch, { force: true });
|
|
243
|
-
}
|
|
244
259
|
}
|
|
245
260
|
return { failures, batchChecksum: publications.has(batch) ? crypto.createHash("sha256").update(publications.get(batch)).digest("hex") : null,
|
|
246
261
|
publish: () => {
|
|
@@ -270,8 +285,7 @@ export function prepareVnextPlanArtifacts(context, input) {
|
|
|
270
285
|
export function validateVnextCodeHandoff(context, input) {
|
|
271
286
|
return validateVnextPlanArtifacts(context, {
|
|
272
287
|
...input,
|
|
273
|
-
protocols: protocolIds(input.home)
|
|
274
|
-
publishBatch: false
|
|
288
|
+
protocols: protocolIds(input.home)
|
|
275
289
|
});
|
|
276
290
|
}
|
|
277
291
|
const planTask = "Produce accepted plan.json and aspect-map.json artifacts.";
|
|
@@ -296,16 +310,18 @@ function assertProtocolWorkspace(workspaceRoot, protocols) {
|
|
|
296
310
|
}
|
|
297
311
|
}
|
|
298
312
|
function read(file) { if (!fs.existsSync(file))
|
|
299
|
-
throw new AppError("
|
|
313
|
+
throw new AppError("runtime_artifact_missing", "vNext PLAN prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
|
|
300
314
|
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
301
315
|
function checksum(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
302
|
-
function validateAspectMap(file, protocols, projectRoot) {
|
|
303
|
-
let value;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
}
|
|
309
325
|
}
|
|
310
326
|
const map = value;
|
|
311
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))
|
|
@@ -350,7 +366,13 @@ function stageStartedAt(home, fallback) { try {
|
|
|
350
366
|
catch {
|
|
351
367
|
return fallback;
|
|
352
368
|
} }
|
|
353
|
-
function validationFailure(file, error) {
|
|
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
|
+
}
|
|
354
376
|
function acceptedObligations(home) {
|
|
355
377
|
const file = path.join(home, "01-specify", "specify.json");
|
|
356
378
|
if (!fs.existsSync(file))
|
|
@@ -387,7 +409,7 @@ function ensurePlanSkeleton(file, protocolId, identity) {
|
|
|
387
409
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
388
410
|
writeJson(file, { ...identity, title: "", summary: "", goal: { outcome: "", constraints: [], non_goals: [] }, assessment: {}, decisions: [], document_updates: [], checks: [], items: [], acceptance: [] });
|
|
389
411
|
}
|
|
390
|
-
function ensureAspectMapSkeleton(file, protocolId, identity,
|
|
412
|
+
function ensureAspectMapSkeleton(file, protocolId, identity, aspects) {
|
|
391
413
|
if (fs.existsSync(file))
|
|
392
414
|
return;
|
|
393
415
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -400,10 +422,9 @@ function ensureAspectMapSkeleton(file, protocolId, identity, workspaceRoot) {
|
|
|
400
422
|
catalog_ref: { path: ".memory-bank/dd-flow/mb-sdlc/plan-aspects/aspects" },
|
|
401
423
|
routing: { initial_state: "orchestrator_local", groups: [] },
|
|
402
424
|
review_groups: [],
|
|
403
|
-
aspects:
|
|
425
|
+
aspects: aspects.map((aspectId) => ({ aspect_id: aspectId, planned_artifact_refs: [] }))
|
|
404
426
|
});
|
|
405
427
|
}
|
|
406
|
-
function readPlan(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
407
428
|
function assertPlanIdentity(value, identity, file) {
|
|
408
429
|
for (const key of ["schema_id", "plan_id", "protocol_id"])
|
|
409
430
|
if (value[key] !== identity[key])
|
|
@@ -413,11 +434,12 @@ function assertPlanIdentity(value, identity, file) {
|
|
|
413
434
|
if (JSON.stringify(value.source_refs) !== JSON.stringify(identity.source_refs))
|
|
414
435
|
throw new AppError("validation", "PLAN must preserve CLI-owned source_refs", 2, { file });
|
|
415
436
|
}
|
|
416
|
-
function normalizedAspectMapRefs(file, projectRoot, runHome, runId) {
|
|
437
|
+
function normalizedAspectMapRefs(file, bytes, projectRoot, runHome, runId) {
|
|
417
438
|
projectRoot = canonicalPath(projectRoot);
|
|
418
439
|
runHome = canonicalPath(runHome);
|
|
419
|
-
const bytes = fs.readFileSync(file, "utf8");
|
|
420
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 });
|
|
421
443
|
let changed = false;
|
|
422
444
|
const normalize = (value) => {
|
|
423
445
|
if (!path.isAbsolute(value))
|
|
@@ -551,8 +573,7 @@ function resolvePortablePath(value, workspaceRoot, home, runId) {
|
|
|
551
573
|
}
|
|
552
574
|
function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
553
575
|
function runRef(runId, runHome, file) { return `run://${runId}/${path.relative(runHome, file).split(path.sep).join("/")}`; }
|
|
554
|
-
function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
|
|
555
|
-
const plan = readPlan(file);
|
|
576
|
+
function validatePlanSemantics(file, plan, ownedRefs, acceptedRefs) {
|
|
556
577
|
const items = plan.items ?? [];
|
|
557
578
|
const ids = new Set(items.map((item) => item.id).filter((id) => Boolean(id)));
|
|
558
579
|
if (ids.size !== items.length)
|