@deksden-com/dd-flow-cli 0.9.0-beta.0 → 0.9.0-beta.7
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 +56 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +2 -2
- package/dist/cli/run-cli.js +105 -7
- package/dist/schemas/code-work-batch.schema.json +3 -3
- 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/eval-snapshots.js +5 -2
- 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 +5 -0
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/runs.js +50 -6
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +26 -11
- package/dist/services/stage-pause.js +31 -16
- package/dist/services/usage.js +81 -57
- package/dist/services/vnext-code-review.js +46 -12
- package/dist/services/vnext-code.js +59 -22
- package/dist/services/vnext-merge.js +102 -42
- package/dist/services/vnext-plan-review.js +31 -13
- package/dist/services/vnext-plan.js +57 -9
- package/dist/services/work-registry.js +130 -27
- package/dist/storage/database.js +125 -2
- package/package.json +12 -12
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
|
@@ -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);
|
|
@@ -76,12 +79,19 @@ export function startVnextPlan(context, input) {
|
|
|
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
84
|
? `- The measured reviewer capacity is ${measuredCapacity}. This is a runtime fact for later PLAN-REVIEW dispatch; do not repeat the probe or invent a different value.`
|
|
81
85
|
: "- Reviewer capacity is not measured yet. PLAN must not probe or launch reviewers; PLAN-REVIEW will measure it once if review is enabled.";
|
|
82
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 measures current capacity once and schedules 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);
|
|
@@ -177,6 +187,15 @@ export function validateVnextPlanArtifacts(context, input) {
|
|
|
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 {
|
|
@@ -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
|
+
}
|
|
@@ -4,12 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
5
|
import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
6
6
|
import { validateSchema } from "./schema-validation.js";
|
|
7
|
-
import { resolveProjectRoot, resolveRunReferences
|
|
7
|
+
import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
|
|
8
8
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
9
|
-
import { readCodeCheckProfile, runCodeChecks } from "./code-checks.js";
|
|
9
|
+
import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
|
|
10
10
|
import { nextWorkId, nextWorkIds } from "./ids.js";
|
|
11
11
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
12
12
|
import { flowCommand } from "./stage-pause.js";
|
|
13
|
+
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
14
|
+
import { publicSessionIdentity } from "./session-identity.js";
|
|
13
15
|
const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
|
|
14
16
|
export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
|
|
15
17
|
export function createChildWork(context, input) {
|
|
@@ -111,7 +113,7 @@ export function listWorks(context, input) {
|
|
|
111
113
|
return { ...work, payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), ready: isReadyNow, ...(isReadyNow ? { start_command: workStartCommand(context, work) } : {}), ...(input.includeResults ? {} : { result: undefined }) };
|
|
112
114
|
}) };
|
|
113
115
|
}
|
|
114
|
-
export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? ORDER BY created_at", [work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions } }; }
|
|
116
|
+
export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id, ws.status, ws.prompt_path, ws.result_path, ws.created_at, ws.completed_at, s.harness AS harness_id, COALESCE(s.provider_session_id, s.session_id) AS native_session_id FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at", [work.project_id, work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions: sessions.map(({ session_id, harness_id, native_session_id, ...session }) => ({ ...session, ...(harness_id && native_session_id ? { session: publicSessionIdentity({ harness: harness_id, provider_session_id: native_session_id, session_id }) } : {}) })) } }; }
|
|
115
117
|
export function mutateWorkDeps(context, input) {
|
|
116
118
|
const work = requireWork(context, input.workId);
|
|
117
119
|
if (input.action === "list")
|
|
@@ -262,6 +264,17 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
262
264
|
fs.mkdirSync(directory, { recursive: true });
|
|
263
265
|
const promptPath = path.join(directory, "prompt.md");
|
|
264
266
|
const resultPath = path.join(directory, "result.json");
|
|
267
|
+
const contextPath = path.join(directory, "context.json");
|
|
268
|
+
const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
269
|
+
const prompt = renderWorkerPrompt(context, work, run, dependencyResults);
|
|
270
|
+
const payload = parsePayload(work);
|
|
271
|
+
const readOnly = payload?.read_only === true;
|
|
272
|
+
const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
|
|
273
|
+
const token = crypto.randomUUID();
|
|
274
|
+
const promptCandidate = `${promptPath}.${token}.tmp`;
|
|
275
|
+
const contextCandidate = `${contextPath}.${token}.tmp`;
|
|
276
|
+
fs.writeFileSync(promptCandidate, prompt);
|
|
277
|
+
fs.writeFileSync(contextCandidate, `${JSON.stringify(workContext, null, 2)}\n`);
|
|
265
278
|
context.db.exec("BEGIN IMMEDIATE");
|
|
266
279
|
try {
|
|
267
280
|
bindSession(context, work, run, identity, now);
|
|
@@ -271,23 +284,23 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
271
284
|
if (claimed.changes !== 1)
|
|
272
285
|
throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
|
|
273
286
|
context.db.run(`INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)`, [linkId, work.work_id, identity.sessionId, hookEventId, promptPath, resultPath, now, now]);
|
|
287
|
+
fs.renameSync(promptCandidate, promptPath);
|
|
288
|
+
fs.renameSync(contextCandidate, contextPath);
|
|
274
289
|
context.db.exec("COMMIT");
|
|
275
290
|
}
|
|
276
291
|
catch (error) {
|
|
277
292
|
context.db.exec("ROLLBACK");
|
|
293
|
+
fs.rmSync(promptCandidate, { force: true });
|
|
294
|
+
fs.rmSync(contextCandidate, { force: true });
|
|
278
295
|
throw error;
|
|
279
296
|
}
|
|
280
|
-
const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
281
|
-
const prompt = renderWorkerPrompt(context, work, run, dependencyResults, resultPath);
|
|
282
|
-
writeJsonAtomic(path.join(directory, "context.json"), { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema });
|
|
283
|
-
fs.writeFileSync(promptPath, prompt);
|
|
284
297
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
285
|
-
return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session_id: identity.
|
|
298
|
+
return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
|
|
286
299
|
}
|
|
287
300
|
export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
|
|
288
301
|
/** Structured fan-in closes a parent after its Session was handed to a child. */
|
|
289
302
|
export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
|
|
290
|
-
throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running') LIMIT 1", [work.work_id]))
|
|
303
|
+
throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
|
|
291
304
|
throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
|
|
292
305
|
export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
|
|
293
306
|
export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
|
|
@@ -302,7 +315,7 @@ async function settle(context, id, status, result, progress) {
|
|
|
302
315
|
id = work.work_id;
|
|
303
316
|
if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
|
|
304
317
|
throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
|
|
305
|
-
if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running') LIMIT 1", [id]))
|
|
318
|
+
if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [id]))
|
|
306
319
|
throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: id });
|
|
307
320
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
308
321
|
const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [id]);
|
|
@@ -311,7 +324,14 @@ async function settle(context, id, status, result, progress) {
|
|
|
311
324
|
let receipts = [];
|
|
312
325
|
let coordinationDrift = [];
|
|
313
326
|
if (status === "completed") {
|
|
314
|
-
|
|
327
|
+
if (parsePayload(work)?.read_only === true) {
|
|
328
|
+
const contextFile = link ? path.join(path.dirname(link.prompt_path), "context.json") : "";
|
|
329
|
+
const baseline = contextFile && fs.existsSync(contextFile) ? JSON.parse(fs.readFileSync(contextFile, "utf8")).workspace_fingerprint : null;
|
|
330
|
+
const current = workspaceFingerprint(run.workspace_root);
|
|
331
|
+
if (typeof baseline !== "string" || baseline !== current)
|
|
332
|
+
throw new AppError("read_only_work_mutated_workspace", "Read-only Work changed the accepted workspace", 2, { work_id: work.work_id, baseline, current });
|
|
333
|
+
}
|
|
334
|
+
validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
|
|
315
335
|
const packet = codePacket(work);
|
|
316
336
|
if (packet) {
|
|
317
337
|
// CODE packets are projected from a PLAN that already validated this
|
|
@@ -362,21 +382,33 @@ function bindSession(context, work, run, identity, now) {
|
|
|
362
382
|
const inferredParentSession = work.parent_work_id
|
|
363
383
|
? context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.parent_work_id])?.session_id ?? null
|
|
364
384
|
: priorWorkSession && priorWorkSession !== identity.sessionId ? priorWorkSession : null;
|
|
365
|
-
|
|
385
|
+
// Work ancestry and provider-tree containment are different relations. An
|
|
386
|
+
// isolated worker is logically a child Work but physically a provider root.
|
|
387
|
+
const parentSession = inferredParentSession ?? identity.parentSessionId;
|
|
388
|
+
const providerParentSession = identity.parentSessionId;
|
|
366
389
|
if (work.parent_work_id && !parentSession)
|
|
367
390
|
throw new AppError("parent_session_required", "Child Work requires a confirmed parent Work/Session link", 1, { work_id: work.work_id, parent_work_id: work.parent_work_id });
|
|
368
391
|
if (work.launch_policy === "fresh_agent_required" && (identity.sessionId === parentSession || context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? LIMIT 1", [work.project_id, work.run_id, identity.sessionId])))
|
|
369
392
|
throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id, session_id: identity.sessionId });
|
|
370
|
-
const existing = context.db.get("SELECT session_id, parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
|
|
393
|
+
const existing = context.db.get("SELECT session_id, parent_session_id, provider_parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
|
|
371
394
|
if (existing && existing.parent_session_id && parentSession && existing.parent_session_id !== parentSession)
|
|
372
395
|
throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { session_id: identity.sessionId });
|
|
373
|
-
|
|
396
|
+
if (existing?.provider_parent_session_id && providerParentSession && existing.provider_parent_session_id !== providerParentSession)
|
|
397
|
+
throw new AppError("provider_session_parent_conflict", "Observed provider Session already has a different immutable provider parent", 1, { session_id: identity.sessionId });
|
|
398
|
+
context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_session_id, provider_parent_session_id, agent_id, parent_session_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path, continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason, transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vnext', 'active', ?, NULL, ?, ?, 'go_router', 'work', NULL, NULL, 0, NULL, ?, ?, '{}', '[]', ?, ?, NULL) ON CONFLICT(session_id, project_id) DO UPDATE SET harness = excluded.harness, provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id), provider_parent_session_id = COALESCE(excluded.provider_parent_session_id, provider_parent_session_id), agent_id = COALESCE(excluded.agent_id, agent_id), parent_session_id = COALESCE(excluded.parent_session_id, parent_session_id), provider = COALESCE(excluded.provider, provider), model = COALESCE(excluded.model, model), reasoning = COALESCE(excluded.reasoning, reasoning), mode = COALESCE(excluded.mode, mode), agent_type = COALESCE(excluded.agent_type, agent_type), flow_kind = excluded.flow_kind, run_id = excluded.run_id, worker_id = excluded.worker_id, workspace_path = excluded.workspace_path, transcript_path = COALESCE(excluded.transcript_path, transcript_path), cwd = excluded.cwd, updated_at = excluded.updated_at`, [identity.sessionId, work.project_id, identity.harness, identity.providerSessionId, providerParentSession, identity.agentId, existing?.parent_session_id ?? (parentSession === identity.sessionId ? null : parentSession), identity.provider, identity.model, identity.reasoning, identity.mode, identity.agentType, run.project_root, work.run_id, work.work_id, run.workspace_root, identity.transcriptPath, run.workspace_root, now, now]);
|
|
374
399
|
reactivateBoundSession(context, work.project_id, identity.sessionId, now);
|
|
375
400
|
}
|
|
401
|
+
/** Register the trusted physical Session before a paused Work moves to it. */
|
|
402
|
+
export function bindSessionForResume(context, workId, identity, now) {
|
|
403
|
+
const work = requireWork(context, workId);
|
|
404
|
+
if (work.status !== "paused")
|
|
405
|
+
throw new AppError("invalid_work_state", "Only a paused Work can bind a resume Session", 1, { work_id: work.work_id, status: work.status });
|
|
406
|
+
bindSession(context, work, requireRun(context, work.project_id, work.run_id), identity, now);
|
|
407
|
+
}
|
|
376
408
|
function reactivateBoundSession(context, projectId, sessionId, now) {
|
|
377
409
|
context.db.run("UPDATE sessions SET status = 'active', stop_reason = NULL, stopped_at = NULL, updated_at = ? WHERE project_id = ? AND session_id = ?", [now, projectId, sessionId]);
|
|
378
410
|
}
|
|
379
|
-
function validateWorkResult(work, result, projectRoot, workspaceRoot, runId, resultPath) {
|
|
411
|
+
function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, runId, resultPath) {
|
|
380
412
|
if (!work.result_schema)
|
|
381
413
|
return;
|
|
382
414
|
if (!resultPath)
|
|
@@ -388,20 +420,35 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runId, res
|
|
|
388
420
|
catch {
|
|
389
421
|
throw new AppError("validation", "Work result must be JSON for its declared result schema", 2, { work_id: work.work_id, result_schema: work.result_schema });
|
|
390
422
|
}
|
|
391
|
-
|
|
392
|
-
|
|
423
|
+
const candidate = `${resultPath}.candidate-${process.pid}`;
|
|
424
|
+
fs.writeFileSync(candidate, result);
|
|
425
|
+
try {
|
|
426
|
+
validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: candidate, projectRoot, runId });
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
fs.rmSync(candidate, { force: true });
|
|
430
|
+
}
|
|
393
431
|
if (work.result_schema === "dd-flow/code-work-result@2")
|
|
394
|
-
validateCodeWorkResult(work, parsed, workspaceRoot);
|
|
432
|
+
validateCodeWorkResult(work, parsed, workspaceRoot, runHome, runId);
|
|
395
433
|
if (work.result_schema === "dd-flow/code-review-result@1")
|
|
396
|
-
|
|
434
|
+
validateCodeReviewResult(work, parsed, { workspaceRoot, runHome, runId });
|
|
435
|
+
if (work.result_schema === "dd-flow/plan-review-result@1")
|
|
436
|
+
validatePlanReviewResult(work, parsed, { workspaceRoot, runHome, runId });
|
|
397
437
|
}
|
|
398
|
-
function validateCodeWorkResult(work, value, projectRoot) {
|
|
438
|
+
function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
|
|
399
439
|
const result = value;
|
|
400
440
|
if ((result.deviations?.length ?? 0) > 0 || (result.blockers?.length ?? 0) > 0)
|
|
401
441
|
throw new AppError("work_contract_incomplete", "CODE Work cannot complete with unresolved deviations or blockers; fail the Work and report the contract mismatch", 2, { work_id: work.work_id, deviations: result.deviations ?? [], blockers: result.blockers ?? [] });
|
|
402
442
|
const packet = codePacket(work);
|
|
403
443
|
if (!packet)
|
|
404
444
|
return;
|
|
445
|
+
const acceptedCriteria = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
|
|
446
|
+
for (const item of result.evidence ?? []) {
|
|
447
|
+
if (!item.criterion_id || !acceptedCriteria.has(item.criterion_id))
|
|
448
|
+
throw new AppError("evidence_criterion_unknown", "CODE Work evidence must reference an acceptance criterion assigned to this Work", 2, { work_id: work.work_id, criterion_id: item.criterion_id ?? null });
|
|
449
|
+
for (const ref of item.refs ?? [])
|
|
450
|
+
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
451
|
+
}
|
|
405
452
|
const documentUpdates = (packet.document_updates ?? []);
|
|
406
453
|
const changed = new Set(result.changed_paths ?? []);
|
|
407
454
|
const missingDocuments = documentUpdates.map((entry) => entry.path).filter((entry) => !changed.has(entry));
|
|
@@ -413,6 +460,8 @@ function validateCodeWorkResult(work, value, projectRoot) {
|
|
|
413
460
|
throw new AppError("document_update_not_materialized", "Assigned durable document updates must exist and differ from their PLAN baseline", 2, { work_id: work.work_id, unchanged_paths: unchangedDocuments });
|
|
414
461
|
const assigned = packet.repair?.review_finding_ids ?? [];
|
|
415
462
|
if (assigned.length) {
|
|
463
|
+
if ((result.changed_paths?.length ?? 0) === 0)
|
|
464
|
+
throw new AppError("review_repair_no_change", "Review repair must materialize a project change; a no-op cannot resolve a finding", 2, { work_id: work.work_id, findings: assigned });
|
|
416
465
|
const resolved = new Set(result.resolved_finding_refs ?? []);
|
|
417
466
|
const missing = assigned.filter((finding) => !resolved.has(finding));
|
|
418
467
|
const unexpected = [...resolved].filter((finding) => !assigned.includes(finding));
|
|
@@ -420,24 +469,25 @@ function validateCodeWorkResult(work, value, projectRoot) {
|
|
|
420
469
|
throw new AppError("review_repair_incomplete", "Review repair must explicitly resolve exactly its assigned finding references", 2, { work_id: work.work_id, missing, unexpected });
|
|
421
470
|
}
|
|
422
471
|
}
|
|
423
|
-
function renderWorkerPrompt(context, work, run, dependencies
|
|
472
|
+
function renderWorkerPrompt(context, work, run, dependencies) {
|
|
424
473
|
const command = flowCommand(context);
|
|
425
474
|
const packet = codePacket(work);
|
|
426
475
|
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies the materialization and then executes the check.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
|
|
427
476
|
if (packet)
|
|
428
477
|
codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
|
|
429
|
-
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", `HARD RULE:
|
|
478
|
+
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", `HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not.", "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "The CLI runs every declared required check before accepting this Work. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", "Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output.", "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
|
|
430
479
|
}
|
|
431
|
-
function resultSchemaGuidance(work) {
|
|
480
|
+
function resultSchemaGuidance(work, runId) {
|
|
432
481
|
const schema = work.result_schema;
|
|
482
|
+
const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
|
|
433
483
|
if (schema === "dd-flow/code-work-result@2")
|
|
434
|
-
return ["Use this complete minimal shape. For a review repair, also include resolved_finding_refs with exactly the finding references assigned in repair_context:", "```json", JSON.stringify({ schema_id: schema, summary: "What was implemented.", changed_paths: ["project-relative/path"], evidence: [{ criterion_id: "AC-001", refs: ["project-relative/evidence"] }], deviations: [], blockers: [], resolved_finding_refs: [] }, null, 2), "```"];
|
|
484
|
+
return [refs, "Use this complete minimal shape. For a review repair, also include resolved_finding_refs with exactly the finding references assigned in repair_context:", "```json", JSON.stringify({ schema_id: schema, summary: "What was implemented.", changed_paths: ["project-relative/path"], evidence: [{ criterion_id: "AC-001", refs: ["project-relative/evidence", `run://${runId}/05-code/checks/receipt.json`] }], deviations: [], blockers: [], resolved_finding_refs: [] }, null, 2), "```"];
|
|
435
485
|
if (schema === "dd-flow/code-review-result@1") {
|
|
436
|
-
return ["Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Use this complete minimal shape. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: ["path/to/file"] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
|
|
486
|
+
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Use this complete minimal shape. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: ["path/to/file", `run://${runId}/05-code/checks/receipt.json`] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
|
|
437
487
|
}
|
|
438
488
|
if (schema !== "dd-flow/plan-review-result@1")
|
|
439
489
|
return [];
|
|
440
|
-
return ["Use this complete minimal shape; do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/file"], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
|
|
490
|
+
return [refs, "Use this complete minimal shape; do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/file", `run://${runId}/03-plan/plan.json`], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
|
|
441
491
|
}
|
|
442
492
|
export function validateCodeReviewResultIdentity(work, value) {
|
|
443
493
|
const group = codeReviewGroup(work);
|
|
@@ -458,6 +508,57 @@ export function validateCodeReviewResultIdentity(work, value) {
|
|
|
458
508
|
throw new AppError("review_evidence_invalid", "CODE reviewer finding ids must be local FIND-NNN ids and aspect refs must stay inside the assigned review group", 2, { work_id: work.work_id, group: group.key, required_format: "FIND-NNN", invalid, wrong_aspect: wrongAspect });
|
|
459
509
|
}
|
|
460
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* CODE review evidence is part of the Work contract, not a late stage-level
|
|
513
|
+
* concern. Reject it before `works.result` becomes authoritative so a later
|
|
514
|
+
* fan-in cannot discover a malformed accepted reviewer result.
|
|
515
|
+
*/
|
|
516
|
+
function validateCodeReviewResult(work, value, input) {
|
|
517
|
+
validateCodeReviewResultIdentity(work, value);
|
|
518
|
+
const result = value;
|
|
519
|
+
const findings = result.findings ?? [];
|
|
520
|
+
// A reviewer is a child Work and cannot pause its coordinator-owned Stage.
|
|
521
|
+
// Its structured blocked result is evidence for the coordinator, which then
|
|
522
|
+
// either resolves the gap or pauses the Stage itself.
|
|
523
|
+
if ((result.verdict === "pass" && findings.length > 0) || (result.verdict === "findings" && findings.length === 0)) {
|
|
524
|
+
throw new AppError("review_evidence_invalid", "CODE reviewer verdict must agree with whether material findings are present", 2, { work_id: work.work_id, verdict: result.verdict, findings: findings.length });
|
|
525
|
+
}
|
|
526
|
+
const references = [
|
|
527
|
+
...(result.aspects ?? []).flatMap((aspect) => Array.isArray(aspect.evidence_refs) ? aspect.evidence_refs : []),
|
|
528
|
+
...(result.findings ?? []).flatMap((finding) => Array.isArray(finding.evidence_refs) ? finding.evidence_refs : [])
|
|
529
|
+
];
|
|
530
|
+
for (const ref of references) {
|
|
531
|
+
if (typeof ref !== "string")
|
|
532
|
+
throw new AppError("review_evidence_invalid", "CODE reviewer evidence references must be strings", 2, { work_id: work.work_id, ref });
|
|
533
|
+
assertPortableArtifactRef(ref, input);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
export function validatePlanReviewResult(work, value, input) {
|
|
537
|
+
const group = codeReviewGroup(work);
|
|
538
|
+
if (!group)
|
|
539
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer Work has no assigned review group", 2, { work_id: work.work_id });
|
|
540
|
+
const result = value;
|
|
541
|
+
const aspects = result.aspects ?? [];
|
|
542
|
+
const ids = aspects.map((item) => item.aspect_id ?? "");
|
|
543
|
+
const expected = new Set(group.aspect_ids);
|
|
544
|
+
const missing = group.aspect_ids.filter((id) => !ids.includes(id));
|
|
545
|
+
const unexpected = ids.filter((id) => !expected.has(id));
|
|
546
|
+
if (new Set(ids).size !== ids.length || missing.length || unexpected.length)
|
|
547
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer result must assess every assigned aspect exactly once", 2, { work_id: work.work_id, group: group.key, missing, unexpected });
|
|
548
|
+
const findingIds = aspects.flatMap((aspect) => aspect.findings ?? []).map((finding) => finding.finding_id ?? "");
|
|
549
|
+
const invalid = findingIds.filter((id) => !/^FIND-\d{3}$/.test(id));
|
|
550
|
+
if (new Set(findingIds).size !== findingIds.length || invalid.length)
|
|
551
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer finding ids must be unique local FIND-NNN ids", 2, { work_id: work.work_id, invalid });
|
|
552
|
+
const refs = aspects.flatMap((aspect) => [
|
|
553
|
+
...(Array.isArray(aspect.evidence_refs) ? aspect.evidence_refs : []),
|
|
554
|
+
...(aspect.findings ?? []).flatMap((finding) => Array.isArray(finding.evidence_refs) ? finding.evidence_refs : [])
|
|
555
|
+
]);
|
|
556
|
+
for (const ref of refs) {
|
|
557
|
+
if (typeof ref !== "string")
|
|
558
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer evidence references must be strings", 2, { work_id: work.work_id, ref });
|
|
559
|
+
assertPortableArtifactRef(ref, input);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
461
562
|
function codeReviewGroup(work) {
|
|
462
563
|
const payload = parsePayload(work);
|
|
463
564
|
const group = payload?.group;
|
|
@@ -505,12 +606,14 @@ function validateItem(value, requireExecutionContext = false) { if (!value || ty
|
|
|
505
606
|
throw new AppError("validation", "work item must be an object", 2); const item = value; if (typeof item.key !== "string" || !item.key || typeof item.task !== "string" || !item.task.trim())
|
|
506
607
|
throw new AppError("validation", "work item requires key and task", 2); const code = item.schema_id === "dd-flow/code-work-packet@5"; if (requireExecutionContext && !code)
|
|
507
608
|
throw new AppError("validation", "CODE batch requires code-work-packet@5 items", 2, { key: item.key }); for (const key of code ? ["required_read", "discovery_boundary", "planned_write_areas", "checks", "provides_checks", "stop_conditions"] : [])
|
|
508
|
-
if (!Array.isArray(item[key]) || (!['provides_checks', 'planned_write_areas'].includes(key) && item[key].length === 0))
|
|
609
|
+
if (!Array.isArray(item[key]) || (!['provides_checks', 'planned_write_areas', 'checks'].includes(key) && item[key].length === 0) || (key === "checks" && item[key].length === 0 && !allowsEmptyRepairChecks(item)))
|
|
509
610
|
throw new AppError("validation", `CODE work item requires ${['provides_checks', 'planned_write_areas'].includes(key) ? "an array" : `non-empty ${key}`}`, 2, { key: item.key }); if (item.depends_on !== undefined && (!Array.isArray(item.depends_on) || !item.depends_on.every((entry) => typeof entry === "string")))
|
|
510
611
|
throw new AppError("validation", "depends_on must be a string array", 2); if (item.parent !== undefined && typeof item.parent !== "string")
|
|
511
612
|
throw new AppError("validation", "parent must be a string", 2); if (item.launch_policy !== undefined && item.launch_policy !== "reuse_allowed" && item.launch_policy !== "fresh_agent_required")
|
|
512
613
|
throw new AppError("validation", "launch_policy must be reuse_allowed or fresh_agent_required", 2); if (item.result_schema !== undefined && (typeof item.result_schema !== "string" || !item.result_schema))
|
|
513
614
|
throw new AppError("validation", "result_schema must be a non-empty schema id", 2); const payload = code ? item : (item.payload && typeof item.payload === "object" && !Array.isArray(item.payload) ? item.payload : undefined); return { key: item.key, task: item.task, ...(Array.isArray(item.depends_on) ? { depends_on: item.depends_on } : {}), ...(typeof item.parent === "string" ? { parent: item.parent } : {}), ...(typeof item.launch_policy === "string" ? { launch_policy: item.launch_policy } : {}), ...(typeof item.result_schema === "string" ? { result_schema: item.result_schema } : {}), ...(payload ? { payload } : {}) }; }
|
|
615
|
+
function allowsEmptyRepairChecks(item) { const repair = item.repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
|
|
616
|
+
return false; const value = repair; return typeof value.check_receipt_id === "string" || (Array.isArray(value.review_check_refs) && value.review_check_refs.some((ref) => typeof ref === "string")) || (Array.isArray(value.semantic_unresolved) && value.semantic_unresolved.some((item) => typeof item === "string")); }
|
|
514
617
|
function readJson(file) { try {
|
|
515
618
|
return JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
|
|
516
619
|
}
|