@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,499 @@
|
|
|
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 { resolveProjectRoot } from "../storage/paths.js";
|
|
6
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
7
|
+
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunVariables, gitFacts, setRuntimeRunVariable } from "./runs.js";
|
|
8
|
+
import { addWorkBatch, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, workStartCommand } from "./work-registry.js";
|
|
9
|
+
import { validateSchema } from "./schema-validation.js";
|
|
10
|
+
import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
|
|
11
|
+
import { assertStageStartHookEvent } from "./hooks.js";
|
|
12
|
+
import { validateVnextPlanArtifacts } from "./vnext-plan.js";
|
|
13
|
+
import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
14
|
+
import { nextWorkId } from "./ids.js";
|
|
15
|
+
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
16
|
+
import { writeStageReport } from "./stage-report-renderer.js";
|
|
17
|
+
import { applyExternalStageContext } from "./stage-context.js";
|
|
18
|
+
import { capacityProbe, readFanoutDescriptor, subagentCapacityKey, writeFanoutDescriptor } from "./vnext-fanout.js";
|
|
19
|
+
const stage = "plan-review";
|
|
20
|
+
const stageDir = vnextStageDirectory(stage);
|
|
21
|
+
const reviewTask = "Independently review the accepted PLAN and decide whether CODE may open.";
|
|
22
|
+
const capacityProbeFanoutSize = capacityProbe.fanout_size;
|
|
23
|
+
const capacityProbeHoldSeconds = capacityProbe.probe_hold_seconds;
|
|
24
|
+
const capacityProbeDeadlineSeconds = capacityProbe.cleanup_deadline_seconds;
|
|
25
|
+
export function isVnextPlanReviewRun(context, input) {
|
|
26
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
27
|
+
return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
|
|
28
|
+
}
|
|
29
|
+
export function startVnextPlanReview(context, input) {
|
|
30
|
+
ensureWorkRegistry(context);
|
|
31
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
32
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
33
|
+
const run = requireRun(context, projectRoot, input.runId);
|
|
34
|
+
const home = requireHome(run);
|
|
35
|
+
assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
36
|
+
const workspaceRoute = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan-review" });
|
|
37
|
+
const planRoot = path.join(home, "03-plan");
|
|
38
|
+
const root = path.join(home, stageDir);
|
|
39
|
+
const batch = path.join(planRoot, "code-work-batch.json");
|
|
40
|
+
if (!fs.existsSync(path.join(planRoot, "stage-report.json")) || !fs.existsSync(batch))
|
|
41
|
+
throw new AppError("not_found", "PLAN-REVIEW requires the accepted PLAN report and proposed CODE batch", 1);
|
|
42
|
+
const requested = requestedMode(run);
|
|
43
|
+
const protocolIds = protocolIdsForReview(home);
|
|
44
|
+
assertReviewWorkspace(run.workspace_root, protocolIds);
|
|
45
|
+
const groups = reviewGroups(home, run.workspace_root);
|
|
46
|
+
const effective = resolveMode(requested, groups, home, run.workspace_root);
|
|
47
|
+
const existing = context.db.get("SELECT work_id, status FROM works WHERE project_id = ? AND run_id = ? AND task = ? ORDER BY created_at DESC LIMIT 1", [project.id, run.id, reviewTask]);
|
|
48
|
+
if (existing?.status === "running") {
|
|
49
|
+
const promptPath = path.join(root, "stage-prompt.md");
|
|
50
|
+
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
51
|
+
return { ...preparedExisting(context, { projectRoot, projectId: project.id, run, root, workId: existing.work_id, hookEventId: input.hookEventId, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}), requested, effective, groups, batchChecksum: checksum(batch), planChecksum: checksum(path.join(planRoot, "stage-report.json")) }), ...(externalContext ? { external_context: externalContext } : {}) };
|
|
52
|
+
}
|
|
53
|
+
const priorStage = readIndex(run).stage_runs?.find((entry) => entry.stage === stage);
|
|
54
|
+
if (priorStage?.status === "done") {
|
|
55
|
+
const report = readJson(path.join(root, "stage-report.json"));
|
|
56
|
+
const outcome = report.semantic?.plan_review?.outcome;
|
|
57
|
+
if (outcome === "off" || outcome === "accepted")
|
|
58
|
+
return { ok: true, run_id: run.id, stage, outcome: outcome === "off" ? "review_off" : "accepted", ...(report.semantic?.code ? { code: report.semantic.code } : {}), next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) }, idempotent: true };
|
|
59
|
+
throw new AppError("invalid_work_state", "PLAN-REVIEW is already terminal; corrections are applied in the same review Work and are not re-reviewed automatically", 2, { run_id: run.id, outcome });
|
|
60
|
+
}
|
|
61
|
+
fs.mkdirSync(root, { recursive: true });
|
|
62
|
+
const now = context.now();
|
|
63
|
+
const batchChecksum = checksum(batch);
|
|
64
|
+
const planChecksum = checksum(path.join(planRoot, "stage-report.json"));
|
|
65
|
+
if (effective === "off") {
|
|
66
|
+
const failures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIds });
|
|
67
|
+
if (failures.length)
|
|
68
|
+
throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: failures });
|
|
69
|
+
const rootWork = rootWorkId(context, project.id, run.id);
|
|
70
|
+
const promptPath = path.join(root, "stage-prompt.md");
|
|
71
|
+
fs.writeFileSync(promptPath, "PLAN-REVIEW is disabled by the frozen RUN configuration.\n");
|
|
72
|
+
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
73
|
+
bindStageCoordinatorWork(context, { workId: rootWork, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
74
|
+
attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/plan-review-decision@3" });
|
|
75
|
+
const registered = registerCode(context, project.id, run.id, batch);
|
|
76
|
+
const report = reportFor({ run, mode: effective, requested, outcome: "off", groups: [], batchChecksum, planChecksum, code: registered, now, projectRoot, flow: flowCommand(context) });
|
|
77
|
+
writeReport(root, report);
|
|
78
|
+
completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
|
|
79
|
+
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "plan_review_off", nextAction: "start_code" });
|
|
80
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_opened", mode: effective, outcome: "off" });
|
|
81
|
+
return { ok: true, run_id: run.id, stage, outcome: "review_off", ...(externalContext ? { external_context: externalContext } : {}), plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), reason: "No applicable independent review group." }, next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
|
|
82
|
+
}
|
|
83
|
+
const rootWork = rootWorkId(context, project.id, run.id);
|
|
84
|
+
const workId = nextWorkId(context, project.id, "plan-review");
|
|
85
|
+
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)", [workId, project.id, run.id, rootWork, reviewTask, now, now, now]);
|
|
86
|
+
const pauseCommand = stagePauseCommand(context, { runId: run.id, stage, workId, projectRoot });
|
|
87
|
+
const prompt = orchestratorPrompt(context, { projectRoot, run, home, root, workId, requested, effective, groups, batchChecksum, planChecksum, workspaceRoute, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand) });
|
|
88
|
+
const promptPath = path.join(root, "stage-prompt.md");
|
|
89
|
+
fs.writeFileSync(promptPath, prompt);
|
|
90
|
+
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
91
|
+
let binding;
|
|
92
|
+
try {
|
|
93
|
+
binding = bindStageCoordinatorWork(context, { workId, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
context.db.run("DELETE FROM works WHERE work_id = ? AND status = 'running'", [workId]);
|
|
97
|
+
fs.rmSync(promptPath, { force: true });
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/plan-review-decision@3" });
|
|
101
|
+
markReviewPending(home, groups);
|
|
102
|
+
const workSessionId = String(binding.work_session_id);
|
|
103
|
+
const sessionId = String(binding.session_id);
|
|
104
|
+
const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: workId, dispatch: "plan-review", capacity_required: true });
|
|
105
|
+
writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: currentPlanRevision(home, run.workspace_root), plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
|
|
106
|
+
refreshRunWorkProjection(context, project.id, run.id);
|
|
107
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage, work_id: workId, id: workSessionId, session_id: sessionId });
|
|
108
|
+
return { ok: true, run_id: run.id, stage, outcome: "review_required", work_id: workId, id: workSessionId, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), groups }, next: { dispatch_command: dispatchCommand(context, run.id, projectRoot), finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
|
|
109
|
+
}
|
|
110
|
+
export function dispatchVnextPlanReview(context, input) {
|
|
111
|
+
ensureWorkRegistry(context);
|
|
112
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
113
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
114
|
+
const run = requireRun(context, projectRoot, input.runId);
|
|
115
|
+
const home = requireHome(run);
|
|
116
|
+
const root = path.join(home, stageDir);
|
|
117
|
+
const contextFile = readJson(path.join(root, "work-context.json"));
|
|
118
|
+
const groups = contextFile.groups ?? [];
|
|
119
|
+
if (!groups.length || contextFile.system?.effective_mode === "off")
|
|
120
|
+
throw new AppError("invalid_work_state", "PLAN-REVIEW dispatch requires an enabled review stage", 2);
|
|
121
|
+
const parent = context.db.get("SELECT work_id, status FROM works WHERE project_id = ? AND run_id = ? AND task = ? ORDER BY created_at DESC LIMIT 1", [project.id, run.id, reviewTask]);
|
|
122
|
+
if (!parent || parent.status !== "running")
|
|
123
|
+
throw new AppError("invalid_work_state", "PLAN-REVIEW has no running parent Work", 2);
|
|
124
|
+
const capacity = ensureReviewCapacity(context, { projectRoot, runId: run.id });
|
|
125
|
+
if (capacity.pending)
|
|
126
|
+
return capacity.response;
|
|
127
|
+
if (capacity.availableSlots < 1)
|
|
128
|
+
throw new AppError("no_subagent_capacity", "PLAN-REVIEW requires one fresh reviewer Session but no probe launch succeeded", 1, { run_id: run.id });
|
|
129
|
+
const existing = context.db.all("SELECT work_id, task, status, result, created_at, launch_policy FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ?", [project.id, run.id, parent.work_id]);
|
|
130
|
+
const latest = (group) => existing.find((item) => item.task === group.task);
|
|
131
|
+
const pending = groups.filter((group) => !latest(group));
|
|
132
|
+
if (pending.length) {
|
|
133
|
+
const file = path.join(root, ".dispatch.json");
|
|
134
|
+
writeJson(file, { works: pending.map((group) => ({ key: group.key, task: group.task, depends_on: group.depends_on, launch_policy: "fresh_agent_required", result_schema: "dd-flow/plan-review-result@1", payload: { kind: "plan-review", group: { key: group.key, aspect_ids: group.aspect_ids } } })) });
|
|
135
|
+
try {
|
|
136
|
+
addWorkBatch(context, { parentWorkId: parent.work_id, file });
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
fs.rmSync(file, { force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const created = context.db.all("SELECT work_id, task, status, launch_policy, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ?", [project.id, run.id, parent.work_id]);
|
|
143
|
+
const reviews = groups.map((group) => { const work = created.find((item) => item.task === group.task); if (!work)
|
|
144
|
+
throw new AppError("runtime_missing", "Reviewer Work was not created", 1, { group: group.key }); const ready = work.status === "created" && isReviewWorkReady(context, work.work_id); return { group: group.key, work_id: work.work_id, status: work.status, depends_on: group.depends_on, ready, ...(ready ? { start_command: workStartCommand(context, { ...work, project_id: project.id, run_id: run.id }) } : {}) }; });
|
|
145
|
+
refreshRunWorkProjection(context, project.id, run.id);
|
|
146
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_dispatched", work_id: parent.work_id, groups: reviews.map((item) => ({ group: item.group, work_id: item.work_id })) });
|
|
147
|
+
const waves = plannedReviewWaves(groups, capacity.availableSlots);
|
|
148
|
+
return { ok: true, run_id: run.id, stage, capacity: { available_slots: capacity.availableSlots }, routing: { group_count: groups.length, planned_waves: waves.length, waves, preference: "Use one wave when semantic compatibility permits; hard dependencies create later waves." }, review_works: reviews, next_action: "launch_ready_reviews" };
|
|
149
|
+
}
|
|
150
|
+
// Capacity is a RUN fact shared by every fan-out stage.
|
|
151
|
+
const reviewCapacityKey = subagentCapacityKey;
|
|
152
|
+
/** Capacity is observed immediately before this stage dispatches reviewers. */
|
|
153
|
+
export function recordVnextPlanReviewCapacity(context, input) {
|
|
154
|
+
if (!Number.isInteger(input.availableSlots) || input.availableSlots < 0 || input.availableSlots > 15) {
|
|
155
|
+
throw new AppError("validation", "--available-slots must be an integer from 0 to 15", 2);
|
|
156
|
+
}
|
|
157
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
158
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
159
|
+
const run = requireRun(context, projectRoot, input.runId);
|
|
160
|
+
const variables = getFlowRunVariables(context, { projectRoot, runId: run.id });
|
|
161
|
+
const prior = variables.variables[reviewCapacityKey];
|
|
162
|
+
if (typeof prior === "number") {
|
|
163
|
+
if (prior === input.availableSlots)
|
|
164
|
+
return { ok: true, run_id: run.id, capacity: { available_slots: prior, source: "bounded_probe", reused: true } };
|
|
165
|
+
setRuntimeRunVariable(context, { projectRoot, runId: run.id, key: reviewCapacityKey, value: input.availableSlots });
|
|
166
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "subagent_capacity_refreshed", previous_available_slots: prior, available_slots: input.availableSlots, fanout_size: capacityProbeFanoutSize });
|
|
167
|
+
return { ok: true, run_id: run.id, capacity: { available_slots: input.availableSlots, previous_available_slots: prior, source: "bounded_probe", refreshed: true } };
|
|
168
|
+
}
|
|
169
|
+
setRuntimeRunVariable(context, { projectRoot, runId: run.id, key: reviewCapacityKey, value: input.availableSlots });
|
|
170
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "subagent_capacity_observed", available_slots: input.availableSlots, fanout_size: capacityProbeFanoutSize });
|
|
171
|
+
return { ok: true, run_id: run.id, capacity: { available_slots: input.availableSlots, source: "one_shot_fanout", fanout_size: capacityProbeFanoutSize } };
|
|
172
|
+
}
|
|
173
|
+
export function finishVnextPlanReview(context, input) {
|
|
174
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
175
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
176
|
+
const run = requireRun(context, projectRoot, input.runId);
|
|
177
|
+
const home = requireHome(run);
|
|
178
|
+
const root = path.join(home, stageDir);
|
|
179
|
+
const planRoot = path.join(home, "03-plan");
|
|
180
|
+
const batch = path.join(planRoot, "code-work-batch.json");
|
|
181
|
+
const parent = context.db.get("SELECT work_id, status FROM works WHERE project_id = ? AND run_id = ? AND task = ? ORDER BY created_at DESC LIMIT 1", [project.id, run.id, reviewTask]);
|
|
182
|
+
if (!parent || parent.status !== "running")
|
|
183
|
+
throw new AppError("invalid_work_state", "PLAN-REVIEW has no running parent Work", 2);
|
|
184
|
+
const decisionPath = path.resolve(input.decisionFile ?? path.join(root, "decision.json"));
|
|
185
|
+
const decision = readDecision(context, projectRoot, run.id, decisionPath);
|
|
186
|
+
const children = context.db.all("SELECT work_id, task, status, result, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND task <> 'Capacity probe: return ready and finish this Work.'", [project.id, run.id, parent.work_id]);
|
|
187
|
+
const contextFile = readJson(path.join(root, "work-context.json"));
|
|
188
|
+
const groups = contextFile.groups ?? [];
|
|
189
|
+
const reviewedPlanRevision = contextFile.system?.plan_revision;
|
|
190
|
+
if (!Number.isInteger(reviewedPlanRevision) || reviewedPlanRevision < 1)
|
|
191
|
+
throw new AppError("runtime_missing", "PLAN-REVIEW starting revision is unavailable", 1);
|
|
192
|
+
const planRevision = currentPlanRevision(home, run.workspace_root);
|
|
193
|
+
if (["blocked", "failed", "cancelled"].includes(decision.outcome)) {
|
|
194
|
+
preserveDecisionReceipt(root, decisionPath);
|
|
195
|
+
return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision });
|
|
196
|
+
}
|
|
197
|
+
if (children.some((child) => child.status === "created" || child.status === "running"))
|
|
198
|
+
throw new AppError("worker_jobs_incomplete", "PLAN-REVIEW finish requires all reviewer Works to settle", 1, { works: children.map((child) => ({ work_id: child.work_id, status: child.status })) });
|
|
199
|
+
const latestChildren = latestReviewChildren(children);
|
|
200
|
+
const evidenceFailures = reviewerEvidenceFailures(context, { projectId: project.id, parentWorkId: parent.work_id, children: latestChildren, groups, planRevision: reviewedPlanRevision });
|
|
201
|
+
if (evidenceFailures.length)
|
|
202
|
+
throw new AppError("review_evidence_invalid", "PLAN-REVIEW reviewer evidence is incomplete, stale, or not isolated", 2, { errors: evidenceFailures });
|
|
203
|
+
if (decision.outcome !== "accepted")
|
|
204
|
+
throw new AppError("validation", "Unsupported PLAN-REVIEW decision outcome", 2, { outcome: decision.outcome });
|
|
205
|
+
// A completed reviewer can legitimately report `blocked` when an upstream
|
|
206
|
+
// plan uncertainty prevents it from approving its aspect. That is evidence
|
|
207
|
+
// for the coordinator to classify and fix in this one review pass, not an
|
|
208
|
+
// infrastructure failure or a reason to start another review cycle.
|
|
209
|
+
const needsCorrection = latestChildren.some((child) => ["needs_changes", "blocked"].includes(reviewerVerdict(child.result)));
|
|
210
|
+
if (latestChildren.some((child) => child.status !== "completed"))
|
|
211
|
+
throw new AppError("blocked", "PLAN-REVIEW cannot accept incomplete reviewer evidence", 2);
|
|
212
|
+
const materialFindingIds = canonicalReviewerFindings(latestChildren).filter(({ finding }) => ["blocker", "high", "medium"].includes(finding.severity)).map(({ finding_ref }) => finding_ref);
|
|
213
|
+
const decidedFindingIds = new Set(decision.finding_decisions.map((finding) => finding.finding_ref).filter((id) => Boolean(id)));
|
|
214
|
+
if (new Set(materialFindingIds).size !== materialFindingIds.length || materialFindingIds.some((id) => !decidedFindingIds.has(id)))
|
|
215
|
+
throw new AppError("review_evidence_invalid", "Every material reviewer finding needs one stable decision", 2, { material_finding_ids: materialFindingIds, decided_finding_ids: [...decidedFindingIds] });
|
|
216
|
+
if (needsCorrection) {
|
|
217
|
+
if (decision.correction.status !== "applied")
|
|
218
|
+
throw new AppError("validation", "Reviewer findings require an in-place correction receipt", 2);
|
|
219
|
+
if (decision.correction.previous_plan_revision !== reviewedPlanRevision || planRevision <= reviewedPlanRevision || decision.correction.changed_paths.length === 0)
|
|
220
|
+
throw new AppError("validation", "Applied PLAN correction must advance revision and list changed paths", 2, { reviewed_plan_revision: reviewedPlanRevision, final_plan_revision: planRevision });
|
|
221
|
+
}
|
|
222
|
+
else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision) {
|
|
223
|
+
throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2);
|
|
224
|
+
}
|
|
225
|
+
const planFailures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
|
|
226
|
+
if (planFailures.length)
|
|
227
|
+
throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors: planFailures });
|
|
228
|
+
if (needsCorrection) {
|
|
229
|
+
const initialBatchChecksum = contextFile.system?.batch_checksum;
|
|
230
|
+
if (!initialBatchChecksum || checksum(batch) === initialBatchChecksum)
|
|
231
|
+
throw new AppError("stale_code_work_batch", "Applied PLAN correction must regenerate the proposed CODE batch", 2, { batch, initial_batch_checksum: initialBatchChecksum });
|
|
232
|
+
if (decision.correction.changed_paths.some((item) => item.endsWith("code-work-batch.json")))
|
|
233
|
+
throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths });
|
|
234
|
+
}
|
|
235
|
+
preserveDecisionReceipt(root, decisionPath);
|
|
236
|
+
const finalPlanChecksum = checksum(path.join(run.workspace_root, ".memory-bank", "protocol", protocolIdsForReview(home)[0], "plan.json"));
|
|
237
|
+
const finalBatchChecksum = checksum(batch);
|
|
238
|
+
const registered = registerCode(context, project.id, run.id, batch);
|
|
239
|
+
const now = context.now();
|
|
240
|
+
settleReviewParent(context, { projectRoot, projectId: project.id, runId: run.id, parentWorkId: parent.work_id, status: "completed", result: decision.summary, reason: "plan_review_completed", resultPath: path.join(root, "stage-report.json") });
|
|
241
|
+
const report = reportFor({ run, mode: contextFile.system?.effective_mode ?? "standard", requested: contextFile.system?.requested_mode ?? "auto", outcome: "accepted", groups, batchChecksum: contextFile.system?.batch_checksum ?? finalBatchChecksum, planChecksum: contextFile.system?.plan_checksum ?? finalPlanChecksum, finalBatchChecksum, finalPlanChecksum, initialPlanRevision: reviewedPlanRevision, finalPlanRevision: planRevision, code: registered, now, projectRoot, decision, children: latestChildren, flow: flowCommand(context) });
|
|
242
|
+
applyReviewResults(home, latestChildren, decision);
|
|
243
|
+
writeReport(root, report);
|
|
244
|
+
refreshRunWorkProjection(context, project.id, run.id);
|
|
245
|
+
completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
|
|
246
|
+
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "plan_review_accepted", nextAction: "start_code" });
|
|
247
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_accepted", work_id: parent.work_id, code: registered });
|
|
248
|
+
return { ok: true, run_id: run.id, stage, outcome: "accepted", code: registered, next_action: "start_code", next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
|
|
249
|
+
}
|
|
250
|
+
function preparedExisting(context, input) {
|
|
251
|
+
const promptPath = path.join(input.root, "stage-prompt.md");
|
|
252
|
+
const binding = bindStageCoordinatorWork(context, { workId: input.workId, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(input.root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
253
|
+
const orchestration = readFanoutDescriptor(input.root) ?? writeFanoutDescriptor(input.root, { stage, parent_work_id: input.workId, dispatch: "plan-review", capacity_required: true });
|
|
254
|
+
return { ok: true, resumed: true, run_id: input.run.id, stage, outcome: "review_required", work_id: input.workId, id: binding.work_session_id, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), orchestration, plan_review: { requested_mode: input.requested, effective_mode: input.effective, policy_source: policySource(input.run), groups: input.groups }, next: { dispatch_command: dispatchCommand(context, input.run.id, input.projectRoot), finish_command: finishCommand(context, input.run.id, input.projectRoot, path.join(input.root, "decision.json")) } };
|
|
255
|
+
}
|
|
256
|
+
function orchestratorPrompt(context, input) {
|
|
257
|
+
const template = read(path.join(input.projectRoot, ".memory-bank", "dd-flow", "vnext", "plan-review.md"));
|
|
258
|
+
const decision = path.join(input.root, "decision.json");
|
|
259
|
+
const revision = currentPlanRevision(input.home, input.run.workspace_root);
|
|
260
|
+
const workspaceContract = ["<workspace_contract>", `- route: ${input.workspaceRoute.route}`, `- feature branch: ${input.workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${input.workspaceRoute.base_ref ?? "not applicable"}`, `- read/write workspace: ${input.run.workspace_root}`, "The CLI verified this frozen route. All plan and correction writes belong in the named workspace; project root remains only the stable lifecycle identity. Do not create, switch, merge or delete branches/worktrees.", "</workspace_contract>"].join("\n");
|
|
261
|
+
return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch requests capacity, run exactly one concurrent fan-out of ${capacityProbeFanoutSize} probes. This measures the harness limit; it is not a task to obtain ${capacityProbeFanoutSize} successful probes. Start #01…#${capacityProbeFanoutSize} once, all together, using all-settled handling so one rejection does not hide the other outcomes. A rejected launch is expected evidence. Never retry, replace, or add a probe. Each started probe calls no tools, reads no files, creates no children, waits ${capacityProbeHoldSeconds} seconds, then returns exactly AGENT-NN. For cleanup, wait at most ${capacityProbeDeadlineSeconds} seconds from the first launch, terminate every unfinished probe, then release every finished probe session that the harness permits. Only after that cleanup record the number of launches that started successfully, not the number of replacement attempts or late completions: ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<successful-initial-launches>")}. Capacity probes are not Works and are never registered.`, "After dispatch, launch at most the measured capacity at once. If more independent reviewer Works remain, wait for the current wave to settle, then start the unchanged queued Works in the next wave. A reviewer launch rejected before it starts is not review evidence: do not create a replacement; wait for a running wave to settle and start that same queued Work. Each reviewer must be a genuinely fresh harness child Session. The lifecycle adapter binds that observed Session; do not bind or supply a Session ID manually. Reviewers are read-only and must not create children. As soon as a reviewer result is accepted, release that reviewer Session when the harness permits; do not let finished disposable workers occupy slots before the next wave.", "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input 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", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted | blocked | failed | cancelled", summary: "Concise evidence-backed final decision.", finding_decisions: [{ finding_ref: "WRK-001-review/FIND-001", decision: "accepted_fix | rejected | deferred_as_DEF | requires_user | duplicate", reason: "Why." }], correction: { status: "not_required | applied", previous_plan_revision: revision, changed_paths: [], summary: "No material correction was needed, or summarize the applied correction." } }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
|
|
262
|
+
}
|
|
263
|
+
function reviewGroups(home, workspaceRoot) {
|
|
264
|
+
const root = path.join(home, "03-plan");
|
|
265
|
+
const reports = JSON.parse(fs.readFileSync(path.join(home, "02-protocolize", "stage-report.json"), "utf8"));
|
|
266
|
+
const protocols = reports.semantic?.acceptance ?? [];
|
|
267
|
+
const groups = [];
|
|
268
|
+
for (const protocolId of protocols) {
|
|
269
|
+
const mapPath = path.join(root, protocolId, "aspect-map.json");
|
|
270
|
+
const map = readJson(mapPath);
|
|
271
|
+
const review = map.review_groups?.filter((group) => group.id && group.aspect_ids?.length).map((group) => ({ id: group.id, aspect_ids: group.aspect_ids })) ?? [];
|
|
272
|
+
const groupForAspect = new Map(review.flatMap((group) => group.aspect_ids.map((aspectId) => [aspectId, `${protocolId}/${group.id}`])));
|
|
273
|
+
for (const group of review) {
|
|
274
|
+
const key = `${protocolId}/${group.id}`;
|
|
275
|
+
const dependencies = new Set();
|
|
276
|
+
for (const aspectId of group.aspect_ids)
|
|
277
|
+
for (const dependency of aspectDependencies(workspaceRoot, aspectId)) {
|
|
278
|
+
const dependencyGroup = groupForAspect.get(dependency);
|
|
279
|
+
if (dependencyGroup && dependencyGroup !== key)
|
|
280
|
+
dependencies.add(dependencyGroup);
|
|
281
|
+
}
|
|
282
|
+
groups.push({ key, protocol_id: protocolId, aspect_ids: group.aspect_ids, depends_on: [...dependencies].sort(), task: `Review PLAN group ${key}: ${group.aspect_ids.join(", ")}. Read run://${path.basename(home)}/03-plan/${protocolId}/aspect-map.json and the referenced plan. Try to falsify ambiguous behavior and missing verification. For every selected check that starts fixture/reset, service, or client processes, verify that they share the intended data/configuration world and that any runtime entrypoint requiring change is explicit in a Work task and verification and ordered before its consumer. planned_write_areas are coordination hints, not ownership. Do not edit plan or product files. Return one aspect entry for every assigned aspect, with exact evidence references and concise findings. Finish with the JSON contract supplied in your work prompt.` });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return groups;
|
|
286
|
+
}
|
|
287
|
+
function aspectDependencies(workspaceRoot, aspectId) {
|
|
288
|
+
const file = path.join(workspaceRoot, ".memory-bank", "dd-flow", "mb-sdlc", "plan-aspects", "aspects", `${aspectId}.md`);
|
|
289
|
+
try {
|
|
290
|
+
const match = fs.readFileSync(file, "utf8").match(/^depends_on:\s*\[([^\]]*)\]/m);
|
|
291
|
+
return match?.[1]?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function isReviewWorkReady(context, workId) {
|
|
298
|
+
const row = context.db.get("SELECT depends_on_json FROM works WHERE work_id = ?", [workId]);
|
|
299
|
+
const dependencies = row ? JSON.parse(row.depends_on_json) : [];
|
|
300
|
+
return dependencies.every((dependency) => context.db.get("SELECT status FROM works WHERE work_id = ?", [dependency])?.status === "completed");
|
|
301
|
+
}
|
|
302
|
+
function plannedReviewWaves(groups, capacity) {
|
|
303
|
+
const remaining = new Map(groups.map((group) => [group.key, group]));
|
|
304
|
+
const completed = new Set();
|
|
305
|
+
const waves = [];
|
|
306
|
+
while (remaining.size) {
|
|
307
|
+
const ready = [...remaining.values()].filter((group) => group.depends_on.every((dependency) => completed.has(dependency)));
|
|
308
|
+
if (!ready.length)
|
|
309
|
+
throw new AppError("validation", "PLAN-REVIEW group dependencies contain a cycle", 2);
|
|
310
|
+
for (let offset = 0; offset < ready.length; offset += capacity) {
|
|
311
|
+
const wave = ready.slice(offset, offset + capacity).map((group) => group.key);
|
|
312
|
+
waves.push(wave);
|
|
313
|
+
for (const key of wave) {
|
|
314
|
+
remaining.delete(key);
|
|
315
|
+
completed.add(key);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return waves;
|
|
320
|
+
}
|
|
321
|
+
export function capacityProbeResponse(context, input) {
|
|
322
|
+
return { ok: true, run_id: input.runId, stage, outcome: "capacity_probe_required", capacity_probe: { ...capacityProbe, controller_instruction: "Launch exactly one concurrent batch of 15 independent leaf probes. Use all-settled behavior: every initial launch is attempted once, and a launch rejection is expected evidence of the current limit. Do not retry, replace, or add probes. Count the initial launches that returned a live session handle. Successful probes must not call tools, read files, create children or run dd-flow; they wait 60 seconds then return exactly AGENT-NN. Wait only for cleanup, at most 180 seconds from first launch; terminate unfinished probes, then close/delete every finished probe session that the harness permits. Record only the initial successful-launch count after cleanup." }, next_action: "run_one_shot_capacity_probe_then_retry_dispatch", next: { record_command: capacityRecordCommand(context, input.runId, input.projectRoot, "<successful-initial-launches>"), retry_command: dispatchCommand(context, input.runId, input.projectRoot) } };
|
|
323
|
+
}
|
|
324
|
+
function ensureReviewCapacity(context, input) {
|
|
325
|
+
const variables = getFlowRunVariables(context, { projectRoot: input.projectRoot, runId: input.runId });
|
|
326
|
+
const known = variables.variables[reviewCapacityKey];
|
|
327
|
+
if (typeof known === "number" && Number.isInteger(known) && known >= 0)
|
|
328
|
+
return { pending: false, availableSlots: known };
|
|
329
|
+
return { pending: true, response: capacityProbeResponse(context, { projectRoot: input.projectRoot, runId: input.runId }) };
|
|
330
|
+
}
|
|
331
|
+
function protocolIdsForReview(home) {
|
|
332
|
+
const report = readJson(path.join(home, "02-protocolize", "stage-report.json"));
|
|
333
|
+
return Array.isArray(report.semantic?.acceptance) ? report.semantic.acceptance.filter((value) => typeof value === "string") : [];
|
|
334
|
+
}
|
|
335
|
+
/** The aspect map is a PLAN routing manifest; reviewer state lives in Work results. */
|
|
336
|
+
function markReviewPending(_home, _groups) { }
|
|
337
|
+
/** Review evidence is immutable child Work output plus the stage decision. */
|
|
338
|
+
function applyReviewResults(_home, _children, _decision) { }
|
|
339
|
+
function canonicalReviewerFindings(children) { return children.flatMap((child) => (reviewerResult(child.result)?.aspects ?? []).flatMap((aspect) => aspect.findings.map((finding) => ({ finding_ref: `${child.work_id}/${finding.finding_id}`, finding })))); }
|
|
340
|
+
function resolveMode(requested, groups, home, projectRoot) {
|
|
341
|
+
if (requested !== "auto")
|
|
342
|
+
return requested;
|
|
343
|
+
if (!groups.length)
|
|
344
|
+
return "off";
|
|
345
|
+
const plans = protocolIdsForReview(home).map((id) => readJson(path.join(projectRoot, ".memory-bank", "protocol", id, "plan.json")));
|
|
346
|
+
return plans.some((plan) => plan.assessment?.failure_impact?.level === "high" || plan.assessment?.solution_uncertainty?.level === "high") ? "deep" : "standard";
|
|
347
|
+
}
|
|
348
|
+
function assertReviewWorkspace(workspaceRoot, protocols) {
|
|
349
|
+
for (const id of protocols) {
|
|
350
|
+
const plan = path.join(workspaceRoot, ".memory-bank", "protocol", id, "plan.json");
|
|
351
|
+
if (!fs.existsSync(plan))
|
|
352
|
+
throw new AppError("workspace_plan_missing", "PLAN-REVIEW requires PLAN artifacts in the registered RUN workspace", 1, { workspace_root: workspaceRoot, protocol_id: id, expected: plan });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function readIndex(run) { try {
|
|
356
|
+
return JSON.parse(run.index_json);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return {};
|
|
360
|
+
} }
|
|
361
|
+
function requestedMode(run) { try {
|
|
362
|
+
const index = JSON.parse(run.index_json);
|
|
363
|
+
const mode = index.settings?.plan_review?.mode;
|
|
364
|
+
return mode && ["auto", "off", "standard", "deep"].includes(mode) ? mode : "auto";
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return "auto";
|
|
368
|
+
} }
|
|
369
|
+
function policySource(run) { try {
|
|
370
|
+
const index = JSON.parse(run.index_json);
|
|
371
|
+
return index.settings?.plan_review?.source ?? "default";
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return "default";
|
|
375
|
+
} }
|
|
376
|
+
function rootWorkId(context, projectId, runId) { const root = 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", [projectId, runId]); if (!root)
|
|
377
|
+
throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return root.work_id; }
|
|
378
|
+
function registerCode(context, projectId, runId, batch) { return addWorkBatch(context, { parentWorkId: rootWorkId(context, projectId, runId), file: batch }); }
|
|
379
|
+
function readDecision(context, projectRoot, runId, file) { validateSchema({ schemaName: "plan-review-decision", file, projectRoot, runId, ddFlowHome: context.ddFlowHome }); return readJson(file); }
|
|
380
|
+
function preserveDecisionReceipt(root, source) {
|
|
381
|
+
const receipt = path.join(root, "decision.receipt.json");
|
|
382
|
+
const bytes = fs.readFileSync(source);
|
|
383
|
+
if (fs.existsSync(receipt)) {
|
|
384
|
+
if (!bytes.equals(fs.readFileSync(receipt)))
|
|
385
|
+
throw new AppError("decision_receipt_conflict", "The immutable PLAN-REVIEW decision receipt already contains a different payload", 2, { receipt });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
fs.writeFileSync(receipt, bytes);
|
|
389
|
+
}
|
|
390
|
+
function reviewerResult(result) { try {
|
|
391
|
+
const value = JSON.parse(result ?? "");
|
|
392
|
+
return Array.isArray(value.aspects) ? value : null;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return null;
|
|
396
|
+
} }
|
|
397
|
+
function reviewerVerdict(result) { return reviewerResult(result)?.overall_verdict ?? ""; }
|
|
398
|
+
function latestReviewChildren(children) { return children; }
|
|
399
|
+
function currentPlanRevision(home, projectRoot) {
|
|
400
|
+
const revisions = protocolIdsForReview(home).map((id) => Number(readJson(path.join(projectRoot, ".memory-bank", "protocol", id, "plan.json")).revision));
|
|
401
|
+
if (!revisions.length || revisions.some((value) => !Number.isInteger(value) || value < 1))
|
|
402
|
+
throw new AppError("runtime_missing", "Accepted PLAN revision is unavailable", 1);
|
|
403
|
+
return Math.max(...revisions);
|
|
404
|
+
}
|
|
405
|
+
function reviewerEvidenceFailures(context, input) {
|
|
406
|
+
const parent = context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [input.parentWorkId]);
|
|
407
|
+
const failures = [];
|
|
408
|
+
for (const group of input.groups) {
|
|
409
|
+
const child = input.children.find((item) => item.task === group.task);
|
|
410
|
+
if (!child || child.status !== "completed") {
|
|
411
|
+
failures.push({ group: group.key, error: "reviewer_work_not_completed" });
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const result = reviewerResult(child.result);
|
|
415
|
+
if (!result || result.plan_revision !== input.planRevision) {
|
|
416
|
+
failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_result_missing_or_stale" });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const actual = result.aspects.map((item) => item.aspect_id);
|
|
420
|
+
if (actual.length !== group.aspect_ids.length || new Set(actual).size !== actual.length || group.aspect_ids.some((id) => !actual.includes(id)) || result.aspects.some((item) => item.evidence_refs.length === 0))
|
|
421
|
+
failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_aspect_coverage_invalid", expected_aspects: group.aspect_ids, actual_aspects: actual });
|
|
422
|
+
const workSession = context.db.get("SELECT session_id, status FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [child.work_id]);
|
|
423
|
+
if (!workSession?.session_id || workSession.status !== "completed" || workSession.session_id === parent?.session_id)
|
|
424
|
+
failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_session_not_fresh", parent_session_id: parent?.session_id ?? null, reviewer_session_id: workSession?.session_id ?? null });
|
|
425
|
+
}
|
|
426
|
+
return failures;
|
|
427
|
+
}
|
|
428
|
+
function settleReviewParent(context, input) {
|
|
429
|
+
const now = context.now();
|
|
430
|
+
context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [input.status, input.result, now, now, input.parentWorkId]);
|
|
431
|
+
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", [input.parentWorkId]);
|
|
432
|
+
if (!workSession)
|
|
433
|
+
return;
|
|
434
|
+
context.db.run("UPDATE work_sessions SET status = ?, result_path = COALESCE(?, result_path), completed_at = ?, updated_at = ? WHERE id = ?", [input.status, input.resultPath ?? null, now, now, workSession.id]);
|
|
435
|
+
}
|
|
436
|
+
function finishTerminalReview(context, input) {
|
|
437
|
+
const now = context.now();
|
|
438
|
+
const childStatus = input.decision.outcome === "cancelled" ? "cancelled" : "failed";
|
|
439
|
+
// A terminal decision is structured cancellation of the review subtree. It
|
|
440
|
+
// must settle probes as well as semantic reviewers before its parent Work
|
|
441
|
+
// can settle; otherwise the RUN contains abandoned active Work/Session links.
|
|
442
|
+
settleReviewChildren(context, { projectId: input.projectId, runId: input.run.id, parentWorkId: input.parentWorkId, status: childStatus, reason: `PLAN-REVIEW terminal decision: ${input.decision.summary}`, now });
|
|
443
|
+
settleReviewParent(context, { projectRoot: input.projectRoot, projectId: input.projectId, runId: input.run.id, parentWorkId: input.parentWorkId, status: childStatus, result: input.decision.summary, reason: `plan_review_${input.decision.outcome}`, resultPath: path.join(input.root, "stage-report.json") });
|
|
444
|
+
const rootWork = rootWorkId(context, input.projectId, input.run.id);
|
|
445
|
+
context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [childStatus, input.decision.summary, now, now, rootWork]);
|
|
446
|
+
closeRunningWorkSession(context, rootWork, childStatus, now);
|
|
447
|
+
const settledChildren = context.db.all("SELECT work_id, task, status, result, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND task <> 'Capacity probe: return ready and finish this Work.'", [input.projectId, input.run.id, input.parentWorkId]);
|
|
448
|
+
const report = reportFor({ run: input.run, mode: input.contextFile.system?.effective_mode ?? "standard", requested: input.contextFile.system?.requested_mode ?? "auto", outcome: input.decision.outcome, groups: input.contextFile.groups ?? [], batchChecksum: input.contextFile.system?.batch_checksum ?? checksum(input.batch), planChecksum: input.contextFile.system?.plan_checksum ?? checksum(path.join(input.planRoot, "stage-report.json")), code: {}, now, projectRoot: input.projectRoot, decision: input.decision, children: latestReviewChildren(settledChildren), flow: flowCommand(context) });
|
|
449
|
+
writeReport(input.root, report);
|
|
450
|
+
refreshRunWorkProjection(context, input.projectId, input.run.id);
|
|
451
|
+
const stageStatus = input.decision.outcome === "cancelled" ? "skipped" : input.decision.outcome === "failed" ? "failed" : "blocked";
|
|
452
|
+
completeFlowRunStage(context, { projectRoot: input.projectRoot, runId: input.run.id, stage, status: stageStatus, data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
|
|
453
|
+
completeFlowRun(context, { projectRoot: input.projectRoot, runId: input.run.id, status: input.decision.outcome === "cancelled" ? "cancelled" : input.decision.outcome === "failed" ? "failed" : "blocked", verdict: `plan_review_${input.decision.outcome}`, nextAction: "resolve_plan_review_blocker" });
|
|
454
|
+
appendFlowRunTimelineEvent(context, input.projectId, input.run.id, { type: "plan_review_terminal", outcome: input.decision.outcome, work_id: input.parentWorkId });
|
|
455
|
+
return { ok: true, run_id: input.run.id, stage, outcome: input.decision.outcome, next_action: "resolve_plan_review_blocker" };
|
|
456
|
+
}
|
|
457
|
+
function settleReviewChildren(context, input) {
|
|
458
|
+
const active = context.db.all("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND status IN ('created', 'running')", [input.projectId, input.runId, input.parentWorkId]);
|
|
459
|
+
for (const child of active) {
|
|
460
|
+
context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [input.status, input.reason, input.now, input.now, child.work_id]);
|
|
461
|
+
closeRunningWorkSession(context, child.work_id, input.status, input.now);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function closeRunningWorkSession(context, workId, status, now) {
|
|
465
|
+
context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [status, now, now, workId]);
|
|
466
|
+
}
|
|
467
|
+
function reportFor(input) {
|
|
468
|
+
const accepted = input.outcome === "accepted" || input.outcome === "off";
|
|
469
|
+
const result = input.outcome === "off" ? "Independent PLAN review was explicitly or automatically skipped." : accepted ? "Independent PLAN review accepted the plan." : `PLAN-REVIEW stopped with outcome: ${input.outcome}.`;
|
|
470
|
+
const decision = input.decision;
|
|
471
|
+
const correction = decision?.correction ?? null;
|
|
472
|
+
const timing = stageTiming(input.run, input.now);
|
|
473
|
+
const sessionCommand = `${input.flow} stat run sessions ls --run ${input.run.id} --project-root ${JSON.stringify(input.projectRoot)} --json`;
|
|
474
|
+
const usageCommand = `${input.flow} stat usage --run ${input.run.id} --project-root ${JSON.stringify(input.projectRoot)} --json`;
|
|
475
|
+
const changedFiles = correction?.status === "applied" ? correction.changed_paths : [];
|
|
476
|
+
return { schema_id: "dd-flow/stage-report@1", run_id: input.run.id, stage, generated_at: input.now, verdict: accepted ? "done" : input.outcome, semantic: { result, acceptance: [input.outcome], changed_files: changedFiles, checks: ["accepted PLAN report", "generated CODE batch", "reviewer evidence", ...(correction ? ["mechanical correction receipt"] : [])], evidence: [], next_action: accepted ? "start_code" : "resolve_plan_review_blocker", plan_review: { requested_mode: input.requested, effective_mode: input.mode, outcome: input.outcome, groups: input.groups.map((group) => group.key), initial_plan_revision: input.initialPlanRevision ?? null, final_plan_revision: input.finalPlanRevision ?? input.initialPlanRevision ?? null, initial_plan_checksum: input.planChecksum, final_plan_checksum: input.finalPlanChecksum ?? input.planChecksum, initial_batch_checksum: input.batchChecksum, final_batch_checksum: input.finalBatchChecksum ?? input.batchChecksum, correction, decision: input.decision ?? null, reviewer_works: input.children?.map((child) => ({ work_id: child.work_id, status: child.status, verdict: reviewerVerdict(child.result) })) ?? [] }, plan_checksum: input.finalPlanChecksum ?? input.planChecksum, batch_checksum: input.finalBatchChecksum ?? input.batchChecksum, code: input.code }, mechanical: { started_at: timing.started_at, finished_at: input.now, wall_clock_ms: timing.wall_clock_ms, git: gitFacts(input.run.workspace_root), session_stats_command: sessionCommand, usage_stats_command: usageCommand, ...(accepted ? { next_command: `${input.flow} stage start ${input.run.id} --stage code --project-root ${JSON.stringify(input.projectRoot)} --json` } : {}) }, 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" } };
|
|
477
|
+
}
|
|
478
|
+
function stageTiming(run, now) { const started = readIndex(run).stage_runs?.find((item) => item.stage === stage && typeof item.started_at === "string"); const startedAt = started?.started_at ?? now; const milliseconds = Date.parse(now) - Date.parse(startedAt); return { started_at: startedAt, wall_clock_ms: Number.isFinite(milliseconds) && milliseconds >= 0 ? milliseconds : null }; }
|
|
479
|
+
function writeReport(root, report) {
|
|
480
|
+
writeStageReport(root, report);
|
|
481
|
+
}
|
|
482
|
+
function codeCommand(context, runId, projectRoot) { return `${flowCommand(context)} stage start ${runId} --stage code --project-root ${JSON.stringify(projectRoot)} --json`; }
|
|
483
|
+
function dispatchCommand(context, runId, projectRoot) { return `${flowCommand(context)} plan-review dispatch ${runId} --project-root ${JSON.stringify(projectRoot)} --json`; }
|
|
484
|
+
function finishCommand(context, runId, projectRoot, decision) { return `${flowCommand(context)} stage finish ${runId} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --decision-file ${JSON.stringify(decision)} --json`; }
|
|
485
|
+
function capacityRecordCommand(context, runId, projectRoot, availableSlots) { return `${flowCommand(context)} run capacity record ${runId} --available-slots ${availableSlots} --project-root ${JSON.stringify(projectRoot)} --json`; }
|
|
486
|
+
function requireRun(context, projectRoot, runId) { const project = requireProjectByRoot(context, projectRoot); const run = context.db.get("SELECT id, project_id, workspace_root, run_home_path, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]); if (!run)
|
|
487
|
+
throw new AppError("not_found", "RUN is not registered", 1); return run; }
|
|
488
|
+
function requireHome(run) { if (!run.run_home_path)
|
|
489
|
+
throw new AppError("runtime_missing", "RUN workspace is unavailable", 1); return run.run_home_path; }
|
|
490
|
+
function read(file) { if (!fs.existsSync(file))
|
|
491
|
+
throw new AppError("not_found", "Required vNext prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
|
|
492
|
+
function readJson(file) { try {
|
|
493
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
throw new AppError("validation", "Expected valid JSON artifact", 2, { file });
|
|
497
|
+
} }
|
|
498
|
+
function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
499
|
+
function checksum(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|