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

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,13 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.16
4
+
5
+ ### Patch Changes
6
+
7
+ - Make repair verification causal and retryable, preserve attempt-qualified
8
+ evidence, expose RUN continuation from persisted lifecycle state, and keep
9
+ native child ownership bound to the physical harness session.
10
+
3
11
  ## 0.9.0-beta.15
4
12
 
5
13
  ### 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.16",
4
+ "cli_commit": "8de503c90a952a8b56300a0c0367dd89be5c2436",
5
+ "built_at": "2026-09-05T22:05:40.605Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.0.4",
8
- "commit": "c8cae271f844fd3b3c7492f164e13a60eaba4839",
8
+ "commit": "8e572d151c7f9d22c2b60122c87d9febcbfbde69",
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",
@@ -878,6 +878,10 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
878
878
  const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
879
879
  const resultFile = optionalOption(parsed, "result-file") ?? semanticFile;
880
880
  const resultStdin = hasOption(parsed, "result-stdin");
881
+ const retryCheckId = optionalOption(parsed, "retry-check");
882
+ const retryReason = optionalOption(parsed, "reason");
883
+ if (retryCheckId && !retryReason?.trim())
884
+ throw new AppError("usage", "--retry-check requires --reason explaining the recovered environment", 2);
881
885
  if (stage === "specify" && isVnextSpecifyRun(context, { projectRoot, runId })) {
882
886
  const outcome = optionalOption(parsed, "outcome");
883
887
  if (!outcome || !["specified", "failed", "cancelled"].includes(outcome)) {
@@ -917,17 +921,17 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
917
921
  if (semanticFile || resultFile || resultStdin || hasOption(parsed, "outcome")) {
918
922
  throw new AppError("usage", "vNext CODE finish accepts only --verification-file", 2);
919
923
  }
920
- return await finishVnextCode(context, { projectRoot, runId, verificationFile: optionalOption(parsed, "verification-file") ?? path.join("<missing>", "code-verification.json"), progress: commandProgress(io, output) });
924
+ 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
925
  }
922
926
  if (stage === "code-review" && isVnextCodeReviewRun(context, { projectRoot, runId })) {
923
927
  if (resultStdin || semanticFile || resultFile || hasOption(parsed, "outcome"))
924
928
  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) });
929
+ return await finishVnextCodeReview(context, { projectRoot, runId, ...(optionalOption(parsed, "decision-file") ? { decisionFile: optionalOption(parsed, "decision-file") } : {}), ...(retryCheckId ? { retryCheckId, retryReason: retryReason } : {}), progress: commandProgress(io, output) });
926
930
  }
927
931
  if (stage === "merge" && isVnextMergeRun(context, { projectRoot, runId })) {
928
932
  if (resultStdin || semanticFile || resultFile || hasOption(parsed, "outcome"))
929
933
  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) });
934
+ return await finishVnextMerge(context, { projectRoot, runId, requestId: requiredOption(parsed, "request"), workId: requiredOption(parsed, "work"), ...(retryCheckId ? { retryCheckId, retryReason: retryReason } : {}), progress: commandProgress(io, output) });
931
935
  }
932
936
  if (hasOption(parsed, "outcome") || hasOption(parsed, "result-file") || resultStdin) {
933
937
  throw new AppError("usage", "--outcome, --result-file and --result-stdin are only available for vNext SPECIFY", 2);
@@ -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,7 +222,17 @@ 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
+ const receipt = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).find((item) => item.id === input.receiptId);
229
+ if (!receipt || receipt.scope !== "aggregate" || !["failed", "aborted"].includes(receipt.status))
230
+ 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 });
231
+ const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
232
+ if (!receipt.check_refs.some((ref) => declared.has(ref)))
233
+ 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 });
234
+ return receipt;
235
+ }
226
236
  export function checkReceipts(context, input) {
227
237
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
228
238
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
@@ -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
@@ -229,7 +229,33 @@ export function getFlowRunStatus(context, input) {
229
229
  const run = resolveRun(context, project.id, input.runId);
230
230
  const index = authoritativeIndex(run);
231
231
  const runtime = readRuntimeSnapshot(run.runtime_path);
232
- return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
232
+ return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
233
+ }
234
+ /** A read-only projection of the next legal lifecycle action.
235
+ * It is derived from the persisted RUN/Work facts so controllers never infer a
236
+ * transition from a model's prose or a hard-coded stage array. */
237
+ function runContinuation(context, run, index) {
238
+ if (["done", "failed", "cancelled"].includes(run.status))
239
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
240
+ const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
241
+ if (active) {
242
+ 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]);
243
+ const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
244
+ return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
245
+ }
246
+ if (run.status === "waiting_for_user" || run.status === "paused")
247
+ return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
248
+ if (run.status === "blocked")
249
+ return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
250
+ if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
251
+ const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
252
+ const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
253
+ if (!next)
254
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
255
+ const command = `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
256
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command };
257
+ }
258
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
233
259
  }
234
260
  export function getFlowRunConfig(context, input) {
235
261
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -470,7 +496,7 @@ export function attachFlowRunStage(context, input) {
470
496
  const existing = index.stage_runs.find((item) => item.stage === stage);
471
497
  assertVnextStageStart(run, index, stage, status, existing);
472
498
  if (status === "running" && existing) {
473
- archiveExistingStageAttempt(runArtifactRoot(run), dir);
499
+ archiveExistingStageAttempt(context, project.id, run.id, runArtifactRoot(run), dir);
474
500
  }
475
501
  const stageRun = {
476
502
  ...(existing ?? { order: index.stage_runs.length + 1 }),
@@ -776,7 +802,7 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
776
802
  }
777
803
  const root = runArtifactRoot(run);
778
804
  for (const stage of ["code", "code-review", "merge"])
779
- archiveExistingStageAttempt(root, vnextStageDirectory(stage));
805
+ archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
780
806
  const now = context.now();
781
807
  const restarted = {
782
808
  ...code,
@@ -1391,7 +1417,9 @@ function resolveWorkspaceRoot(workspaceRoot) {
1391
1417
  }
1392
1418
  return fs.realpathSync(absolute);
1393
1419
  }
1394
- function archiveExistingStageAttempt(runRoot, stageDir) {
1420
+ /** Move one completed stage attempt and update only known durable path columns.
1421
+ * Receipt bytes remain immutable; readers resolve their new location through the DB. */
1422
+ function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1395
1423
  const currentStageDir = path.join(runRoot, stageDir);
1396
1424
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1397
1425
  return;
@@ -1409,6 +1437,17 @@ function archiveExistingStageAttempt(runRoot, stageDir) {
1409
1437
  for (const entry of entries) {
1410
1438
  fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
1411
1439
  }
1440
+ const oldPrefix = `${currentStageDir}${path.sep}`;
1441
+ const newPrefix = `${archiveDir}${path.sep}`;
1442
+ const rewrite = (table, column, where, values) => {
1443
+ context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, ...values]);
1444
+ };
1445
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
1446
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
1447
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
1448
+ const receiptScope = "project_id = ? AND run_id = ?";
1449
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
1450
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
1412
1451
  }
1413
1452
  function writeJsonFile(file, value) {
1414
1453
  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 });
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, 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 });
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 });
@@ -330,7 +335,11 @@ export function addVnextCodeRepair(context, input) {
330
335
  origin_work_ids: origins.map((work) => work.work_id),
331
336
  ...(receipt ? { check_receipt_id: receipt.id, failure_receipt_path: receipt.receipt_path } : {}),
332
337
  ...(input.reviewFindings?.length ? { review_findings: input.reviewFindings } : {}),
333
- ...(semanticRepair ? { semantic_unresolved: unique(input.semanticUnresolved ?? []), verification_path: input.verificationPath } : {})
338
+ ...(semanticRepair ? { semantic_unresolved: unique(input.semanticUnresolved ?? []), verification_path: input.verificationPath } : {}),
339
+ // `run_at` remains the accepted aggregate placement. This separate
340
+ // repair obligation is deliberately executed before this repair Work is
341
+ // accepted, so a local fix cannot close without re-proving its cause.
342
+ verification_check_refs: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}), ...(semanticRepair ? { semanticChecks: declaredChecks } : {}) })
334
343
  },
335
344
  task: input.objective,
336
345
  semantic_spine: {
@@ -384,11 +393,8 @@ export function addVnextCodeRepair(context, input) {
384
393
  refreshRunWorkProjection(context, project.id, run.id);
385
394
  return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
386
395
  }
387
- /** Retain the causal declaration for worker context; run_at keeps aggregate gates at stage scope. */
396
+ /** Retain the causal declaration for worker context; run_at still records its original gate. */
388
397
  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
398
  const candidates = input.failedCheck ? [input.failedCheck] : input.reviewChecks ?? input.semanticChecks ?? [];
393
399
  const checks = candidates.map((check) => ({ ...check, purpose: `${input.semanticChecks ? "Prove the CODE verification repair" : "Prove the CODE-REVIEW repair"}: ${check.purpose}` }));
394
400
  return uniqueBy(checks, (check) => check.id);
@@ -66,7 +66,7 @@ export function getVnextFanoutStatus(context, input) {
66
66
  run_key: subagentCapacityKey,
67
67
  available_slots: capacityKnown ? available : null
68
68
  },
69
- works: { ...counts, ready: ready.works.map((work) => ({ work_id: work.work_id, task: work.task, start_command: work.start_command })) }
69
+ 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
70
  }
71
71
  };
72
72
  }
@@ -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 });
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));
@@ -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.16",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {