@deksden-com/dd-flow-cli 0.9.0-beta.15 → 0.9.0-beta.17

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.17
4
+
5
+ ### Patch Changes
6
+
7
+ - 0dd4da3: Verify current-attempt gate retries, deduplicate equivalent checks across gate references, retain archive locations across interrupted and repeated repairs, and align MERGE environment recovery guidance with source repair.
8
+
9
+ Snapshot SQLite consistently with VACUUM INTO and exclude local environment secrets, dependencies and caches from project/workspace snapshots.
10
+
11
+ Reconcile native child failures only against unique registered Work/Session attempts, retain non-restorable incomplete snapshots for failed evals, and expose unexpected merge-lane release failures for safe retry.
12
+
13
+ ## 0.9.0-beta.16
14
+
15
+ ### Patch Changes
16
+
17
+ - Make repair verification causal and retryable, preserve attempt-qualified
18
+ evidence, expose RUN continuation from persisted lifecycle state, and keep
19
+ native child ownership bound to the physical harness session.
20
+
3
21
  ## 0.9.0-beta.15
4
22
 
5
23
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.15",
4
- "cli_commit": "e11a611fe87370d6203792e5c6a54d7205235a26",
5
- "built_at": "2026-09-05T09:50:59.828Z",
3
+ "cli_version": "0.9.0-beta.17",
4
+ "cli_commit": "162dd994d19c0d454a2ad6b7b62abd70c3e848d6",
5
+ "built_at": "2026-09-06T00:16:19.328Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.0.4",
8
- "commit": "c8cae271f844fd3b3c7492f164e13a60eaba4839",
8
+ "commit": "ced59c19d0a4062146a1dc4ccb977e10284a2fe4",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
10
  "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
11
  "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
package/dist/cli/help.js CHANGED
@@ -154,7 +154,7 @@ Usage:
154
154
  dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
155
155
  dd-flow run list --project-root <root> --json
156
156
  dd-flow run prepare-vnext-specify --project-root <root> --slug <slug> --json
157
- dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate) --project-root <root> --output <directory> --json
157
+ dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate|--incomplete) --project-root <root> --output <directory> --json
158
158
  dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> --json
159
159
  dd-flow run snapshot bootstrap create --project-root <root> --output <directory> --json
160
160
  dd-flow run snapshot bootstrap restore --snapshot <directory> --project-root <prepared-root> --json
@@ -190,6 +190,7 @@ Usage:
190
190
  dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
191
191
  dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
192
192
  dd-flow stage fanout dispatch <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
193
+ dd-flow stage fanout reconcile <RUN-ID> --project-root <root> --stage <name> --observations-file <native-observations.json> --json
193
194
  dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--semantic-file <RUN-local-json>] --json
194
195
  dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage specify (--result-file <RUN-local-specify.json>|--result-stdin) --outcome <specified|failed|cancelled> --json
195
196
 
@@ -41,7 +41,7 @@ import { addVnextCodeRepair, finishVnextCode, startVnextCode } from "../services
41
41
  import { addVnextCodeReviewRepair, finishVnextCodeReview, isVnextCodeReviewRun, startVnextCodeReview } from "../services/vnext-code-review.js";
42
42
  import { applyVnextMerge, finishVnextMerge, getVnextMergeRequest, isVnextMergeRun, repairVnextMerge, routeVnextMergeRequest, startVnextMerge } from "../services/vnext-merge.js";
43
43
  import { mergeServerStatus, serveMergeRequests, stopMergeServer } from "../services/merge-server.js";
44
- import { getVnextFanoutStatus } from "../services/vnext-fanout.js";
44
+ import { getVnextFanoutStatus, reconcileVnextFanout } from "../services/vnext-fanout.js";
45
45
  import { pauseStageForUser, resumeStageAfterUser } from "../services/stage-pause.js";
46
46
  import { blockStageForRuntime, unblockStageAfterRuntimeRepair } from "../services/stage-blocker.js";
47
47
  import { classifyCliOperation, requiresExplicitUpgradeAuthorization } from "../services/cli-operation-classifier.js";
@@ -661,12 +661,14 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
661
661
  const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "stage fanout");
662
662
  if (action === "status")
663
663
  return getVnextFanoutStatus(context, { projectRoot, runId, stage });
664
+ if (action === "reconcile")
665
+ return reconcileVnextFanout(context, { projectRoot, runId, stage, observations: JSON.parse(fs.readFileSync(requiredOption(parsed, "observations-file"), "utf8")) });
664
666
  if (action === "dispatch") {
665
667
  if (stage === "plan-review")
666
668
  return dispatchVnextPlanReview(context, { projectRoot, runId });
667
669
  return getVnextFanoutStatus(context, { projectRoot, runId, stage });
668
670
  }
669
- throw new AppError("usage", "Use: dd-flow stage fanout <status|dispatch> <RUN> --stage <stage> --project-root <path>", 2);
671
+ throw new AppError("usage", "Use: dd-flow stage fanout <status|dispatch|reconcile> <RUN> --stage <stage> --project-root <path>; reconcile requires --observations-file <native-observations.json>", 2);
670
672
  }
671
673
  if (family === "run" && command === "capacity") {
672
674
  if (requiredPosition(parsed, 0, "capacity action") !== "record")
@@ -878,6 +880,10 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
878
880
  const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
879
881
  const resultFile = optionalOption(parsed, "result-file") ?? semanticFile;
880
882
  const resultStdin = hasOption(parsed, "result-stdin");
883
+ const retryCheckId = optionalOption(parsed, "retry-check");
884
+ const retryReason = optionalOption(parsed, "reason");
885
+ if (retryCheckId && !retryReason?.trim())
886
+ throw new AppError("usage", "--retry-check requires --reason explaining the recovered environment", 2);
881
887
  if (stage === "specify" && isVnextSpecifyRun(context, { projectRoot, runId })) {
882
888
  const outcome = optionalOption(parsed, "outcome");
883
889
  if (!outcome || !["specified", "failed", "cancelled"].includes(outcome)) {
@@ -917,17 +923,17 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
917
923
  if (semanticFile || resultFile || resultStdin || hasOption(parsed, "outcome")) {
918
924
  throw new AppError("usage", "vNext CODE finish accepts only --verification-file", 2);
919
925
  }
920
- return await finishVnextCode(context, { projectRoot, runId, verificationFile: optionalOption(parsed, "verification-file") ?? path.join("<missing>", "code-verification.json"), progress: commandProgress(io, output) });
926
+ return await finishVnextCode(context, { projectRoot, runId, verificationFile: optionalOption(parsed, "verification-file") ?? path.join("<missing>", "code-verification.json"), ...(retryCheckId ? { retryCheckId, retryReason: retryReason } : {}), progress: commandProgress(io, output) });
921
927
  }
922
928
  if (stage === "code-review" && isVnextCodeReviewRun(context, { projectRoot, runId })) {
923
929
  if (resultStdin || semanticFile || resultFile || hasOption(parsed, "outcome"))
924
930
  throw new AppError("usage", "vNext CODE-REVIEW uses --decision-file", 2);
925
- return await finishVnextCodeReview(context, { projectRoot, runId, ...(optionalOption(parsed, "decision-file") ? { decisionFile: optionalOption(parsed, "decision-file") } : {}), progress: commandProgress(io, output) });
931
+ return await finishVnextCodeReview(context, { projectRoot, runId, ...(optionalOption(parsed, "decision-file") ? { decisionFile: optionalOption(parsed, "decision-file") } : {}), ...(retryCheckId ? { retryCheckId, retryReason: retryReason } : {}), progress: commandProgress(io, output) });
926
932
  }
927
933
  if (stage === "merge" && isVnextMergeRun(context, { projectRoot, runId })) {
928
934
  if (resultStdin || semanticFile || resultFile || hasOption(parsed, "outcome"))
929
935
  throw new AppError("usage", "vNext MERGE uses the Work result path returned by stage start", 2);
930
- return await finishVnextMerge(context, { projectRoot, runId, requestId: requiredOption(parsed, "request"), workId: requiredOption(parsed, "work"), progress: commandProgress(io, output) });
936
+ return await finishVnextMerge(context, { projectRoot, runId, requestId: requiredOption(parsed, "request"), workId: requiredOption(parsed, "work"), ...(retryCheckId ? { retryCheckId, retryReason: retryReason } : {}), progress: commandProgress(io, output) });
931
937
  }
932
938
  if (hasOption(parsed, "outcome") || hasOption(parsed, "result-file") || resultStdin) {
933
939
  throw new AppError("usage", "--outcome, --result-file and --result-stdin are only available for vNext SPECIFY", 2);
@@ -1431,16 +1437,17 @@ function dispatchRun(context, command, parsed) {
1431
1437
  }
1432
1438
  if (action === "create") {
1433
1439
  const candidate = parsed.options.has("candidate");
1440
+ const incomplete = parsed.options.has("incomplete");
1434
1441
  const stageEntry = optionalOption(parsed, "stage-entry");
1435
- if (candidate && stageEntry !== undefined)
1436
- throw new AppError("usage", "Use either --candidate or --stage-entry, not both", 2);
1437
- if (!candidate && stageEntry === undefined)
1438
- throw new AppError("usage", "Snapshot create requires --candidate or --stage-entry", 2);
1442
+ if (Number(candidate) + Number(incomplete) + Number(stageEntry !== undefined) !== 1)
1443
+ throw new AppError("usage", "Use exactly one of --candidate, --incomplete or --stage-entry", 2);
1439
1444
  const input = {
1440
1445
  projectRoot: requiredOption(parsed, "project-root"),
1441
1446
  runId: requiredPosition(parsed, 1, "run-id"),
1442
1447
  output: requiredOption(parsed, "output")
1443
1448
  };
1449
+ if (incomplete)
1450
+ return createEvalRunSnapshot(context, { ...input, incomplete: true });
1444
1451
  return candidate
1445
1452
  ? createEvalRunSnapshot(context, { ...input, candidate: true })
1446
1453
  : createEvalRunSnapshot(context, { ...input, stageEntry: stageEntry });
@@ -19,7 +19,7 @@
19
19
  "check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "minLength": 1}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}]},
20
20
  "documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason", "baseline_sha256"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}, "baseline_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}}},
21
21
  "reviewFinding": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "priority", "problem", "impact", "required_outcome", "evidence_refs", "obligation_refs", "decision_reason", "check_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "priority": {"enum": ["p0", "p1", "p2", "p3"]}, "problem": {"type": "string", "minLength": 1}, "impact": {"type": "string", "minLength": 1}, "required_outcome": {"type": "string", "minLength": 1}, "evidence_refs": {"$ref": "#/$defs/strings"}, "obligation_refs": {"$ref": "#/$defs/strings"}, "decision_reason": {"type": "string", "minLength": 1}, "check_refs": {"$ref": "#/$defs/strings"}}},
22
- "repair": {"type": "object", "additionalProperties": false, "required": ["origin_work_ids"], "anyOf": [{"required": ["check_receipt_id", "failure_receipt_path"]}, {"required": ["review_findings"]}, {"required": ["semantic_unresolved", "verification_path"]}], "properties": {"origin_work_ids": {"$ref": "#/$defs/strings"}, "check_receipt_id": {"type": "string", "minLength": 1}, "failure_receipt_path": {"type": "string", "minLength": 1}, "review_findings": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/reviewFinding"}}, "semantic_unresolved": {"$ref": "#/$defs/strings"}, "verification_path": {"type": "string", "minLength": 1}}},
22
+ "repair": {"type": "object", "additionalProperties": false, "required": ["origin_work_ids", "verification_check_refs"], "anyOf": [{"required": ["check_receipt_id", "failure_receipt_path"]}, {"required": ["review_findings"]}, {"required": ["semantic_unresolved", "verification_path"]}], "properties": {"origin_work_ids": {"$ref": "#/$defs/strings"}, "check_receipt_id": {"type": "string", "minLength": 1}, "failure_receipt_path": {"type": "string", "minLength": 1}, "review_findings": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/reviewFinding"}}, "semantic_unresolved": {"$ref": "#/$defs/strings"}, "verification_path": {"type": "string", "minLength": 1}, "verification_check_refs": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/check"}}}},
23
23
  "work": {"type": "object", "additionalProperties": false, "required": ["schema_id", "key", "launch_policy", "source", "task", "semantic_spine", "requirements", "acceptance", "document_updates", "required_read", "discovery_boundary", "planned_write_areas", "checks", "provides_checks", "stop_conditions", "depends_on", "result_schema"], "properties": {"schema_id": {"const": "dd-flow/code-work-packet@5"}, "key": {"type": "string", "minLength": 1}, "launch_policy": {"const": "fresh_agent_required"}, "source": {"$ref": "#/$defs/source"}, "repair": {"$ref": "#/$defs/repair"}, "task": {"type": "string", "minLength": 1}, "semantic_spine": {"$ref": "#/$defs/spine"}, "requirements": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/obligation"}}, "acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}}, "document_updates": {"type": "array", "items": {"$ref": "#/$defs/documentUpdate"}}, "required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources, not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas; additional project-local reads are allowed."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Soft coordination hints only. A worker may change any necessary project file inside the RUN workspace."}, "checks": {"type": "array", "items": {"$ref": "#/$defs/check"}}, "provides_checks": {"type": "array", "items": {"$ref": "#/$defs/check"}}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Hard semantic or external stop conditions; path prediction drift is not a stop condition."}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "result_schema": {"const": "dd-flow/code-work-result@3"}}}
24
24
  }
25
25
  }
@@ -222,16 +222,46 @@ catch {
222
222
  } }
223
223
  export function unchangedFinalGateFailures(context, input) { const wanted = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)])); const current = workspaceFingerprint(input.workspaceRoot); const latest = new Map(); for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && item.check_refs.some((ref) => wanted.has(ref))))
224
224
  for (const ref of receipt.check_refs)
225
- latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
225
+ latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current && receipt.id !== input.retryReceiptId); }
226
+ /** Validate an explicit retry; the caller still executes a fresh real gate and retains a new receipt. */
227
+ export function validateFailedCheckRetry(context, input) {
228
+ if (!input.reason?.trim())
229
+ throw new AppError("retry_check_invalid", "--retry-check requires --reason describing the restored environment", 2);
230
+ const receipt = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).find((item) => item.id === input.receiptId);
231
+ if (!receipt || receipt.scope !== "aggregate" || !["failed", "aborted"].includes(receipt.status))
232
+ throw new AppError("retry_check_invalid", "--retry-check must name a failed or aborted aggregate receipt in this RUN", 2, { check_receipt_id: input.receiptId });
233
+ const relative = path.relative(input.artifactRoot, receipt.receipt_path);
234
+ if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`) || /^try-\d+([/\\]|$)/.test(relative))
235
+ throw new AppError("retry_check_invalid", "--retry-check must belong to this current stage attempt, not an archived or different stage", 2, { check_receipt_id: input.receiptId });
236
+ const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
237
+ if (!receipt.check_refs.some((ref) => declared.has(ref)))
238
+ throw new AppError("retry_check_invalid", "--retry-check does not belong to this stage gate", 2, { check_receipt_id: input.receiptId, check_refs: receipt.check_refs });
239
+ return receipt;
240
+ }
226
241
  export function checkReceipts(context, input) {
227
242
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
228
243
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
229
- return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), artifacts: JSON.parse(row.artifacts_json), resources: readReceiptResources(row.receipt_path) }));
244
+ return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), resources: readReceiptResources(row.receipt_path), ...resolvedReceiptArtifacts(row.receipt_path, JSON.parse(row.artifacts_json)) }));
245
+ }
246
+ /** File bytes remain immutable; only the reader projects their current location. */
247
+ function resolvedReceiptArtifacts(receiptPath, artifacts) {
248
+ let original = {};
249
+ try {
250
+ original = JSON.parse(fs.readFileSync(receiptPath, "utf8"));
251
+ }
252
+ catch { /* Historical missing receipts remain visibly incomplete. */ }
253
+ const currentRoot = path.dirname(receiptPath);
254
+ const oldRoot = original.receipt_path ? path.dirname(original.receipt_path) : currentRoot;
255
+ const relocate = (file) => {
256
+ const relative = path.relative(oldRoot, file);
257
+ return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) ? path.join(currentRoot, relative) : file;
258
+ };
259
+ return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })) };
230
260
  }
231
261
  export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
232
262
  export function workspaceChangedPaths(root) { const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (status.status !== 0)
233
263
  return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
234
- function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts, ports]); const current = groups.get(key); if (current)
264
+ function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([commands[index], artifacts, ports]); const current = groups.get(key); if (current)
235
265
  current.declarations.push(check);
236
266
  else
237
267
  groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
@@ -19,7 +19,7 @@ export function createEvalBootstrapSnapshot(context, input) {
19
19
  if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
20
20
  throw new AppError("snapshot_output_inside_runtime", "Bootstrap snapshot output must be outside DD_FLOW_HOME", 1, { output });
21
21
  fs.mkdirSync(output, { recursive: true });
22
- fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
22
+ fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
23
23
  const manifest = {
24
24
  schema_id: "dd-eval/bootstrap-snapshot@1",
25
25
  stage: "specify",
@@ -55,7 +55,8 @@ export function createEvalRunSnapshot(context, input) {
55
55
  const project = requireProjectByRoot(context, projectRoot);
56
56
  const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
57
57
  assertDedicatedHome(context, project.id, input.runId);
58
- if (input.candidate)
58
+ if (input.incomplete) { /* Forensic evidence is not an accepted or restorable boundary. */ }
59
+ else if (input.candidate)
59
60
  assertTerminalCandidate(status.index.stage_runs ?? []);
60
61
  else if (input.stageEntry)
61
62
  assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
@@ -65,7 +66,7 @@ export function createEvalRunSnapshot(context, input) {
65
66
  const activeChildren = input.candidate
66
67
  ? context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND status IN ('created', 'running', 'paused')", [project.id, input.runId])?.count ?? 0
67
68
  : context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? IS NULL OR COALESCE(result_schema, '') NOT LIKE ?)))", [project.id, input.runId, allowedCreatedResultSchema, allowedCreatedResultSchema ? `${allowedCreatedResultSchema}@%` : ""])?.count ?? 0;
68
- if (activeChildren > 0)
69
+ if (!input.incomplete && activeChildren > 0)
69
70
  throw new AppError("snapshot_not_quiescent", `${input.candidate ? "Candidate" : "Stage-entry"} snapshot requires no active child Work`, 1, { run_id: input.runId, active_children: activeChildren });
70
71
  const output = path.resolve(input.output);
71
72
  if (fs.existsSync(output))
@@ -75,7 +76,6 @@ export function createEvalRunSnapshot(context, input) {
75
76
  const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
76
77
  if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
77
78
  throw new AppError("snapshot_output_inside_project", "Snapshot output must be outside the project and RUN workspace trees", 1, { output });
78
- context.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
79
79
  fs.mkdirSync(output, { recursive: true });
80
80
  fs.cpSync(context.ddFlowHome, path.join(output, "runtime"), {
81
81
  recursive: true,
@@ -83,12 +83,17 @@ export function createEvalRunSnapshot(context, input) {
83
83
  // Managed checkouts are restored from their Git bundle and workspace
84
84
  // payload. Copying their .git indirection into runtime makes the snapshot
85
85
  // dependent on the source machine's worktree metadata.
86
- filter: (source) => !["engines", "checkouts"].includes(path.basename(source))
86
+ filter: (source) => snapshotPayloadAllowed(source) && !["engines", "checkouts"].includes(path.basename(source)) && ![context.db.path, `${context.db.path}-wal`, `${context.db.path}-shm`].includes(source)
87
87
  });
88
+ // VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
89
+ // pages. Copying the live database and its WAL separately is not atomic.
90
+ const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
91
+ fs.mkdirSync(path.dirname(databaseCopy), { recursive: true });
92
+ context.db.exec(`VACUUM INTO '${databaseCopy.replace(/'/g, "''")}'`);
88
93
  fs.cpSync(projectRoot, path.join(output, "project"), {
89
94
  recursive: true,
90
95
  verbatimSymlinks: true,
91
- filter: (source) => path.basename(source) !== ".git"
96
+ filter: snapshotPayloadAllowed
92
97
  });
93
98
  const projectGit = snapshotProjectGit(projectRoot, output);
94
99
  const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
@@ -98,8 +103,9 @@ export function createEvalRunSnapshot(context, input) {
98
103
  project_id: project.id,
99
104
  project_root: projectRoot,
100
105
  dd_flow_home: context.ddFlowHome,
101
- purpose: input.candidate ? "candidate" : "stage_entry",
102
- stage_entry: input.candidate ? null : input.stageEntry,
106
+ purpose: input.incomplete ? "incomplete" : input.candidate ? "candidate" : "stage_entry",
107
+ stage_entry: input.incomplete || input.candidate ? null : input.stageEntry,
108
+ ...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : {}),
103
109
  created_at: context.now(),
104
110
  runtime_sha256: treeHash(path.join(output, "runtime")),
105
111
  project_sha256: treeHash(path.join(output, "project")),
@@ -121,6 +127,8 @@ function createdWorkSchemaForStage(stage) {
121
127
  export function restoreEvalRunSnapshot(context, input) {
122
128
  const snapshot = path.resolve(input.snapshot);
123
129
  const manifest = readManifest(snapshot);
130
+ if (manifest.purpose === "incomplete")
131
+ throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
124
132
  if (manifest.purpose === "candidate")
125
133
  throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
126
134
  if (!manifest.stage_entry)
@@ -285,7 +293,7 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
285
293
  throw new AppError("snapshot_workspace_invalid", "Feature workspace has no readable HEAD", 1, { workspace_root: workspaceRoot });
286
294
  const workspace = path.join(output, "workspace");
287
295
  const bundle = path.join(output, "workspace.bundle");
288
- fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
296
+ fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
289
297
  runGit(projectRoot, ["bundle", "create", bundle, branch]);
290
298
  return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
291
299
  }
@@ -343,6 +351,11 @@ function runGit(root, args) {
343
351
  throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
344
352
  }
345
353
  function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
354
+ function snapshotPayloadAllowed(source) {
355
+ const name = path.basename(source);
356
+ return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
357
+ && name !== ".env" && !name.startsWith(".env.");
358
+ }
346
359
  function treeHash(root) {
347
360
  const hash = crypto.createHash("sha256");
348
361
  const visit = (dir, relative) => {
@@ -370,7 +383,7 @@ function readManifest(snapshot) {
370
383
  if (!fs.existsSync(file))
371
384
  throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
372
385
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
373
- if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose === "candidate" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
386
+ if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
374
387
  throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
375
388
  if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
376
389
  throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
@@ -309,7 +309,11 @@ export function handleCodexHook(context, input) {
309
309
  const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
310
310
  if (!project)
311
311
  return { ok: true, observed: false, reason: "unrelated_cwd" };
312
- const binding = rootProviderSessionId ? upsertSessionBindingFromPayload(context, project, rootProviderSessionId, payload) : undefined;
312
+ // Codex retains the root session_id for a thread-spawned child, while the
313
+ // payload transcript belongs to the current agent_id. Keep lineage separate
314
+ // from ownership: otherwise a child overwrites the root transcript binding.
315
+ const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
316
+ const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
313
317
  const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
314
318
  const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
315
319
  const observedSession = flowPayload
@@ -17,6 +17,7 @@ import { bindCurrentEngineToRun } from "./engines.js";
17
17
  import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
18
18
  import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
19
19
  import { publicSessionIdentity } from "./session-identity.js";
20
+ import { flowCommand } from "./stage-pause.js";
20
21
  const runSchemaId = "dd-flow/flow-run@3";
21
22
  const runtimeSchemaId = "dd-flow/flow-run@3";
22
23
  const runIdType = "RUN";
@@ -229,7 +230,33 @@ export function getFlowRunStatus(context, input) {
229
230
  const run = resolveRun(context, project.id, input.runId);
230
231
  const index = authoritativeIndex(run);
231
232
  const runtime = readRuntimeSnapshot(run.runtime_path);
232
- return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
233
+ return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
234
+ }
235
+ /** A read-only projection of the next legal lifecycle action.
236
+ * It is derived from the persisted RUN/Work facts so controllers never infer a
237
+ * transition from a model's prose or a hard-coded stage array. */
238
+ function runContinuation(context, run, index) {
239
+ if (["done", "failed", "cancelled"].includes(run.status))
240
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
241
+ const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
242
+ if (active) {
243
+ const runningWork = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' ORDER BY updated_at DESC LIMIT 1", [run.project_id, run.id]);
244
+ const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
245
+ return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
246
+ }
247
+ if (run.status === "waiting_for_user" || run.status === "paused")
248
+ return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
249
+ if (run.status === "blocked")
250
+ return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
251
+ if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
252
+ const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
253
+ const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
254
+ if (!next)
255
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
256
+ const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
257
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command };
258
+ }
259
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
233
260
  }
234
261
  export function getFlowRunConfig(context, input) {
235
262
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -470,7 +497,7 @@ export function attachFlowRunStage(context, input) {
470
497
  const existing = index.stage_runs.find((item) => item.stage === stage);
471
498
  assertVnextStageStart(run, index, stage, status, existing);
472
499
  if (status === "running" && existing) {
473
- archiveExistingStageAttempt(runArtifactRoot(run), dir);
500
+ archiveExistingStageAttempt(context, project.id, run.id, runArtifactRoot(run), dir);
474
501
  }
475
502
  const stageRun = {
476
503
  ...(existing ?? { order: index.stage_runs.length + 1 }),
@@ -763,6 +790,9 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
763
790
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
764
791
  const run = resolveRun(context, project.id, input.runId);
765
792
  const index = authoritativeIndex(run);
793
+ const priorRepair = index.variables?.["merge.source_repair"];
794
+ if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
795
+ return { run_id: run.id, cycle: priorRepair.cycle };
766
796
  const code = index.stage_runs.find((item) => item.stage === "code");
767
797
  const review = index.stage_runs.find((item) => item.stage === "code-review");
768
798
  const merge = index.stage_runs.find((item) => item.stage === "merge");
@@ -776,7 +806,7 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
776
806
  }
777
807
  const root = runArtifactRoot(run);
778
808
  for (const stage of ["code", "code-review", "merge"])
779
- archiveExistingStageAttempt(root, vnextStageDirectory(stage));
809
+ archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
780
810
  const now = context.now();
781
811
  const restarted = {
782
812
  ...code,
@@ -1391,24 +1421,55 @@ function resolveWorkspaceRoot(workspaceRoot) {
1391
1421
  }
1392
1422
  return fs.realpathSync(absolute);
1393
1423
  }
1394
- function archiveExistingStageAttempt(runRoot, stageDir) {
1424
+ /** Move one completed stage attempt and update only known durable path columns.
1425
+ * Receipt bytes remain immutable; readers resolve their new location through the DB. */
1426
+ export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1395
1427
  const currentStageDir = path.join(runRoot, stageDir);
1396
1428
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1397
1429
  return;
1398
1430
  }
1399
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1400
- if (entries.length === 0) {
1431
+ const journal = path.join(runRoot, `.archive-${stageDir}.json`);
1432
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
1433
+ const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1434
+ if (entries.length === 0 && !pending) {
1401
1435
  return;
1402
1436
  }
1403
1437
  let attempt = 1;
1404
1438
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1405
1439
  attempt += 1;
1406
1440
  }
1407
- const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1441
+ const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
1442
+ if (!pending) {
1443
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1444
+ const prior = stored ? JSON.parse(stored.index_json) : null;
1445
+ writeJsonFile(journal, { archiveDir, entries, stage_run: prior?.stage_runs.find((stage) => stage.dir === stageDir) ?? null });
1446
+ }
1408
1447
  ensureDir(archiveDir);
1409
1448
  for (const entry of entries) {
1410
- fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
1449
+ const source = path.join(currentStageDir, entry);
1450
+ const target = path.join(archiveDir, entry);
1451
+ if (fs.existsSync(source)) {
1452
+ if (fs.existsSync(target))
1453
+ throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
1454
+ fs.renameSync(source, target);
1455
+ }
1456
+ else if (!fs.existsSync(target))
1457
+ throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1411
1458
  }
1459
+ const oldPrefix = `${currentStageDir}${path.sep}`;
1460
+ const newPrefix = `${archiveDir}${path.sep}`;
1461
+ const rewrite = (table, column, where, values) => {
1462
+ context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
1463
+ };
1464
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1465
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1466
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1467
+ const receiptScope = "project_id = ? AND run_id = ?";
1468
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1469
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1470
+ // Retain the original stage facts with its immutable artifacts; current
1471
+ // stage_runs stays a projection of the current attempt, not a second ledger.
1472
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
1412
1473
  }
1413
1474
  function writeJsonFile(file, value) {
1414
1475
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -11,7 +11,7 @@ import { flowCommand } from "./stage-pause.js";
11
11
  import { assertStageStartHookEvent } from "./hooks.js";
12
12
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
13
13
  import { addVnextCodeRepair } from "./vnext-code.js";
14
- import { finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
14
+ import { finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
15
15
  import { addWorkBatch, bindStageCoordinatorWork, finishWork, refreshRunWorkProjection } from "./work-registry.js";
16
16
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
17
17
  import { writeStageReport } from "./stage-report-renderer.js";
@@ -172,9 +172,13 @@ export async function finishVnextCodeReview(context, input) {
172
172
  const outcome = decision.findings.some((item) => item.disposition === "defer") ? "accepted_with_DEF" : "accepted";
173
173
  const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
174
174
  const finalChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }));
175
- const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks });
175
+ if (input.retryCheckId) {
176
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
177
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
178
+ }
179
+ const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
176
180
  if (unchangedFailures.length)
177
- throw new AppError("code_review_gate_repair_required", "CODE-REVIEW final gate already failed for the unchanged workspace; repair the evidenced failure before retrying", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures });
181
+ throw new AppError("code_review_gate_repair_required", "CODE-REVIEW final gate already failed for the unchanged workspace; choose source repair or restore the environment and retry that receipt", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures, retry_command: `${finishCommand(context, run.id, projectRoot, decisionFile)} --retry-check ${unchangedFailures[0].id} --reason "<environment recovery evidence>"` });
178
182
  const receipts = await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, artifactDir: stageDir, scope: "aggregate", checks: finalChecks, ...(input.progress ? { progress: input.progress } : {}) });
179
183
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
180
184
  if (failed.length)
@@ -183,7 +187,8 @@ export async function finishVnextCodeReview(context, input) {
183
187
  outcome: "repair_required",
184
188
  retry_after_workspace_change: true,
185
189
  workspace_fingerprint: failed[0].workspace_fingerprint,
186
- repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`
190
+ repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
191
+ retry_command: `${finishCommand(context, run.id, projectRoot, decisionFile)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"`
187
192
  });
188
193
  const stopTarget = executionStopTarget(run);
189
194
  const nextAction = stopTarget === "merge_completed" ? "start_merge" : "code_review_completed";
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
- import { aggregateCheckDeclarations, checkReceipts, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
7
+ import { aggregateCheckDeclarations, checkReceipts, effectiveCheckDeclarations, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
9
  import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
10
10
  import { validateSchema } from "./schema-validation.js";
@@ -157,9 +157,13 @@ export async function finishVnextCode(context, input) {
157
157
  return { ok: true, run_id: run.id, stage, outcome: "repair_required", verification, repair, instruction: "The semantic verification is not accepted. Run the returned repair Work, then update code-verification.json and invoke this same stage finish command again." };
158
158
  }
159
159
  const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
160
- const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks });
160
+ if (input.retryCheckId) {
161
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
162
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
163
+ }
164
+ const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
161
165
  if (unchangedFailures.length)
162
- throw new AppError("code_gate_repair_required", "CODE final gate already failed for the unchanged workspace; repair the evidenced failure before retrying", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures });
166
+ throw new AppError("code_gate_repair_required", "CODE final gate already failed for the unchanged workspace; choose source repair or restore the environment and retry that receipt", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures, retry_command: `${finishCommand(context, run.id, projectRoot, input.verificationFile)} --retry-check ${unchangedFailures[0].id} --reason "<environment recovery evidence>"` });
163
167
  let receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks });
164
168
  if (!receipts) {
165
169
  const release = acquireAggregateGateLock(root);
@@ -187,7 +191,8 @@ export async function finishVnextCode(context, input) {
187
191
  outcome: "repair_required",
188
192
  retry_after_workspace_change: true,
189
193
  workspace_fingerprint: failed[0].workspace_fingerprint,
190
- repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`
194
+ repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
195
+ retry_command: `${finishCommand(context, run.id, projectRoot, input.verificationFile)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"`
191
196
  });
192
197
  }
193
198
  const allReceipts = checkReceipts(context, { projectId: project.id, runId: run.id });
@@ -284,6 +289,11 @@ export function addVnextCodeRepair(context, input) {
284
289
  const home = requireHome(run);
285
290
  if (!input.objective.trim())
286
291
  throw new AppError("validation", "Repair objective must not be empty", 2);
292
+ if (input.reviewCycle) {
293
+ const previous = codeWorks(context, project.id, run.id).find((work) => packet(work)?.review_cycle === input.reviewCycle);
294
+ if (previous)
295
+ return { ok: true, run_id: run.id, repair_work_id: previous.work_id, start_command: workStartCommand(context, previous), reused: true };
296
+ }
287
297
  if (!input.checkReceiptId && !input.reviewFindings?.length && !input.semanticUnresolved?.length) {
288
298
  throw new AppError("validation", "Repair needs failed check evidence, a CODE-REVIEW finding, or an unresolved CODE verification", 2);
289
299
  }
@@ -307,10 +317,10 @@ export function addVnextCodeRepair(context, input) {
307
317
  // repair that fixes one failed aggregate check can make a second, already
308
318
  // declared aggregate failure impossible to repair. The immutable source
309
319
  // of repair eligibility is the original CODE graph.
310
- const declaredChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
320
+ const declaredChecks = effectiveCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
311
321
  .map((work) => packet(work))
312
322
  .filter((value) => !value.repair)
313
- .flatMap((value) => value.checks));
323
+ .flatMap((value) => value.checks), ["work", "code", "readiness", "merge"]);
314
324
  const failedCheck = receipt ? declaredChecks.find((check) => check.id === receipt.declaration_id) : undefined;
315
325
  if (receipt && !failedCheck) {
316
326
  throw new AppError("repair_check_declaration_missing", "Repair receipt is not backed by an accepted CODE check declaration", 2, {
@@ -330,7 +340,11 @@ export function addVnextCodeRepair(context, input) {
330
340
  origin_work_ids: origins.map((work) => work.work_id),
331
341
  ...(receipt ? { check_receipt_id: receipt.id, failure_receipt_path: receipt.receipt_path } : {}),
332
342
  ...(input.reviewFindings?.length ? { review_findings: input.reviewFindings } : {}),
333
- ...(semanticRepair ? { semantic_unresolved: unique(input.semanticUnresolved ?? []), verification_path: input.verificationPath } : {})
343
+ ...(semanticRepair ? { semantic_unresolved: unique(input.semanticUnresolved ?? []), verification_path: input.verificationPath } : {}),
344
+ // `run_at` remains the accepted aggregate placement. This separate
345
+ // repair obligation is deliberately executed before this repair Work is
346
+ // accepted, so a local fix cannot close without re-proving its cause.
347
+ verification_check_refs: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}), ...(semanticRepair ? { semanticChecks: declaredChecks } : {}) })
334
348
  },
335
349
  task: input.objective,
336
350
  semantic_spine: {
@@ -384,11 +398,8 @@ export function addVnextCodeRepair(context, input) {
384
398
  refreshRunWorkProjection(context, project.id, run.id);
385
399
  return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
386
400
  }
387
- /** Retain the causal declaration for worker context; run_at keeps aggregate gates at stage scope. */
401
+ /** Retain the causal declaration for worker context; run_at still records its original gate. */
388
402
  export function selectRepairChecks(input) {
389
- // Gate placement belongs to the accepted PLAN. `runCodeChecks` executes only
390
- // work-scoped entries, while retaining the aggregate declaration tells the
391
- // worker exactly what will be rerun by the coordinator.
392
403
  const candidates = input.failedCheck ? [input.failedCheck] : input.reviewChecks ?? input.semanticChecks ?? [];
393
404
  const checks = candidates.map((check) => ({ ...check, purpose: `${input.semanticChecks ? "Prove the CODE verification repair" : "Prove the CODE-REVIEW repair"}: ${check.purpose}` }));
394
405
  return uniqueBy(checks, (check) => check.id);
@@ -3,8 +3,10 @@ import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
4
  import { resolveProjectRoot } from "../storage/paths.js";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
- import { getFlowRunVariables } from "./runs.js";
7
- import { listWorks } from "./work-registry.js";
6
+ import { getFlowRunVariables, appendFlowRunTimelineEvent } from "./runs.js";
7
+ import { listWorks, failWork, cancelWork } from "./work-registry.js";
8
+ import { storageSessionId } from "./session-identity.js";
9
+ import { reconcileUnfinishedChecks } from "./code-checks.js";
8
10
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
9
11
  export const subagentCapacityKey = "runtime.subagent.available_slots";
10
12
  export function fanoutState(input) {
@@ -66,7 +68,70 @@ export function getVnextFanoutStatus(context, input) {
66
68
  run_key: subagentCapacityKey,
67
69
  available_slots: capacityKnown ? available : null
68
70
  },
69
- works: { ...counts, ready: ready.works.map((work) => ({ work_id: work.work_id, task: work.task, start_command: work.start_command })) }
71
+ works: { ...counts, ready: ready.works.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: work.start_command })) }
70
72
  }
71
73
  };
72
74
  }
75
+ /** Explicit controller mutation, separate from read-only fanout status.
76
+ * A native child may be mapped only by its registered identity. Reused native
77
+ * Sessions need a provider execution identity: without one we report ambiguity
78
+ * instead of applying a late event to the newest Work attempt. */
79
+ export async function reconcileVnextFanout(context, input) {
80
+ const status = getVnextFanoutStatus(context, input);
81
+ if (!status.orchestration)
82
+ return { ok: true, issues: [], reconciled: [] };
83
+ const value = input.observations;
84
+ if (!value || typeof value.harness_id !== "string" || !value.harness_id || typeof value.parent_session_id !== "string" || !Array.isArray(value.children)
85
+ || value.children.some(child => !child || typeof child.session_id !== "string" || !["running", "unknown", "completed", "failed", "cancelled"].includes(String(child.status))))
86
+ throw new AppError("usage", "Expected native observations: harness_id, parent_session_id and children[{session_id,status}]", 2);
87
+ if (new Set(value.children.map(child => child.session_id)).size !== value.children.length)
88
+ throw new AppError("usage", "Native observations must contain each child Session exactly once", 2);
89
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
90
+ const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
91
+ const issues = [], reconciled = [];
92
+ for (const child of value.children) {
93
+ if (child.status === "running" || child.status === "unknown")
94
+ continue;
95
+ const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
96
+ const links = context.db.all(`SELECT ws.id, ws.work_id, ws.status, w.status AS work_status, w.parent_work_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? AND s.parent_session_id = ? ORDER BY ws.created_at`, [project.id, input.runId, sessionId, parent]);
97
+ const active = links.filter(link => link.status === "running" && link.work_status === "running" && link.parent_work_id === status.orchestration.parent_work_id);
98
+ if (!active.length) {
99
+ if (!links.length)
100
+ issues.push({ code: "child_launch_association_missing", session_id: child.session_id, outcome: child.status });
101
+ continue;
102
+ }
103
+ if (links.length !== 1 || active.length !== 1) {
104
+ issues.push({ code: "child_execution_ambiguous", session_id: child.session_id, work_ids: active.map(link => link.work_id) });
105
+ continue;
106
+ }
107
+ const link = active[0];
108
+ const pendingCheck = () => context.db.get("SELECT 1 FROM check_receipts WHERE project_id = ? AND run_id = ? AND work_id = ? AND status = 'running' LIMIT 1", [project.id, input.runId, link.work_id]);
109
+ if (pendingCheck()) {
110
+ const run = context.db.get("SELECT workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
111
+ try {
112
+ reconcileUnfinishedChecks(context, { projectId: project.id, runId: input.runId, workspaceRoot: run.workspace_root, runHome: run.run_root, workId: link.work_id });
113
+ }
114
+ catch (error) {
115
+ if (!(error instanceof AppError) || error.code !== "check_in_progress")
116
+ throw error;
117
+ }
118
+ if (pendingCheck()) {
119
+ issues.push({ code: "child_check_in_progress", work_id: link.work_id });
120
+ continue;
121
+ }
122
+ }
123
+ if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [link.work_id])) {
124
+ issues.push({ code: "child_descendants_unsettled", work_id: link.work_id });
125
+ continue;
126
+ }
127
+ if (child.status === "completed") {
128
+ issues.push({ code: "execution_ended_without_work_result", work_id: link.work_id, session_id: child.session_id });
129
+ continue;
130
+ }
131
+ const reason = JSON.stringify({ source: "native_child_observation", harness_id: value.harness_id, session_id: child.session_id, work_session_id: link.id, outcome: child.status });
132
+ const result = await (child.status === "failed" ? failWork : cancelWork)(context, link.work_id, reason);
133
+ reconciled.push({ work_id: link.work_id, work_session_id: link.id, outcome: child.status, result });
134
+ }
135
+ appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_reconciliation", stage: input.stage, observations: value, issues, reconciled });
136
+ return { ok: true, issues, reconciled };
137
+ }
@@ -15,7 +15,7 @@ import { validateSchema } from "./schema-validation.js";
15
15
  import { writeStageReport } from "./stage-report-renderer.js";
16
16
  import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
17
17
  import { addVnextCodeRepair } from "./vnext-code.js";
18
- import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
18
+ import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
19
19
  import { applyExternalStageContext } from "./stage-context.js";
20
20
  import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
21
21
  const stage = "merge";
@@ -226,14 +226,18 @@ export async function finishVnextMerge(context, input) {
226
226
  request = requireRequest(context, request.merge_request_id);
227
227
  }
228
228
  const checks = effectiveMergeChecks(run, request);
229
- const unchanged = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: request.target_workspace, declarations: checks });
229
+ if (input.retryCheckId) {
230
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
231
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
232
+ }
233
+ const unchanged = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: request.target_workspace, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
230
234
  if (unchanged.length)
231
- throw new AppError("merge_check_repair_required", "MERGE checks already failed for the unchanged integrated tree", 2, { failures: unchanged });
235
+ throw new AppError("merge_check_repair_required", "MERGE checks already failed for the unchanged integrated tree; choose source repair or restore the environment and retry that receipt", 2, { failures: unchanged, retry_command: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${unchanged[0].id} --reason "<environment recovery evidence>"` });
232
236
  const receipts = request.checkpoint === "checks_passed" || request.checkpoint === "delivered" || request.checkpoint === "finalized" ? checkReceipts(context, { projectId: project.id, runId: run.id, workId: request.executor_work_id }).filter((receipt) => receipt.status === "passed" && receipt.before_fingerprint === workspaceFingerprint(request.target_workspace)) : await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: requireHome(run), workspaceRoot: request.target_workspace, workId: request.executor_work_id, artifactDir: path.join(stageDir, "works", request.executor_work_id), scope: "aggregate", checks, ...(input.progress ? { progress: input.progress } : {}) });
233
237
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
234
238
  if (failed.length) {
235
239
  context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
236
- throw new AppError("merge_source_repair_required", "Integrated target checks failed. Do not edit the integration workspace: run the returned source-repair command.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json` });
240
+ throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"` });
237
241
  }
238
242
  const requiredRefs = mergeAcceptanceRefs(run.workspace_root, JSON.parse(request.protocol_ids_json));
239
243
  const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
@@ -310,6 +314,11 @@ export async function repairVnextMerge(context, input) {
310
314
  const projectRoot = resolveProjectRoot(input.projectRoot);
311
315
  const project = requireProjectByRoot(context, projectRoot);
312
316
  const request = requireRequest(context, input.requestId);
317
+ if (request.project_id === project.id && request.status === "superseded" && errorCode(request) === "merge_source_repair") {
318
+ const saved = JSON.parse(request.last_error_json);
319
+ releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
320
+ return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: { repair_work_id: saved.repair_work_id }, next: { kind: "start_stage", stage: "code", command: `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } };
321
+ }
313
322
  if (request.project_id !== project.id || request.status !== "action_required" || errorCode(request) !== "merge_gate_failed") {
314
323
  throw new AppError("invalid_merge_repair_state", "MERGE source repair requires an action-required failed integration gate", 2, { merge_request_id: input.requestId, status: request.status, code: errorCode(request) });
315
324
  }
@@ -322,16 +331,18 @@ export async function repairVnextMerge(context, input) {
322
331
  throw new AppError("runtime_missing", "MERGE failure has no durable failed receipt", 1, { merge_request_id: request.merge_request_id });
323
332
  // Settle the child while its canonical result path still exists; the next
324
333
  // step archives the entire MERGE attempt as immutable evidence.
325
- await failWork(context, request.executor_work_id, `Superseded by source repair after failed integration gate ${failed[0]}.`);
334
+ const executor = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
335
+ if (executor?.status !== "failed")
336
+ await failWork(context, request.executor_work_id, `Superseded by source repair after failed integration gate ${failed[0]}.`);
326
337
  const prepared = prepareVnextMergeSourceRepairAttempt(context, { projectRoot, runId: request.run_id, mergeRequestId: request.merge_request_id });
327
- const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
338
+ const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, reviewCycle: `MRR-${request.merge_request_id}`, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
328
339
  const now = context.now();
329
340
  context.db.run("UPDATE merge_requests SET status = 'superseded', checkpoint = 'source_repair_required', last_error_json = ?, completed_at = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_source_repair", replacement_pending: true, failed_receipt_ids: failed, repair_work_id: repair.repair_work_id }), now, now, request.merge_request_id]);
330
341
  releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
331
342
  appendFlowRunTimelineEvent(context, project.id, request.run_id, { type: "merge_source_repair_created", merge_request_id: request.merge_request_id, repair_work_id: repair.repair_work_id, cycle: prepared.cycle, failed_receipt_ids: failed });
332
343
  return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: repair, next: { kind: "start_stage", stage: "code", command: `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } };
333
344
  }
334
- function mergePrompt(context, input) { const pause = `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`; const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(executionSettings(input.run).merge_delivery)}`, `- cleanup: ${JSON.stringify(executionSettings(input.run).merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...effectiveMergeChecks(input.run, input.request).map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, inspect only the returned receipt/logs, then run this exact source-repair command: ${repair}. It restores the integration target to baseline and creates a CODE → independent CODE-REVIEW → replacement MRG cycle. Do not repair the integration target and do not retry this MRG.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
345
+ function mergePrompt(context, input) { const pause = `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`; const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(executionSettings(input.run).merge_delivery)}`, `- cleanup: ${JSON.stringify(executionSettings(input.run).merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...effectiveMergeChecks(input.run, input.request).map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
335
346
  function mergeReport(context, run, request, semantic, receipts) { const now = context.now(); const cleanup = cleanupReceiptPath(run); return { schema_id: "dd-flow/stage-report@2", run_id: run.id, stage, generated_at: now, verdict: "done", summary: semantic.summary, semantic: { result: semantic.summary, acceptance: ["source_commit_frozen", "integration_commit_created", "merge_gate_passed", "delivery_confirmed"], changed_files: [], checks: receipts.map((item) => item.command), evidence: [applyReceiptPath(context, request), ...receipts.map((item) => item.receipt_path), ...(fs.existsSync(cleanup) ? [cleanup] : [])], next_action: "merge_completed", merge: { merge_request_id: request.merge_request_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source_commit: request.source_commit, execution_target_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit, route: request.execution_route, delivery: executionSettings(run).merge_delivery, cleanup: executionSettings(run).merge_cleanup, verification_summary: semantic.verification_summary, residual_risks: semantic.residual_risks } }, mechanical: { started_at: request.lock_acquired_at, finished_at: now, git: gitFacts(request.target_workspace), queue: queueStatus(context, request) }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } }; }
336
347
  function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
337
348
  function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
@@ -396,7 +407,12 @@ function releaseMergeLane(context, projectRoot, request, reason) {
396
407
  try {
397
408
  releaseLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, reason });
398
409
  }
399
- catch { /* An expired lease cannot invalidate an already completed MERGE. */ }
410
+ catch (error) {
411
+ // An absent/replaced lease is already no longer ours. Storage and other
412
+ // failures must remain visible so a repeated operation can release it.
413
+ if (!(error instanceof AppError) || !["lane_lock_not_active", "lane_lock_owned_by_other_worker"].includes(error.code))
414
+ throw error;
415
+ }
400
416
  }
401
417
  function queueAhead(context, request) { return context.db.get("SELECT COUNT(*) AS count FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?))", [request.project_id, request.created_at, request.created_at, request.merge_request_id])?.count ?? 0; }
402
418
  function queueStatus(context, request) { return { position: queueAhead(context, request) + 1, requests_ahead: queueAhead(context, request), status: request.status, route: request.execution_route }; }
@@ -340,7 +340,10 @@ async function settle(context, id, status, result, progress) {
340
340
  readCodeCheckProfile(run.workspace_root);
341
341
  coordinationDrift = plannedAreaDrift(packet, result);
342
342
  const artifactDir = path.relative(requireRunHome(run), path.dirname(link.result_path));
343
- receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: packet.checks.filter((check) => check.run_at === "work"), ...(progress ? { progress } : {}) });
343
+ const requiredChecks = packet.repair?.verification_check_refs?.length
344
+ ? uniqueChecks([...packet.checks.filter((check) => check.run_at === "work"), ...packet.repair.verification_check_refs])
345
+ : packet.checks.filter((check) => check.run_at === "work");
346
+ receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: requiredChecks, ...(progress ? { progress } : {}) });
344
347
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
345
348
  if (failed.length)
346
349
  throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
@@ -365,7 +368,7 @@ async function settle(context, id, status, result, progress) {
365
368
  throw error;
366
369
  }
367
370
  refreshRunWorkProjection(context, work.project_id, work.run_id);
368
- const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
371
+ const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
369
372
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
370
373
  for (const ready of newlyReady)
371
374
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
@@ -496,7 +499,7 @@ function renderWorkerPrompt(context, work, run, dependencies) {
496
499
  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>", "");
497
500
  if (packet)
498
501
  codeContext.push("<temporary_services>", "Prefer the declared check launcher: it already owns check resources. If the planned scenario genuinely requires an interactive HTTP service, use the managed supervisor below. This is a template: replace the project service command, port names and readiness path from the plan/project instructions; do not invent a fixed port.", `${command} runtime process start --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --command '<project-service-command>' --ports api --ready-port api --ready-path /health --json --progress-jsonl`, "The service receives DD_FLOW_PORT_API (and equivalent variables for all declared names). The command stays running as its supervisor. Retain its tool handle; wait for the service ready event and read its service.json receipt. Pass those exact ports and the same project environment to reset/seed, API and browser operations.", "A ready receipt proves service readiness only. Record the scenario outcome and real evidence separately. After the scenario, execute the exact stop_command from that receipt, then wait for the supervisor to exit. Never use pkill/killall or stop a sibling's process. If cleanup fails, retain the process id and report the failure; do not claim the resource is free.", "</temporary_services>", "");
499
- 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>", ...writeBoundary, "</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>", "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "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");
502
+ 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>", ...writeBoundary, "</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>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "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");
500
503
  }
501
504
  export function resultSchemaGuidance(work, runId) {
502
505
  const schema = work.result_schema;
@@ -658,7 +661,7 @@ export function refreshRunWorkProjection(context, projectId, runId) { refreshRun
658
661
  if (fs.existsSync(obsolete))
659
662
  fs.rmSync(obsolete);
660
663
  } }
661
- export function codeWorkGraph(context, projectId, runId) { const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
664
+ export function codeWorkGraph(context, projectId, runId) { const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
662
665
  function parsePayload(work) { if (!work.payload_json)
663
666
  return null; try {
664
667
  const value = JSON.parse(work.payload_json);
@@ -667,6 +670,7 @@ function parsePayload(work) { if (!work.payload_json)
667
670
  catch {
668
671
  return null;
669
672
  } }
673
+ function uniqueChecks(checks) { return checks.filter((check, index) => checks.findIndex((candidate) => candidate.id === check.id) === index); }
670
674
  function codePacket(work) { const value = parsePayload(work); if (value?.schema_id !== "dd-flow/code-work-packet@5")
671
675
  return null; return value; }
672
676
  function assertNoCycles(nodes) { const local = new Map(nodes.map((node) => [node.id, node.dependencies.filter((dependency) => nodes.some((candidate) => candidate.id === dependency))])); const active = new Set(); const done = new Set(); const visit = (id) => { if (active.has(id))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.15",
3
+ "version": "0.9.0-beta.17",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {