@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.10
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 +70 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +3 -3
- package/dist/cli/run-cli.js +127 -8
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +1 -1
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/cleanup.js +18 -8
- package/dist/services/code-checks.js +194 -44
- package/dist/services/engines.js +4 -4
- package/dist/services/eval-snapshots.js +10 -5
- package/dist/services/harness-config.js +66 -0
- package/dist/services/hooks.js +25 -22
- package/dist/services/lanes.js +1 -0
- package/dist/services/managed-processes.js +169 -0
- package/dist/services/merge-server.js +8 -2
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/prompts.js +4 -2
- package/dist/services/run-engine-bindings.js +19 -61
- package/dist/services/run-projection.js +10 -8
- package/dist/services/runs.js +71 -9
- package/dist/services/schema-validation.js +11 -11
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +26 -11
- package/dist/services/stage-lifecycle.js +15 -8
- package/dist/services/stage-pause.js +35 -20
- package/dist/services/usage.js +74 -42
- package/dist/services/vnext-code-review.js +82 -41
- package/dist/services/vnext-code.js +98 -34
- package/dist/services/vnext-fanout.js +5 -12
- package/dist/services/vnext-merge.js +144 -65
- package/dist/services/vnext-plan-review.js +50 -35
- package/dist/services/vnext-plan.js +69 -21
- package/dist/services/vnext-protocolize.js +6 -6
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +150 -40
- package/dist/storage/database.js +128 -2
- package/package.json +1 -1
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
|
@@ -14,14 +14,12 @@ import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
|
14
14
|
import { nextWorkId } from "./ids.js";
|
|
15
15
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
16
16
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
17
|
+
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
17
18
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
18
|
-
import {
|
|
19
|
+
import { readFanoutDescriptor, subagentCapacityKey, writeFanoutDescriptor } from "./vnext-fanout.js";
|
|
19
20
|
const stage = "plan-review";
|
|
20
21
|
const stageDir = vnextStageDirectory(stage);
|
|
21
22
|
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
23
|
export function isVnextPlanReviewRun(context, input) {
|
|
26
24
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
27
25
|
return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
|
|
@@ -48,7 +46,7 @@ export function startVnextPlanReview(context, input) {
|
|
|
48
46
|
if (existing?.status === "running") {
|
|
49
47
|
const promptPath = path.join(root, "stage-prompt.md");
|
|
50
48
|
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:
|
|
49
|
+
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: planSetChecksum(home, run.workspace_root) }), ...(externalContext ? { external_context: externalContext } : {}) };
|
|
52
50
|
}
|
|
53
51
|
const priorStage = readIndex(run).stage_runs?.find((entry) => entry.stage === stage);
|
|
54
52
|
if (priorStage?.status === "done") {
|
|
@@ -61,7 +59,7 @@ export function startVnextPlanReview(context, input) {
|
|
|
61
59
|
fs.mkdirSync(root, { recursive: true });
|
|
62
60
|
const now = context.now();
|
|
63
61
|
const batchChecksum = checksum(batch);
|
|
64
|
-
const planChecksum =
|
|
62
|
+
const planChecksum = planSetChecksum(home, run.workspace_root);
|
|
65
63
|
if (effective === "off") {
|
|
66
64
|
const failures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIds });
|
|
67
65
|
if (failures.length)
|
|
@@ -125,13 +123,13 @@ export function dispatchVnextPlanReview(context, input) {
|
|
|
125
123
|
if (capacity.pending)
|
|
126
124
|
return capacity.response;
|
|
127
125
|
if (capacity.availableSlots < 1)
|
|
128
|
-
throw new AppError("
|
|
126
|
+
throw new AppError("subagent_capacity_unqualified", "PLAN-REVIEW requires one qualified native subagent slot", 1, { run_id: run.id });
|
|
129
127
|
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
128
|
const latest = (group) => existing.find((item) => item.task === group.task);
|
|
131
129
|
const pending = groups.filter((group) => !latest(group));
|
|
132
130
|
if (pending.length) {
|
|
133
131
|
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 } } })) });
|
|
132
|
+
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", read_only: true, group: { key: group.key, aspect_ids: group.aspect_ids } } })) });
|
|
135
133
|
try {
|
|
136
134
|
addWorkBatch(context, { parentWorkId: parent.work_id, file });
|
|
137
135
|
}
|
|
@@ -161,14 +159,14 @@ export function recordVnextPlanReviewCapacity(context, input) {
|
|
|
161
159
|
const prior = variables.variables[reviewCapacityKey];
|
|
162
160
|
if (typeof prior === "number") {
|
|
163
161
|
if (prior === input.availableSlots)
|
|
164
|
-
return { ok: true, run_id: run.id, capacity: { available_slots: prior, source: "
|
|
162
|
+
return { ok: true, run_id: run.id, capacity: { available_slots: prior, source: "harness_qualification", reused: true } };
|
|
165
163
|
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,
|
|
167
|
-
return { ok: true, run_id: run.id, capacity: { available_slots: input.availableSlots, previous_available_slots: prior, source: "
|
|
164
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "subagent_capacity_refreshed", previous_available_slots: prior, available_slots: input.availableSlots, source: "harness_qualification" });
|
|
165
|
+
return { ok: true, run_id: run.id, capacity: { available_slots: input.availableSlots, previous_available_slots: prior, source: "harness_qualification", refreshed: true } };
|
|
168
166
|
}
|
|
169
167
|
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,
|
|
171
|
-
return { ok: true, run_id: run.id, capacity: { available_slots: input.availableSlots, source: "
|
|
168
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "subagent_capacity_observed", available_slots: input.availableSlots, source: "harness_qualification" });
|
|
169
|
+
return { ok: true, run_id: run.id, capacity: { available_slots: input.availableSlots, source: "harness_qualification" } };
|
|
172
170
|
}
|
|
173
171
|
export function finishVnextPlanReview(context, input) {
|
|
174
172
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
@@ -182,22 +180,26 @@ export function finishVnextPlanReview(context, input) {
|
|
|
182
180
|
if (!parent || parent.status !== "running")
|
|
183
181
|
throw new AppError("invalid_work_state", "PLAN-REVIEW has no running parent Work", 2);
|
|
184
182
|
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 = ?
|
|
183
|
+
const decision = readDecision(context, projectRoot, run.id, home, decisionPath);
|
|
184
|
+
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 = ?", [project.id, run.id, parent.work_id]);
|
|
187
185
|
const contextFile = readJson(path.join(root, "work-context.json"));
|
|
188
186
|
const groups = contextFile.groups ?? [];
|
|
189
187
|
const reviewedPlanRevision = contextFile.system?.plan_revision;
|
|
190
188
|
if (!Number.isInteger(reviewedPlanRevision) || reviewedPlanRevision < 1)
|
|
191
189
|
throw new AppError("runtime_missing", "PLAN-REVIEW starting revision is unavailable", 1);
|
|
192
190
|
const planRevision = currentPlanRevision(home, run.workspace_root);
|
|
193
|
-
|
|
191
|
+
const currentPlanChecksum = planSetChecksum(home, run.workspace_root);
|
|
192
|
+
if (decision.outcome === "blocked") {
|
|
193
|
+
throw new AppError("stage_pause_required", "A user or decision blocker must pause the current PLAN-REVIEW Work with `dd-flow stage pause`; do not finish the stage as blocked", 2, { run_id: run.id, stage, work_id: parent.work_id });
|
|
194
|
+
}
|
|
195
|
+
if (["failed", "cancelled"].includes(decision.outcome)) {
|
|
194
196
|
preserveDecisionReceipt(root, decisionPath);
|
|
195
197
|
return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision });
|
|
196
198
|
}
|
|
197
199
|
if (children.some((child) => child.status === "created" || child.status === "running"))
|
|
198
200
|
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
201
|
const latestChildren = latestReviewChildren(children);
|
|
200
|
-
const evidenceFailures = reviewerEvidenceFailures(context, { projectId: project.id, parentWorkId: parent.work_id, children: latestChildren, groups, planRevision: reviewedPlanRevision });
|
|
202
|
+
const evidenceFailures = reviewerEvidenceFailures(context, { projectId: project.id, parentWorkId: parent.work_id, children: latestChildren, groups, planRevision: reviewedPlanRevision, workspaceRoot: run.workspace_root, runHome: home, runId: run.id });
|
|
201
203
|
if (evidenceFailures.length)
|
|
202
204
|
throw new AppError("review_evidence_invalid", "PLAN-REVIEW reviewer evidence is incomplete, stale, or not isolated", 2, { errors: evidenceFailures });
|
|
203
205
|
if (decision.outcome !== "accepted")
|
|
@@ -209,17 +211,21 @@ export function finishVnextPlanReview(context, input) {
|
|
|
209
211
|
const needsCorrection = latestChildren.some((child) => ["needs_changes", "blocked"].includes(reviewerVerdict(child.result)));
|
|
210
212
|
if (latestChildren.some((child) => child.status !== "completed"))
|
|
211
213
|
throw new AppError("blocked", "PLAN-REVIEW cannot accept incomplete reviewer evidence", 2);
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
const canonicalFindings = canonicalReviewerFindings(latestChildren);
|
|
215
|
+
const allFindingIds = canonicalFindings.map(({ finding_ref }) => finding_ref);
|
|
216
|
+
const materialFindingIds = canonicalFindings.filter(({ finding }) => ["blocker", "high", "medium"].includes(finding.severity)).map(({ finding_ref }) => finding_ref);
|
|
217
|
+
const decisionFindingIds = decision.finding_decisions.map((finding) => finding.finding_ref).filter((id) => Boolean(id));
|
|
218
|
+
const decidedFindingIds = new Set(decisionFindingIds);
|
|
219
|
+
const unknownDecisions = decisionFindingIds.filter((id) => !allFindingIds.includes(id));
|
|
220
|
+
if (new Set(allFindingIds).size !== allFindingIds.length || decidedFindingIds.size !== decisionFindingIds.length || unknownDecisions.length || materialFindingIds.some((id) => !decidedFindingIds.has(id)))
|
|
221
|
+
throw new AppError("review_evidence_invalid", "Reviewer findings and coordinator decisions must use unique known canonical references, with every material finding decided", 2, { material_finding_ids: materialFindingIds, decided_finding_ids: decisionFindingIds, unknown_decisions: unknownDecisions });
|
|
216
222
|
if (needsCorrection) {
|
|
217
223
|
if (decision.correction.status !== "applied")
|
|
218
224
|
throw new AppError("validation", "Reviewer findings require an in-place correction receipt", 2);
|
|
219
225
|
if (decision.correction.previous_plan_revision !== reviewedPlanRevision || planRevision <= reviewedPlanRevision || decision.correction.changed_paths.length === 0)
|
|
220
226
|
throw new AppError("validation", "Applied PLAN correction must advance revision and list changed paths", 2, { reviewed_plan_revision: reviewedPlanRevision, final_plan_revision: planRevision });
|
|
221
227
|
}
|
|
222
|
-
else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision) {
|
|
228
|
+
else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision || currentPlanChecksum !== contextFile.system?.plan_checksum) {
|
|
223
229
|
throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2);
|
|
224
230
|
}
|
|
225
231
|
const planFailures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
|
|
@@ -233,7 +239,7 @@ export function finishVnextPlanReview(context, input) {
|
|
|
233
239
|
throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths });
|
|
234
240
|
}
|
|
235
241
|
preserveDecisionReceipt(root, decisionPath);
|
|
236
|
-
const finalPlanChecksum =
|
|
242
|
+
const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
|
|
237
243
|
const finalBatchChecksum = checksum(batch);
|
|
238
244
|
const registered = registerCode(context, project.id, run.id, batch);
|
|
239
245
|
const now = context.now();
|
|
@@ -258,7 +264,7 @@ function orchestratorPrompt(context, input) {
|
|
|
258
264
|
const decision = path.join(input.root, "decision.json");
|
|
259
265
|
const revision = currentPlanRevision(input.home, input.run.workspace_root);
|
|
260
266
|
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
|
|
267
|
+
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 reports qualified_capacity_required, stop. The external harness controller qualifies the selected profile outside this RUN and records the resulting integer with ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<qualified-native-child-count>")}. PLAN-REVIEW never launches a capacity probe.`, "After dispatch, launch at most the qualified 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 | 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
268
|
}
|
|
263
269
|
function reviewGroups(home, workspaceRoot) {
|
|
264
270
|
const root = path.join(home, "03-plan");
|
|
@@ -318,15 +324,15 @@ function plannedReviewWaves(groups, capacity) {
|
|
|
318
324
|
}
|
|
319
325
|
return waves;
|
|
320
326
|
}
|
|
321
|
-
export function
|
|
322
|
-
return { ok: true, run_id: input.runId, stage, outcome: "
|
|
327
|
+
export function capacityQualificationResponse(context, input) {
|
|
328
|
+
return { ok: true, run_id: input.runId, stage, outcome: "qualified_capacity_required", next_action: "qualify_harness_profile_then_record_capacity", next: { record_command: capacityRecordCommand(context, input.runId, input.projectRoot, "<qualified-native-child-count>"), retry_command: dispatchCommand(context, input.runId, input.projectRoot) } };
|
|
323
329
|
}
|
|
324
330
|
function ensureReviewCapacity(context, input) {
|
|
325
331
|
const variables = getFlowRunVariables(context, { projectRoot: input.projectRoot, runId: input.runId });
|
|
326
332
|
const known = variables.variables[reviewCapacityKey];
|
|
327
333
|
if (typeof known === "number" && Number.isInteger(known) && known >= 0)
|
|
328
334
|
return { pending: false, availableSlots: known };
|
|
329
|
-
return { pending: true, response:
|
|
335
|
+
return { pending: true, response: capacityQualificationResponse(context, { projectRoot: input.projectRoot, runId: input.runId }) };
|
|
330
336
|
}
|
|
331
337
|
function protocolIdsForReview(home) {
|
|
332
338
|
const report = readJson(path.join(home, "02-protocolize", "stage-report.json"));
|
|
@@ -376,7 +382,7 @@ catch {
|
|
|
376
382
|
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
383
|
throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return root.work_id; }
|
|
378
384
|
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); }
|
|
385
|
+
function readDecision(context, projectRoot, runId, runRoot, file) { validateSchema({ schemaName: "plan-review-decision", file, projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }); return readJson(file); }
|
|
380
386
|
function preserveDecisionReceipt(root, source) {
|
|
381
387
|
const receipt = path.join(root, "decision.receipt.json");
|
|
382
388
|
const bytes = fs.readFileSync(source);
|
|
@@ -419,6 +425,14 @@ function reviewerEvidenceFailures(context, input) {
|
|
|
419
425
|
const actual = result.aspects.map((item) => item.aspect_id);
|
|
420
426
|
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
427
|
failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_aspect_coverage_invalid", expected_aspects: group.aspect_ids, actual_aspects: actual });
|
|
428
|
+
for (const ref of result.aspects.flatMap((item) => [...item.evidence_refs, ...item.findings.flatMap((finding) => finding.evidence_refs)])) {
|
|
429
|
+
try {
|
|
430
|
+
assertPortableArtifactRef(ref, { workspaceRoot: input.workspaceRoot, runHome: input.runHome, runId: input.runId });
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_evidence_ref_invalid", ref, detail: error instanceof Error ? error.message : String(error) });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
422
436
|
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
437
|
if (!workSession?.session_id || workSession.status !== "completed" || workSession.session_id === parent?.session_id)
|
|
424
438
|
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 });
|
|
@@ -437,15 +451,15 @@ function finishTerminalReview(context, input) {
|
|
|
437
451
|
const now = context.now();
|
|
438
452
|
const childStatus = input.decision.outcome === "cancelled" ? "cancelled" : "failed";
|
|
439
453
|
// A terminal decision is structured cancellation of the review subtree. It
|
|
440
|
-
// must settle
|
|
441
|
-
//
|
|
454
|
+
// must settle semantic reviewers before its parent Work can settle; otherwise
|
|
455
|
+
// the RUN contains abandoned active Work/Session links.
|
|
442
456
|
settleReviewChildren(context, { projectId: input.projectId, runId: input.run.id, parentWorkId: input.parentWorkId, status: childStatus, reason: `PLAN-REVIEW terminal decision: ${input.decision.summary}`, now });
|
|
443
457
|
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
458
|
const rootWork = rootWorkId(context, input.projectId, input.run.id);
|
|
445
459
|
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
460
|
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 = ?
|
|
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 ??
|
|
461
|
+
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 = ?", [input.projectId, input.run.id, input.parentWorkId]);
|
|
462
|
+
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 ?? planSetChecksum(requireHome(input.run), input.run.workspace_root), code: {}, now, projectRoot: input.projectRoot, decision: input.decision, children: latestReviewChildren(settledChildren), flow: flowCommand(context) });
|
|
449
463
|
writeReport(input.root, report);
|
|
450
464
|
refreshRunWorkProjection(context, input.projectId, input.run.id);
|
|
451
465
|
const stageStatus = input.decision.outcome === "cancelled" ? "skipped" : input.decision.outcome === "failed" ? "failed" : "blocked";
|
|
@@ -483,10 +497,10 @@ function codeCommand(context, runId, projectRoot) { return `${flowCommand(contex
|
|
|
483
497
|
function dispatchCommand(context, runId, projectRoot) { return `${flowCommand(context)} plan-review dispatch ${runId} --project-root ${JSON.stringify(projectRoot)} --json`; }
|
|
484
498
|
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
499
|
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,
|
|
500
|
+
function requireRun(context, projectRoot, runId) { const project = requireProjectByRoot(context, projectRoot); const run = context.db.get("SELECT id, project_id, workspace_root, run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]); if (!run)
|
|
487
501
|
throw new AppError("not_found", "RUN is not registered", 1); return run; }
|
|
488
|
-
function requireHome(run) { if (!run.
|
|
489
|
-
throw new AppError("runtime_missing", "RUN
|
|
502
|
+
function requireHome(run) { if (!run.run_root)
|
|
503
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
|
|
490
504
|
function read(file) { if (!fs.existsSync(file))
|
|
491
505
|
throw new AppError("not_found", "Required vNext prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
|
|
492
506
|
function readJson(file) { try {
|
|
@@ -497,3 +511,4 @@ catch {
|
|
|
497
511
|
} }
|
|
498
512
|
function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
|
|
499
513
|
function checksum(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
514
|
+
function planSetChecksum(home, workspaceRoot) { const entries = protocolIdsForReview(home).sort().map((protocolId) => { const file = path.join(workspaceRoot, ".memory-bank", "protocol", protocolId, "plan.json"); return { protocol_id: protocolId, path: path.relative(workspaceRoot, file).split(path.sep).join("/"), sha256: checksum(file) }; }); return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex"); }
|
|
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import { validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
|
|
5
|
+
import { effectiveCheckDeclarations, readCodeCheckProfile, validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
|
|
6
6
|
import { requireProjectByRoot } from "./projects.js";
|
|
7
7
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
8
8
|
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
@@ -29,13 +29,18 @@ export function startVnextPlan(context, input) {
|
|
|
29
29
|
const home = requireHome(run);
|
|
30
30
|
assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage: "plan", projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
31
31
|
const workspaceRoute = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan" });
|
|
32
|
-
const root = path.join(home, vnextStageDirectory("plan"));
|
|
33
|
-
fs.mkdirSync(root, { recursive: true });
|
|
34
32
|
const protocols = protocolIds(home);
|
|
35
33
|
if (!protocols.length)
|
|
36
34
|
throw new AppError("not_found", "PLAN requires accepted PROTOCOLIZE protocols", 1);
|
|
35
|
+
// Static inputs are checked before PLAN creates a Work or materializes a
|
|
36
|
+
// draft. A rejected start is therefore side-effect free and safe to retry.
|
|
37
|
+
const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan.md"));
|
|
38
|
+
assertProtocolWorkspace(run.workspace_root, protocols);
|
|
39
|
+
const { profile: codeCheckProfile } = readCodeCheckProfile(run.workspace_root);
|
|
37
40
|
if (context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND task = ? AND status = 'running'", [project.id, run.id, planTask]))
|
|
38
41
|
throw new AppError("invalid_work_state", "PLAN already has a running Work", 1, { run_id: run.id });
|
|
42
|
+
const root = path.join(home, vnextStageDirectory("plan"));
|
|
43
|
+
fs.mkdirSync(root, { recursive: true });
|
|
39
44
|
const now = context.now();
|
|
40
45
|
const rootWork = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [project.id, run.id]);
|
|
41
46
|
if (!rootWork)
|
|
@@ -56,11 +61,9 @@ export function startVnextPlan(context, input) {
|
|
|
56
61
|
context.db.exec("ROLLBACK");
|
|
57
62
|
throw error;
|
|
58
63
|
}
|
|
59
|
-
const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan.md"));
|
|
60
64
|
// A Desktop task may start above the materialized repository. Lifecycle
|
|
61
65
|
// prompts therefore hand agents write targets as absolute paths: relative
|
|
62
66
|
// `.memory-bank/...` paths would otherwise silently land in the parent cwd.
|
|
63
|
-
assertProtocolWorkspace(run.workspace_root, protocols);
|
|
64
67
|
const planPaths = protocols.map((id) => path.join(run.workspace_root, ".memory-bank", "protocol", id, "plan.json"));
|
|
65
68
|
const mapPaths = protocols.map((id) => `${path.join(root, id, "aspect-map.json")}`);
|
|
66
69
|
const owned = protocolOwnership(home, protocols);
|
|
@@ -71,17 +74,24 @@ export function startVnextPlan(context, input) {
|
|
|
71
74
|
const pauseCommand = stagePauseCommand(context, { runId: run.id, stage: "plan", workId: planWorkId, projectRoot });
|
|
72
75
|
const pauseCommandTemplate = stagePauseCommandTemplate(pauseCommand);
|
|
73
76
|
const validationCommands = protocols.flatMap((_, index) => [
|
|
74
|
-
`${flowCommand(context)} schema validate --schema vnext-protocol-plan --file ${JSON.stringify(planPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --json`,
|
|
75
|
-
`${flowCommand(context)} schema validate --schema plan-aspect-map --file ${JSON.stringify(mapPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --json`
|
|
77
|
+
`${flowCommand(context)} schema validate --schema vnext-protocol-plan --file ${JSON.stringify(planPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --run ${run.id} --json`,
|
|
78
|
+
`${flowCommand(context)} schema validate --schema plan-aspect-map --file ${JSON.stringify(mapPaths[index])} --project-root ${JSON.stringify(run.workspace_root)} --run ${run.id} --json`
|
|
76
79
|
]);
|
|
77
80
|
const runVariables = getFlowRunVariables(context, { projectRoot, runId: run.id });
|
|
78
81
|
const measuredCapacity = runVariables.variables[subagentCapacityKey];
|
|
82
|
+
const mergeRequired = runEndsAtMerge(context, projectRoot, run.id);
|
|
79
83
|
const capacityContext = typeof measuredCapacity === "number" && Number.isInteger(measuredCapacity) && measuredCapacity >= 0
|
|
80
|
-
? `- The
|
|
81
|
-
: "- Reviewer capacity is not
|
|
82
|
-
const reviewGroupingRule = "Group only semantically compatible applicable aspects, preserving real trust, irreversible, high-risk and hard-dependency boundaries. Prefer the fewest groups that retain independent review value, normally one review wave. Put two or three compatible aspects in a group; do not create one group per aspect merely for convenience. A later PLAN-REVIEW dispatch
|
|
84
|
+
? `- 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.`
|
|
85
|
+
: "- Reviewer capacity is not qualified yet. PLAN must not qualify or launch reviewers; an external harness controller supplies it before fan-out.";
|
|
86
|
+
const reviewGroupingRule = "Group only semantically compatible applicable aspects, preserving real trust, irreversible, high-risk and hard-dependency boundaries. Prefer the fewest groups that retain independent review value, normally one review wave. Put two or three compatible aspects in a group; do not create one group per aspect merely for convenience. A later PLAN-REVIEW dispatch uses externally qualified capacity to schedule these semantic groups into waves; do not invent a capacity value here.";
|
|
83
87
|
const checkProfile = path.join(run.workspace_root, ".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
84
|
-
const
|
|
88
|
+
const policyMergeAliases = codeCheckProfile?.mandatory_by_gate.merge ?? [];
|
|
89
|
+
const mergeContract = mergeRequired
|
|
90
|
+
? ["<merge_gate_contract>", ...(policyMergeAliases.length
|
|
91
|
+
? [`This RUN must reach MERGE. Project policy already supplies the mandatory merge gate${policyMergeAliases.length === 1 ? "" : "s"}: ${policyMergeAliases.join(", ")}. Do not duplicate them in semantic checks[]. Add another merge check only when the task genuinely needs additional evidence.`]
|
|
92
|
+
: ["This RUN must reach MERGE and project policy supplies no merge gate. Select at least one real top-level checks[] entry with run_at: merge. It may use an existing project alias or a planned alias materialised by a named P* provider Work. This is a planning obligation: do not defer it to CODE-REVIEW or MERGE."]), "The CLI validates the effective merge gate but never invents one or migrates an incompatible project policy.", "</merge_gate_contract>", ""]
|
|
93
|
+
: [];
|
|
94
|
+
const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), ...mergeContract, "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an honestly limited external/manual proof. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
|
|
85
95
|
const artifactMaterialization = { status: "materialized", completeness: "partially_filled", plan_paths: planPaths, aspect_map_paths: mapPaths, cli_owned_plan_fields: ["schema_id", "plan_id", "protocol_id", "revision", "source_refs"], cli_owned_aspect_map_fields: ["schema_id", "protocol_id", "plan_id", "plan_revision", "catalog_ref", "aspects[].aspect_id"], validation_commands: validationCommands };
|
|
86
96
|
const promptPath = path.join(root, "stage-prompt.md");
|
|
87
97
|
fs.writeFileSync(promptPath, prompt);
|
|
@@ -126,7 +136,7 @@ export function finishVnextPlan(context, input) {
|
|
|
126
136
|
const reviewCommand = `${flowCommand(context)} stage start ${run.id} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --json`;
|
|
127
137
|
const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage: "plan", generated_at: now, verdict: "done", semantic: { result: `Accepted ${protocols.length} executable PLAN artifact${protocols.length === 1 ? "" : "s"}.`, acceptance: protocols, changed_files: [...planFiles.map((file) => path.relative(projectRoot, file)), ...mapFiles.map((file) => runRef(run.id, home, file)), runRef(run.id, home, batch)], checks: ["protocol-plan schema", "aspect-map schema", "cross-artifact references", "generated CODE batch"], evidence: [runRef(run.id, home, path.join(root, "stage-report.json"))], next_action: "start_plan_review", plans: planFiles.map((file) => path.relative(projectRoot, file)), aspect_maps: mapFiles.map((file) => runRef(run.id, home, file)), code_work_batch: runRef(run.id, home, batch), batch_checksum: batchChecksum }, mechanical: { started_at: stageStartedAt(home, now), finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(stageStartedAt(home, now))), git: gitFacts(run.workspace_root), session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, next_command: reviewCommand }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html", summary: "stage-report.md" }, validation: { permission_scope: "known_targets_only", memory_bank_scope: "changed_files_and_links_only", status: "passed" } };
|
|
128
138
|
const reportJson = writeStageReport(root, report).json;
|
|
129
|
-
validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
|
|
139
|
+
validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
|
|
130
140
|
completeFlowRunStage(context, { projectRoot, runId: run.id, stage: "plan", status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
|
|
131
141
|
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "planned", nextAction: "start_plan_review" });
|
|
132
142
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_accepted", work_id: work.work_id, protocols, id: workSession.id, next_stage: "plan-review" });
|
|
@@ -149,7 +159,7 @@ export function validateVnextPlanArtifacts(context, input) {
|
|
|
149
159
|
for (const [index, file] of planFiles.entries()) {
|
|
150
160
|
const protocolId = input.protocols[index];
|
|
151
161
|
try {
|
|
152
|
-
validateSchema({ schemaName: "vnext-protocol-plan", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId });
|
|
162
|
+
validateSchema({ schemaName: "vnext-protocol-plan", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
|
|
153
163
|
const value = readPlan(file);
|
|
154
164
|
assertPlanIdentity(value, planIdentity(input.home, input.runId, protocolId, ownership.get(protocolId) ?? []), file);
|
|
155
165
|
validatePlanSemantics(file, new Set(ownership.get(protocolId) ?? []), obligations);
|
|
@@ -170,20 +180,29 @@ export function validateVnextPlanArtifacts(context, input) {
|
|
|
170
180
|
for (const file of mapFiles) {
|
|
171
181
|
try {
|
|
172
182
|
normalizeAspectMapRefs(file, workspaceRoot, input.home, input.runId);
|
|
173
|
-
validateSchema({ schemaName: "plan-aspect-map", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome });
|
|
183
|
+
validateSchema({ schemaName: "plan-aspect-map", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
|
|
174
184
|
validateAspectMap(file, input.protocols, workspaceRoot);
|
|
175
185
|
}
|
|
176
186
|
catch (error) {
|
|
177
187
|
failures.push(validationFailure(file, error));
|
|
178
188
|
}
|
|
179
189
|
}
|
|
190
|
+
if (!failures.length) {
|
|
191
|
+
try {
|
|
192
|
+
validatePsetCheckIdentity(plans);
|
|
193
|
+
validateRequiredMergeGate(context, input, plans);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
failures.push(validationFailure(batch, error));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
180
199
|
if (!failures.length) {
|
|
181
200
|
const temporaryBatch = `${batch}.tmp-${crypto.randomUUID()}`;
|
|
182
201
|
try {
|
|
183
202
|
const projection = projectCodeWorkBatch({ home: input.home, workspaceRoot, runId: input.runId, plans, protocols: input.protocols, ...(frozenDocumentBaselines ? { frozenDocumentBaselines } : {}) });
|
|
184
203
|
validateProjectedPaths(projection, workspaceRoot, input.home, input.runId);
|
|
185
204
|
fs.writeFileSync(temporaryBatch, `${JSON.stringify(projection, null, 2)}\n`);
|
|
186
|
-
validateSchema({ schemaName: "code-work-batch", file: temporaryBatch, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId });
|
|
205
|
+
validateSchema({ schemaName: "code-work-batch", file: temporaryBatch, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
|
|
187
206
|
validateWorkBatchFile(temporaryBatch);
|
|
188
207
|
if (input.publishBatch !== false) {
|
|
189
208
|
fs.renameSync(temporaryBatch, batch);
|
|
@@ -212,10 +231,10 @@ export function validateVnextCodeHandoff(context, input) {
|
|
|
212
231
|
const planTask = "Produce accepted plan.json and aspect-map.json artifacts.";
|
|
213
232
|
function planExample(protocolId) { return { schema_id: "dd-flow/protocol-plan@6", plan_id: "PLAN-001", protocol_id: protocolId, revision: 1, title: "Example", summary: "A compact executable plan.", source_refs: [{ kind: "specify", id: "SPECIFY", path: "run://RUN-000/01-specify/specify.json", requirement_ids: ["R-001", "AC-001"] }], goal: { outcome: "Deliver the accepted behavior.", constraints: ["Keep the accepted scope."], non_goals: [] }, assessment: { scope_breadth: { level: "narrow", surfaces: ["one surface"], reason: "One vertical slice." }, solution_novelty: { level: "established", surfaces: ["existing pattern"], reason: "Reuse project practice." }, solution_uncertainty: { level: "low", surfaces: ["known behavior"], reason: "No open technical question." }, failure_impact: { level: "low", surfaces: ["local feature"], reason: "Reversible local change." }, selected_depth: "compact_plan", depth_trigger: "none" }, decisions: [], document_updates: [], checks: [{ id: "CHK-P1-TEST", command: "pnpm test", purpose: "Proves the changed behavior.", run_at: "work", availability: "available" }], items: [{ id: "P1", title: "Implement behavior", summary: "Change the owning surface.", details: "Follow the accepted requirement and project conventions.", depends_on: [], requirement_refs: ["R-001", "AC-001"], semantic_spine: { user_outcome: "The requested behavior is available.", component_responsibility: "Own the behavior.", must_preserve: ["Existing behavior."], non_goals: [], acceptance_contribution: "Makes AC-001 observable." }, execution_context: { required_read: ["apps/api/src/example.ts"], discovery_boundary: ["Related tests only."], planned_write_areas: ["apps/api/src/"], stop_conditions: ["Stop if accepted scope conflicts with current truth."] }, verification: { check_refs: ["CHK-P1-TEST"] } }], acceptance: [{ criterion_id: "AC-001", plan_item_ids: ["P1"], changed_surfaces: ["apps/api/src/example.ts"], path: "Exercise the accepted user path.", environment: "Local test environment.", fixtures: [], cleanup: "No persistent fixture.", check_refs: ["CHK-P1-TEST"], expected_evidence: ["Focused check passes."], proof_limits: ["Manual production evidence is not claimed."], gate: "work" }] }; }
|
|
214
233
|
function aspectMapExample(protocolId) { return { $schema: "plan-aspect-map.schema.json", schema_id: "dd-flow/plan-aspect-map@3", protocol_id: protocolId, plan_id: "PLAN-001", plan_revision: 1, catalog_ref: { path: ".memory-bank/dd-flow/mb-sdlc/plan-aspects/aspects" }, routing: { initial_state: "orchestrator_local", selected_route: "local_compact", reason: "One genuinely small semantic unit.", groups: [] }, review_groups: [], aspects: [{ aspect_id: "example_aspect", applicability: "not_applicable", reason: "Only an example; use the supplied real catalog.", planned_artifact_refs: [] }] }; }
|
|
215
|
-
function requireRun(context, root, id) { const project = requireProjectByRoot(context, root); const run = context.db.get("SELECT id, project_id, workspace_root,
|
|
234
|
+
function requireRun(context, root, id) { const project = requireProjectByRoot(context, root); const run = context.db.get("SELECT id, project_id, workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, id]); if (!run)
|
|
216
235
|
throw new AppError("not_found", "RUN is not registered", 1); return run; }
|
|
217
|
-
function requireHome(run) { if (!run.
|
|
218
|
-
throw new AppError("runtime_missing", "RUN
|
|
236
|
+
function requireHome(run) { if (!run.run_root)
|
|
237
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
|
|
219
238
|
function protocolIds(home) {
|
|
220
239
|
const report = JSON.parse(fs.readFileSync(path.join(home, "02-protocolize", "stage-report.json"), "utf8"));
|
|
221
240
|
const ids = report.semantic?.acceptance;
|
|
@@ -285,7 +304,7 @@ function stageStartedAt(home, fallback) { try {
|
|
|
285
304
|
catch {
|
|
286
305
|
return fallback;
|
|
287
306
|
} }
|
|
288
|
-
function validationFailure(file, error) { return { file, message: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { details: error.details } : {}) }; }
|
|
307
|
+
function validationFailure(file, error) { return { file, message: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { code: error.code, details: error.details } : {}) }; }
|
|
289
308
|
function acceptedObligations(home) {
|
|
290
309
|
const file = path.join(home, "01-specify", "specify.json");
|
|
291
310
|
if (!fs.existsSync(file))
|
|
@@ -395,6 +414,7 @@ function projectCodeWorkBatch(input) {
|
|
|
395
414
|
// stale as soon as its provider performs its declared work.
|
|
396
415
|
required_read: [...new Set([
|
|
397
416
|
...orientation,
|
|
417
|
+
path.relative(input.workspaceRoot, file).split(path.sep).join("/"),
|
|
398
418
|
...item.execution_context.required_read,
|
|
399
419
|
...existingDocumentPaths,
|
|
400
420
|
...(value.checks.some((check) => check.availability === "planned" && check.provided_by === item.id)
|
|
@@ -547,9 +567,14 @@ function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
|
|
|
547
567
|
for (const id of acceptance.plan_item_ids ?? [])
|
|
548
568
|
if (!ids.has(id))
|
|
549
569
|
throw new AppError("validation", "PLAN acceptance references an unknown item", 2, { file, criterion_id: acceptance.criterion_id, plan_item_id: id });
|
|
550
|
-
for (const id of acceptance.check_refs ?? [])
|
|
551
|
-
|
|
570
|
+
for (const id of acceptance.check_refs ?? []) {
|
|
571
|
+
const check = checks.get(id);
|
|
572
|
+
if (!check)
|
|
552
573
|
throw new AppError("check_reference_unknown", "PLAN acceptance references an unknown check", 2, { file, criterion_id: acceptance.criterion_id, check_id: id });
|
|
574
|
+
for (const itemId of acceptance.plan_item_ids ?? [])
|
|
575
|
+
if (check.availability === "planned" && check.provided_by !== itemId && !ancestors(itemId).has(check.provided_by))
|
|
576
|
+
throw new AppError("check_consumer_not_ordered_after_provider", "Acceptance may consume a planned check only after its provider", 2, { file, criterion_id: acceptance.criterion_id, item: itemId, check_id: id, provider: check.provided_by });
|
|
577
|
+
}
|
|
553
578
|
}
|
|
554
579
|
for (const obligation of ownedRefs)
|
|
555
580
|
if (!realized.has(obligation))
|
|
@@ -558,3 +583,26 @@ function validatePlanSemantics(file, ownedRefs, acceptedRefs) {
|
|
|
558
583
|
if (!plan.acceptance.some((acceptance) => acceptance.criterion_id === obligation))
|
|
559
584
|
throw new AppError("validation", "Every owned AC-* needs an observable PLAN acceptance entry", 2, { file, criterion_id: obligation });
|
|
560
585
|
}
|
|
586
|
+
function validatePsetCheckIdentity(plans) {
|
|
587
|
+
const owners = new Map();
|
|
588
|
+
for (const plan of plans)
|
|
589
|
+
for (const check of plan.value.checks) {
|
|
590
|
+
const prior = owners.get(check.id);
|
|
591
|
+
if (prior)
|
|
592
|
+
throw new AppError("duplicate_pset_check_id", "PLAN check ids must be unique across the whole PSET", 2, { check_id: check.id, protocols: [prior, plan.protocolId] });
|
|
593
|
+
owners.set(check.id, plan.protocolId);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function runEndsAtMerge(context, projectRoot, runId) {
|
|
597
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
598
|
+
const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]);
|
|
599
|
+
return JSON.parse(row?.index_json ?? "{}").execution_profile?.settings?.stop_target === "merge_completed";
|
|
600
|
+
}
|
|
601
|
+
function validateRequiredMergeGate(context, input, plans) {
|
|
602
|
+
if (!runEndsAtMerge(context, input.projectRoot, input.runId))
|
|
603
|
+
return;
|
|
604
|
+
const declared = plans.flatMap(({ value }) => value.checks);
|
|
605
|
+
if (effectiveCheckDeclarations(input.workspaceRoot ?? input.projectRoot, declared, ["merge"]).length === 0) {
|
|
606
|
+
throw new AppError("merge_gate_plan_missing", "PLAN for a RUN ending in MERGE must declare at least one semantic or project-policy merge check", 2, { run_id: input.runId });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
@@ -135,7 +135,7 @@ export function finishVnextProtocolize(context, input) {
|
|
|
135
135
|
const root = path.join(requireRunHome(run), "02-protocolize");
|
|
136
136
|
const resultFile = path.resolve(input.resultFile);
|
|
137
137
|
inside(root, resultFile);
|
|
138
|
-
validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
|
|
138
|
+
validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
|
|
139
139
|
const result = readResult(resultFile);
|
|
140
140
|
if (result.outcome !== "protocolized")
|
|
141
141
|
throw new AppError("validation", "PROTOCOLIZE may finish only as protocolized; use stage pause for every user question", 2);
|
|
@@ -162,7 +162,7 @@ export function finishVnextProtocolize(context, input) {
|
|
|
162
162
|
const reportMarkdown = path.join(root, "stage-report.md");
|
|
163
163
|
const reportHtml = path.join(root, "stage-report.html");
|
|
164
164
|
writeStageReport(root, report);
|
|
165
|
-
validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
|
|
165
|
+
validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
|
|
166
166
|
completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "protocolize-result.json", dataSchemaId: "dd-flow/vnext-protocolize-result@3", report: "stage-report.md", stageReport: "stage-report.html" });
|
|
167
167
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "protocol_documents_materialized", work_id: work.work_id, protocol_ids: protocolIds, stage });
|
|
168
168
|
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
|
|
@@ -318,7 +318,7 @@ function readResult(file) {
|
|
|
318
318
|
function acceptedSpecifyObligations(context, projectRoot, runId, specifyPath) {
|
|
319
319
|
if (!fs.existsSync(specifyPath))
|
|
320
320
|
throw new AppError("not_found", "PROTOCOLIZE requires accepted specify.json", 1, { path: specifyPath });
|
|
321
|
-
validateSchema({ schemaName: "vnext-specify", file: specifyPath, projectRoot, ddFlowHome: context.ddFlowHome, runId });
|
|
321
|
+
validateSchema({ schemaName: "vnext-specify", file: specifyPath, projectRoot, ddFlowHome: context.ddFlowHome, runId, runRoot: requireRunHome(requireRun(context, projectRoot, runId)) });
|
|
322
322
|
const specify = readVnextSpecifyResult(specifyPath);
|
|
323
323
|
return [
|
|
324
324
|
...specify.requirements.map((obligation) => ({ ...obligation, kind: "requirement" })),
|
|
@@ -498,10 +498,10 @@ function featureIndex(epicRoot, featureSlug) {
|
|
|
498
498
|
}
|
|
499
499
|
function requireWork(context, id) { const work = context.db.get("SELECT * FROM works WHERE work_id = ?", [id]); if (!work)
|
|
500
500
|
throw new AppError("not_found", "Work is not registered", 1, { work_id: id }); return work; }
|
|
501
|
-
function requireRun(context, projectRoot, id) { const project = requireProjectByRoot(context, projectRoot); const run = context.db.get("SELECT id, short_id, slug, project_id, workspace_root,
|
|
501
|
+
function requireRun(context, projectRoot, id) { const project = requireProjectByRoot(context, projectRoot); const run = context.db.get("SELECT id, short_id, slug, project_id, workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, id]); if (!run)
|
|
502
502
|
throw new AppError("not_found", "RUN is not registered", 1, { run_id: id }); return run; }
|
|
503
|
-
function requireRunHome(run) { if (!run.
|
|
504
|
-
throw new AppError("runtime_missing", "RUN
|
|
503
|
+
function requireRunHome(run) { if (!run.run_root)
|
|
504
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
|
|
505
505
|
function inside(root, file) { const relative = path.relative(root, file); if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
506
506
|
throw new AppError("path_escape", "Result must be inside the protocolize workspace", 2); }
|
|
507
507
|
function writeJson(file, value) { const tmp = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(tmp, file); }
|
|
@@ -141,7 +141,7 @@ export function submitVnextSpecify(context, input) {
|
|
|
141
141
|
throw new AppError("not_found", "--result-file must point to an existing file inside the SPECIFY workspace", 1, { result_file: input.resultFile });
|
|
142
142
|
}
|
|
143
143
|
try {
|
|
144
|
-
validateSchema({ schemaName: "vnext-specify", file: candidateFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
|
|
144
|
+
validateSchema({ schemaName: "vnext-specify", file: candidateFile, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: runHome });
|
|
145
145
|
const result = readVnextSpecifyResult(candidateFile);
|
|
146
146
|
validateObligations(result, candidateFile);
|
|
147
147
|
const normalizedResult = `${JSON.stringify(result, null, 2)}\n`;
|
|
@@ -169,7 +169,7 @@ export function submitVnextSpecify(context, input) {
|
|
|
169
169
|
const htmlPath = path.join(stageRoot, "stage-report.html");
|
|
170
170
|
const report = buildStageReport({ run, work, workSession, outcome, result, resultMarkdown: renderedMarkdown, now, stageRoot, resultFile });
|
|
171
171
|
writeStageReport(stageRoot, report);
|
|
172
|
-
validateSchema({ schemaName: "stage-report", file: reportPath, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id });
|
|
172
|
+
validateSchema({ schemaName: "stage-report", file: reportPath, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: runHome });
|
|
173
173
|
completeFlowRunStage(context, {
|
|
174
174
|
projectRoot,
|
|
175
175
|
runId: run.id,
|
|
@@ -268,7 +268,7 @@ function readIntake(input) {
|
|
|
268
268
|
}
|
|
269
269
|
function requireRun(context, projectRoot, runId) {
|
|
270
270
|
const project = requireProjectByRoot(context, projectRoot);
|
|
271
|
-
const run = context.db.get("SELECT id, project_id, project_root, workspace_root,
|
|
271
|
+
const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]);
|
|
272
272
|
if (!run)
|
|
273
273
|
throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
|
|
274
274
|
return run;
|
|
@@ -280,9 +280,9 @@ function runStatus(context, projectId, runId) {
|
|
|
280
280
|
return run.status;
|
|
281
281
|
}
|
|
282
282
|
function requiredRunHome(run) {
|
|
283
|
-
if (!run.
|
|
284
|
-
throw new AppError("runtime_missing", "RUN has no portable
|
|
285
|
-
return run.
|
|
283
|
+
if (!run.run_root)
|
|
284
|
+
throw new AppError("runtime_missing", "RUN has no portable artifact root", 1, { run_id: run.id });
|
|
285
|
+
return run.run_root;
|
|
286
286
|
}
|
|
287
287
|
function requireWork(context, workId) {
|
|
288
288
|
const work = context.db.get("SELECT * FROM works WHERE work_id = ?", [workId]);
|