@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.13

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,21 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.13
4
+
5
+ ### Patch Changes
6
+
7
+ - Make long-running CODE checks suspend-safe and prevent an expired lease from
8
+ stealing a live process. Publish `dd-flow/code-work-result@3`, which unifies
9
+ repair resolutions with evidence references assigned by the Work packet.
10
+
11
+ ## 0.9.0-beta.12
12
+
13
+ ### Patch Changes
14
+
15
+ - Return a failed integration gate to an explicit source CODE and independent
16
+ CODE-REVIEW repair cycle, instead of allowing product repairs in the
17
+ integration workspace.
18
+
3
19
  ## 0.9.0-beta.11
4
20
 
5
21
  ### Patch Changes
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.11",
4
- "cli_commit": "67fea9fdb3fbcc04cc0a45de9afc5f7120c13a08",
5
- "built_at": "2026-09-04T20:59:42.597Z",
3
+ "cli_version": "0.9.0-beta.13",
4
+ "cli_commit": "020ada108a59291f4462f7d52d3ea64bccd77b56",
5
+ "built_at": "2026-09-05T08:17:23.882Z",
6
6
  "built_with_canon": {
7
- "version": "4.0.2",
8
- "commit": "fccdb9fe7359f2ba321eebace328fd18557dcd25",
9
- "flow_contract": "dd-flow-canonical-2026-08",
10
- "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
- "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
12
- "flow_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank/dd-flow",
13
- "layout": "dot_memory_bank"
7
+ "version": null,
8
+ "commit": null,
9
+ "flow_contract": null,
10
+ "repo_root": null,
11
+ "memorybank_root": null,
12
+ "flow_root": null,
13
+ "layout": null
14
14
  }
15
15
  }
package/dist/cli/help.js CHANGED
@@ -369,11 +369,12 @@ Usage:
369
369
  dd-flow merge request status <MRG-ID> --project-root <root> --json
370
370
  dd-flow merge request route <MRG-ID> --mode same_session|server --reason <text> --project-root <root> --json
371
371
  dd-flow merge apply <MRG-ID> --work <WRK-ID> --project-root <root> --json --progress-jsonl
372
+ dd-flow merge repair <MRG-ID> --project-root <root> --json
372
373
  dd-flow merge serve --agent-profile <id> [--once] [--poll-seconds <n>] [--max-parallel-projects <n>] --progress-jsonl
373
374
  dd-flow merge server status --json
374
375
  dd-flow merge server stop <MSV-ID> --json
375
376
 
376
- CODE or CODE-REVIEW creates one MRG request and child MERGE Work. stage start waits in the shared project FIFO, reports progress, locks the execution target baseline and returns the exact apply/finish commands. merge apply is the only supported initial Git mutation. merge serve is a deterministic dispatcher: it launches a fresh Session through the configured harness adapter but does not perform agent work itself. It preserves FIFO inside each project and may serve different projects concurrently up to the explicit bound.`
377
+ CODE or CODE-REVIEW creates one MRG request and child MERGE Work. stage start waits in the shared project FIFO, reports progress, locks the execution target baseline and returns the exact apply/finish commands. merge apply is the only supported initial Git mutation. If its integration gate fails, merge repair restores the target baseline and creates a source CODE → independent CODE-REVIEW → replacement-MRG cycle; it never patches product code in the integration workspace. merge serve is a deterministic dispatcher: it launches a fresh Session through the configured harness adapter but does not perform agent work itself. It preserves FIFO inside each project and may serve different projects concurrently up to the explicit bound.`
377
378
  ],
378
379
  [
379
380
  "cleanup",
@@ -39,7 +39,7 @@ import { finishVnextPlan, isVnextPlanRun, startVnextPlan } from "../services/vne
39
39
  import { dispatchVnextPlanReview, finishVnextPlanReview, isVnextPlanReviewRun, recordVnextPlanReviewCapacity, startVnextPlanReview } from "../services/vnext-plan-review.js";
40
40
  import { addVnextCodeRepair, finishVnextCode, startVnextCode } from "../services/vnext-code.js";
41
41
  import { addVnextCodeReviewRepair, finishVnextCodeReview, isVnextCodeReviewRun, startVnextCodeReview } from "../services/vnext-code-review.js";
42
- import { applyVnextMerge, finishVnextMerge, getVnextMergeRequest, isVnextMergeRun, routeVnextMergeRequest, startVnextMerge } from "../services/vnext-merge.js";
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
44
  import { getVnextFanoutStatus } from "../services/vnext-fanout.js";
45
45
  import { pauseStageForUser, resumeStageAfterUser } from "../services/stage-pause.js";
@@ -1247,6 +1247,8 @@ async function dispatchMerge(context, command, parsed, progress) {
1247
1247
  }
1248
1248
  if (command === "apply")
1249
1249
  return applyVnextMerge(context, { projectRoot: requiredOption(parsed, "project-root"), requestId: requiredPosition(parsed, 0, "merge-request-id"), workId: requiredOption(parsed, "work"), ...(progress ? { progress } : {}) });
1250
+ if (command === "repair")
1251
+ return await repairVnextMerge(context, { projectRoot: requiredOption(parsed, "project-root"), requestId: requiredPosition(parsed, 0, "merge-request-id") });
1250
1252
  if (command === "request") {
1251
1253
  const action = requiredPosition(parsed, 0, "request action");
1252
1254
  const requestId = requiredPosition(parsed, 1, "merge-request-id");
@@ -2324,7 +2326,7 @@ function projectRootForMutation(context, args, result, resolvedScope) {
2324
2326
  if (family === "merge-queue") {
2325
2327
  return projectRootForMergeQueueMutation(context, command, parsed);
2326
2328
  }
2327
- if (family === "merge" && ["one-shot", "apply", "request"].includes(command ?? "")) {
2329
+ if (family === "merge" && ["one-shot", "apply", "repair", "request"].includes(command ?? "")) {
2328
2330
  return requiredOption(parsed, "project-root");
2329
2331
  }
2330
2332
  if (family === "merge-worker" && ["start", "stop"].includes(command ?? "")) {
@@ -20,6 +20,6 @@
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
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}}},
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@2"}}}
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
  }
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "dd-flow/code-work-result@2",
3
+ "$id": "dd-flow/code-work-result@3",
4
4
  "type": "object",
5
5
  "additionalProperties": false,
6
6
  "required": ["schema_id", "summary", "changed_paths", "evidence", "deviations", "blockers"],
7
7
  "properties": {
8
- "schema_id": {"const": "dd-flow/code-work-result@2"},
8
+ "schema_id": {"const": "dd-flow/code-work-result@3"},
9
9
  "summary": {"type": "string", "minLength": 1},
10
10
  "changed_paths": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
11
- "evidence": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "refs"], "properties": {"criterion_id": {"type": "string", "minLength": 1}, "refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}},
11
+ "evidence": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["obligation_ref", "refs"], "properties": {"obligation_ref": {"type": "string", "minLength": 1}, "refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}},
12
12
  "deviations": {"type": "array", "items": {"type": "string", "minLength": 1}},
13
13
  "blockers": {"type": "array", "items": {"type": "string", "minLength": 1}},
14
14
  "resolutions": {"type": "array", "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "summary", "evidence_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "evidence_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}}
@@ -155,7 +155,10 @@ export function reconcileUnfinishedChecks(context, input) {
155
155
  const process = processes.filter((item) => item.check_id === pendingReceipt.id).at(-1);
156
156
  const receipt = readReceipt(pendingReceipt.receipt_path);
157
157
  const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
158
- if (process && ["starting", "running", "stopping"].includes(process.state) && processLeaseIsCurrent(process) && (process.state === "starting" || processIsAlive(process))) {
158
+ // A stale lease after host sleep is not permission to duplicate a living
159
+ // check. Its durable completion marker or explicit reconciliation decides
160
+ // the outcome before another invocation may start.
161
+ if (process && ["starting", "running", "stopping"].includes(process.state) && (process.state === "starting" || processIsAlive(process))) {
159
162
  throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
160
163
  }
161
164
  if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
@@ -212,7 +215,6 @@ function readCompletion(file) { try {
212
215
  catch {
213
216
  return null;
214
217
  } }
215
- function processLeaseIsCurrent(process) { return Date.parse(process.lease_expires_at ?? "") >= Date.now(); }
216
218
  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))))
217
219
  for (const ref of receipt.check_refs)
218
220
  latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
@@ -277,24 +279,14 @@ catch (error) {
277
279
  terminateProcessGroup(child.pid, "SIGTERM");
278
280
  resolve({ exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true });
279
281
  return;
280
- } const inactivityMs = 15 * 60 * 1000; let timeout; const lastOutputSize = new Map(); const armInactivityTimeout = () => { if (timeout)
281
- clearTimeout(timeout); timeout = setTimeout(() => { timedOut = true; terminateProcessGroup(child.pid, "SIGTERM"); escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000); }, inactivityMs); }; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
282
+ } let leaseLost = false; let error = null; let escalation; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
282
283
  error = "managed process lease lost";
283
- timedOut = true;
284
+ leaseLost = true;
284
285
  terminateProcessGroup(child.pid, "SIGTERM");
286
+ escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
285
287
  return;
286
- } for (const file of [stdout, stderr]) {
287
- try {
288
- const size = fs.fstatSync(file).size, previous = lastOutputSize.get(file) ?? 0;
289
- if (size > previous) {
290
- lastOutputSize.set(file, size);
291
- armInactivityTimeout();
292
- }
293
- }
294
- catch { /* terminal stream */ }
295
- } heartbeat(elapsed); }, 15_000); let timedOut = false; let error = null; let escalation; armInactivityTimeout(); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (timeout)
296
- clearTimeout(timeout); if (escalation)
297
- clearTimeout(escalation); resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds without process output" : signal ? `terminated by ${signal}` : null), aborted: timedOut || Boolean(signal) || Boolean(error) }); }); }); }
288
+ } heartbeat(elapsed); }, 15_000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (escalation)
289
+ clearTimeout(escalation); resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: leaseLost || Boolean(signal) || Boolean(error) }); }); }); }
298
290
  function checkShellScript() {
299
291
  return [
300
292
  'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
@@ -54,6 +54,8 @@ export function processIsAlive(record) {
54
54
  return !record.pid_started_at || record.pid_started_at === processStartedAt(record.pid);
55
55
  }
56
56
  function processTreeIsAlive(record) {
57
+ if (processIsAlive(record))
58
+ return true;
57
59
  const group = parseMetadata(record.metadata_json).process_group_id;
58
60
  if (process.platform !== "win32" && typeof group === "number") {
59
61
  try {
@@ -64,9 +66,13 @@ function processTreeIsAlive(record) {
64
66
  return false;
65
67
  }
66
68
  }
67
- return processIsAlive(record);
69
+ return false;
68
70
  }
69
- /** Claims only expired records. Callers must still verify `processIsAlive` before stopping a PID. */
71
+ /**
72
+ * Expiry proves that observation stopped, not that the owner died. A live PID
73
+ * remains owned until its result is reconciled; only a dead process can be
74
+ * atomically claimed for cleanup.
75
+ */
70
76
  export function claimExpiredManagedProcesses(context, ownerId) {
71
77
  const db = registry(context);
72
78
  const now = context.now();
@@ -75,6 +81,8 @@ export function claimExpiredManagedProcesses(context, ownerId) {
75
81
  const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
76
82
  const claimed = [];
77
83
  for (const candidate of candidates) {
84
+ if (processTreeIsAlive(candidate))
85
+ continue;
78
86
  const token = crypto.randomUUID();
79
87
  const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
80
88
  if (result.changes === 1)
@@ -150,7 +158,9 @@ function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart="
150
158
  function releasePortClaims(db, claims) { for (const claim of claims)
151
159
  db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
152
160
  function terminateOwnedProcess(record, signal) {
153
- if (!record.pid || !record.pid_started_at || !processTreeIsAlive(record))
161
+ // Never signal a group merely because its former leader PID was once ours.
162
+ // PID reuse or a departed leader makes group ownership unprovable.
163
+ if (!record.pid || !record.pid_started_at || !processIsAlive(record))
154
164
  return;
155
165
  const group = parseMetadata(record.metadata_json).process_group_id;
156
166
  try {
@@ -102,7 +102,7 @@ async function dispatch(context, input) {
102
102
  clearInterval(leaseHeartbeat);
103
103
  }
104
104
  }
105
- function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
105
+ function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
106
106
  return false; selected.add(request.project_id); return true; }).slice(0, limit); }
107
107
  function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
108
108
  const work = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
@@ -15,7 +15,7 @@ import { refreshRunSessionProjection } from "./run-projection.js";
15
15
  import { resolveCanonRoot } from "./canon.js";
16
16
  import { bindCurrentEngineToRun } from "./engines.js";
17
17
  import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
18
- import { isLegalVnextTransition, vnextStages } from "../domain/stage-catalog.js";
18
+ import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
19
19
  import { publicSessionIdentity } from "./session-identity.js";
20
20
  const runSchemaId = "dd-flow/flow-run@3";
21
21
  const runtimeSchemaId = "dd-flow/flow-run@3";
@@ -754,6 +754,66 @@ export function advanceFlowRun(context, input) {
754
754
  const updatedRun = requireRunById(context, project.id, run.id);
755
755
  return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
756
756
  }
757
+ /**
758
+ * The only legal backward edge in vNext: an integration gate exposed a
759
+ * product defect, so the source must be repaired and reviewed again. Normal
760
+ * stage start remains fail-closed for completed stages.
761
+ */
762
+ export function prepareVnextMergeSourceRepairAttempt(context, input) {
763
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
764
+ const run = resolveRun(context, project.id, input.runId);
765
+ const index = authoritativeIndex(run);
766
+ const code = index.stage_runs.find((item) => item.stage === "code");
767
+ const review = index.stage_runs.find((item) => item.stage === "code-review");
768
+ const merge = index.stage_runs.find((item) => item.stage === "merge");
769
+ if (!code || code.status !== "done" || !review || review.status !== "done" || !merge || merge.status !== "running") {
770
+ throw new AppError("invalid_merge_repair_state", "Source repair requires accepted CODE/CODE-REVIEW and a running MERGE attempt", 2, {
771
+ run_id: run.id,
772
+ code: code?.status ?? null,
773
+ code_review: review?.status ?? null,
774
+ merge: merge?.status ?? null
775
+ });
776
+ }
777
+ const root = runArtifactRoot(run);
778
+ for (const stage of ["code", "code-review", "merge"])
779
+ archiveExistingStageAttempt(root, vnextStageDirectory(stage));
780
+ const now = context.now();
781
+ const restarted = {
782
+ ...code,
783
+ status: "running",
784
+ started_at: now,
785
+ updated_at: now,
786
+ attempt: nextAttempt(code)
787
+ };
788
+ delete restarted.completed_at;
789
+ delete restarted.duration_ms;
790
+ delete restarted.stage_report;
791
+ delete restarted.data;
792
+ delete restarted.report;
793
+ delete restarted.artifact_aliases;
794
+ const cycle = `MRR-${input.mergeRequestId}`;
795
+ index.stage_runs = index.stage_runs.filter((item) => item.stage !== "code" && item.stage !== "code-review" && item.stage !== "merge");
796
+ index.stage_runs.push(restarted);
797
+ index.variables = { ...(index.variables ?? {}), "merge.source_repair": { cycle, merge_request_id: input.mergeRequestId, prepared_at: now } };
798
+ index.status = "running";
799
+ index.verdict = "merge_source_repair";
800
+ index.next_action = "start_code_repair";
801
+ index.current_stage = "code";
802
+ index.updated_at = now;
803
+ index.attempts = index.stage_runs.map((item) => ({
804
+ stage: item.stage,
805
+ attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1,
806
+ root: item.dir,
807
+ archive: null,
808
+ status: item.status,
809
+ started_at: item.started_at ?? now,
810
+ finished_at: item.completed_at ?? null
811
+ }));
812
+ persistRunState(context, project, run, index);
813
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
814
+ appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
815
+ return { run_id: run.id, cycle };
816
+ }
757
817
  function closeOpenStagesForOverride(index, status, now) {
758
818
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
759
819
  for (const stage of index.stage_runs) {
@@ -48,10 +48,11 @@ export function startVnextCodeReview(context, input) {
48
48
  return { ok: true, resumed: true, run_id: run.id, stage, stage_status: prior, id: binding.work_session_id, prompt_path: existingPrompt, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), ...(orchestration ? { orchestration } : {}), next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
49
49
  }
50
50
  const mode = effectiveMode(home, run);
51
+ const cycle = reviewCycle(run);
51
52
  freezeFlowRunReviewMode(context, { projectRoot, runId: run.id, review: "code", mode: mode.mode, source: mode.source, reason: mode.reason });
52
53
  const promptPath = path.join(root, "stage-prompt.md");
53
54
  const reviewContextPath = path.join(root, "review-context.json");
54
- fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, workspace_fingerprint: workspaceFingerprint(run.workspace_root), code_report_sha256: sha256File(path.join(home, "05-code", "stage-report.json")) }, null, 2)}\n`);
55
+ fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", cycle, mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, workspace_fingerprint: workspaceFingerprint(run.workspace_root), code_report_sha256: sha256File(path.join(home, "05-code", "stage-report.json")) }, null, 2)}\n`);
55
56
  if (mode.mode === "off") {
56
57
  const prompt = `<stage_identity>\n- RUN: ${run.id}\n- stage: code-review\n- mode: off\n</stage_identity>\n\nCODE-REVIEW is disabled by the frozen RUN configuration. Finish with: ${finishCommand(context, run.id, projectRoot, path.join(root, "decision.json"))}\n`;
57
58
  fs.writeFileSync(promptPath, prompt);
@@ -61,7 +62,7 @@ export function startVnextCodeReview(context, input) {
61
62
  return { ok: true, run_id: run.id, stage, mode: mode.mode, mode_source: mode.source, id: binding.work_session_id, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
62
63
  }
63
64
  const groups = reviewGroups(home);
64
- const batch = { works: groups.map((group, index) => ({ key: `code-review-${index + 1}`, task: reviewerTask(home, group), launch_policy: "fresh_agent_required", result_schema: "dd-flow/code-review-result@1", payload: { kind: "code-review", group, read_only: true } })) };
65
+ const batch = { works: groups.map((group, index) => ({ key: `code-review-${index + 1}`, task: reviewerTask(home, group), launch_policy: "fresh_agent_required", result_schema: "dd-flow/code-review-result@1", payload: { kind: "code-review", review_cycle: cycle, group, read_only: true } })) };
65
66
  const batchFile = path.join(root, "review-work-batch.json");
66
67
  fs.writeFileSync(batchFile, `${JSON.stringify(batch, null, 2)}\n`);
67
68
  const prompt = orchestratorPrompt(context, { projectRoot, run, root, rootWork, mode: mode.mode, groups });
@@ -79,7 +80,7 @@ export function addVnextCodeReviewRepair(context, input) {
79
80
  const projectRoot = resolveProjectRoot(input.projectRoot);
80
81
  const project = requireProjectByRoot(context, projectRoot);
81
82
  const run = requireRun(context, project.id, input.runId);
82
- const reviewers = reviewerWorks(context, project.id, run.id);
83
+ const reviewers = reviewerWorks(context, project.id, run.id, reviewCycle(run));
83
84
  const findings = canonicalCodeFindings(reviewers);
84
85
  const selected = findings.filter((finding) => input.findingIds.includes(finding.finding_ref));
85
86
  if (selected.length !== new Set(input.findingIds).size)
@@ -108,7 +109,8 @@ export function addVnextCodeReviewRepair(context, input) {
108
109
  reviewFindings: selected.map(({ finding_ref, finding }) => ({ finding_ref, priority: finding.priority, problem: finding.problem, impact: finding.impact, required_outcome: finding.required_outcome, evidence_refs: finding.evidence_refs, obligation_refs: finding.obligation_refs, decision_reason: input.decisionReasonsByFinding?.[finding_ref] ?? input.objective, check_refs: input.checkRefsByFinding[finding_ref] ?? [] })),
109
110
  reviewChecks,
110
111
  originWorkIds: origins,
111
- objective: input.objective
112
+ objective: input.objective,
113
+ reviewCycle: reviewCycle(run)
112
114
  });
113
115
  }
114
116
  export async function finishVnextCodeReview(context, input) {
@@ -140,7 +142,8 @@ export async function finishVnextCodeReview(context, input) {
140
142
  fs.writeFileSync(decisionFile, `${JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "CODE-REVIEW is disabled by RUN configuration.", findings: [] }, null, 2)}\n`);
141
143
  validateSchema({ schemaName: "code-review-decision", file: decisionFile, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
142
144
  const decision = readJson(decisionFile);
143
- const reviewers = reviewerWorks(context, project.id, run.id);
145
+ const cycle = reviewContext.cycle ?? "initial";
146
+ const reviewers = reviewerWorks(context, project.id, run.id, cycle);
144
147
  if (mode !== "off") {
145
148
  const incomplete = reviewers.filter((work) => work.status !== "completed");
146
149
  if (incomplete.length)
@@ -149,7 +152,7 @@ export async function finishVnextCodeReview(context, input) {
149
152
  validateReviewerResult(context, work, run);
150
153
  const canonical = reviewDecisionForRepair(decision, reviewers);
151
154
  const checkRefsByFinding = validateRepairChecks(context, { projectId: project.id, runId: run.id, decision: canonical.decision });
152
- let repairs = reviewRepairWorks(context, project.id, run.id, root);
155
+ let repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
153
156
  const decisionSha = sha256File(decisionFile);
154
157
  const frozenShaFile = path.join(root, ".decision-sha256");
155
158
  if (fs.existsSync(frozenShaFile) && fs.readFileSync(frozenShaFile, "utf8").trim() !== decisionSha)
@@ -160,14 +163,14 @@ export async function finishVnextCodeReview(context, input) {
160
163
  fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
161
164
  return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke the same stage finish command again with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
162
165
  }
163
- repairs = reviewRepairWorks(context, project.id, run.id, root);
166
+ repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
164
167
  const incompleteRepairs = repairs.filter((work) => work.status !== "completed");
165
168
  if (incompleteRepairs.length)
166
169
  throw new AppError("review_repair_incomplete", "CODE-REVIEW cannot finish while a repair Work is unsettled", 2, { works: incompleteRepairs.map((work) => ({ work_id: work.work_id, status: work.status })) });
167
170
  validateDecision(context, { decision: canonical.decision, reviewers: canonical.reviewers, repairs, workspaceRoot: run.workspace_root });
168
171
  }
169
172
  const outcome = decision.findings.some((item) => item.disposition === "defer") ? "accepted_with_DEF" : "accepted";
170
- const repairs = reviewRepairWorks(context, project.id, run.id, root);
173
+ const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
171
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 : []; }));
172
175
  const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks });
173
176
  if (unchangedFailures.length)
@@ -320,7 +323,7 @@ function validateReviewerResult(_context, work, _run) {
320
323
  if (result.aspects.some((aspect) => aspect.verdict === "blocked"))
321
324
  throw new AppError("review_blocked", "A blocked review aspect must be resolved before CODE-REVIEW can finish", 2, { work_id: work.work_id });
322
325
  }
323
- function reviewerWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => { const p = readJsonString(work.payload_json); return Boolean(p && p.kind === "code-review"); }); }
326
+ function reviewerWorks(context, projectId, runId, cycle) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => { const payload = readJsonString(work.payload_json); return Boolean(payload.kind === "code-review" && (typeof payload.review_cycle === "string" ? payload.review_cycle : "initial") === cycle); }); }
324
327
  function codeWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND status = 'completed' ORDER BY created_at, work_id", [projectId, runId]).filter((work) => readJsonString(work.payload_json).schema_id === "dd-flow/code-work-packet@5" && !reviewFindingIds(work).length); }
325
328
  function acceptedCodeChecks(context, projectId, runId) { return codeWorks(context, projectId, runId).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }); }
326
329
  function validateRepairChecks(context, input) { const known = new Set(acceptedCodeChecks(context, input.projectId, input.runId).map((check) => check.id)); const selected = {}; for (const item of input.decision.findings.filter((item) => item.disposition === "fix")) {
@@ -332,7 +335,7 @@ function validateRepairChecks(context, input) { const known = new Set(acceptedCo
332
335
  throw new AppError("review_check_reference_unknown", "CODE-REVIEW repair selects a check absent from the accepted CODE handoff", 2, { finding_ref: item.finding_ref, check_refs: unknown });
333
336
  selected[item.finding_ref] = refs;
334
337
  } return selected; }
335
- function reviewRepairWorks(context, projectId, runId, root) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => isCodeReviewStageRepair(work, root)); }
338
+ function reviewRepairWorks(context, projectId, runId, root, cycle) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => { const payload = readJsonString(work.payload_json); return isCodeReviewStageRepair(work, root) && (typeof payload.review_cycle === "string" ? payload.review_cycle : "initial") === cycle; }); }
336
339
  export function isCodeReviewStageRepair(work, root) {
337
340
  if (reviewFindingIds(work).length)
338
341
  return true;
@@ -350,7 +353,8 @@ function reviewFindingIds(work) { const repair = readJsonString(work.payload_jso
350
353
  function reviewCheckRefs(work) { const repair = readJsonString(work.payload_json).repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
351
354
  return []; const findings = repair.review_findings; return Array.isArray(findings) ? [...new Set(findings.flatMap((value) => typeof value === "object" && value !== null && Array.isArray(value.check_refs) ? value.check_refs.filter((ref) => typeof ref === "string") : []))] : []; }
352
355
  function canonicalCodeFindings(works) { return works.flatMap((work) => readJsonString(work.result).findings.map((finding) => ({ finding_ref: `${work.work_id}/${finding.finding_id}`, finding }))); }
353
- function effectiveMode(home, run) { const index = JSON.parse(run.index_json); const configured = index.settings?.code_review; const requested = configured?.mode ?? "auto"; if (requested !== "auto")
356
+ function effectiveMode(home, run) { const index = JSON.parse(run.index_json); if (index.variables?.["merge.source_repair"])
357
+ return { mode: "standard", source: "project_policy", reason: "A failed integration gate requires independent review of its source repair." }; const configured = index.settings?.code_review; const requested = configured?.mode ?? "auto"; if (requested !== "auto")
354
358
  return { mode: requested, source: configured?.source === "user_instruction" ? "user_instruction" : "project_policy", reason: configured?.reason ?? "Frozen RUN configuration." }; const assessments = findFiles(path.join(home, "03-plan"), "plan.json").map((file) => readJson(file)); const deep = assessments.some((plan) => plan.assessment?.failure_impact?.level === "high" || plan.assessment?.solution_uncertainty?.level === "high"); return { mode: deep ? "deep" : "standard", source: "plan_assessment", reason: deep ? "Accepted PLAN assessment records high impact or uncertainty." : "Accepted PLAN assessment requires the standard independent review." }; }
355
359
  function executionStopTarget(run) { return JSON.parse(run.index_json).execution_profile?.settings?.stop_target ?? "code_review_completed"; }
356
360
  function changedPaths(workspaceRoot) { try {
@@ -366,6 +370,14 @@ function stageStatus(run, name) { try {
366
370
  catch {
367
371
  return null;
368
372
  } }
373
+ function reviewCycle(run) { try {
374
+ const value = JSON.parse(run.index_json);
375
+ const repair = value.variables?.["merge.source_repair"];
376
+ return repair && typeof repair === "object" && !Array.isArray(repair) && typeof repair.cycle === "string" ? String(repair.cycle) : "initial";
377
+ }
378
+ catch {
379
+ return "initial";
380
+ } }
369
381
  function stageStartedAt(run, fallback) { try {
370
382
  return JSON.parse(run.index_json).stage_runs?.find((item) => item.stage === stage)?.started_at ?? fallback;
371
383
  }
@@ -31,6 +31,7 @@ export async function startVnextCode(context, input) {
31
31
  const existingRoot = path.join(home, stageDir);
32
32
  const existingPrompt = path.join(existingRoot, "stage-prompt.md");
33
33
  const existingStage = stageStatus(run, stage);
34
+ const sourceRepairAttempt = isMergeSourceRepairAttempt(run);
34
35
  if (existingStage && fs.existsSync(existingPrompt)) {
35
36
  const externalContext = applyExternalStageContext({ stageRoot: existingRoot, promptPath: existingPrompt, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
36
37
  const prompt = fs.readFileSync(existingPrompt, "utf8");
@@ -73,14 +74,16 @@ export async function startVnextCode(context, input) {
73
74
  resultPath: path.join(root, "stage-report.json"),
74
75
  ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {})
75
76
  });
76
- attachFlowRunStage(context, {
77
- projectRoot,
78
- runId: input.runId,
79
- stage,
80
- dir: stageDir,
81
- status: "running",
82
- dataSchemaId: "dd-flow/stage-report@1"
83
- });
77
+ if (!sourceRepairAttempt) {
78
+ attachFlowRunStage(context, {
79
+ projectRoot,
80
+ runId: input.runId,
81
+ stage,
82
+ dir: stageDir,
83
+ status: "running",
84
+ dataSchemaId: "dd-flow/stage-report@1"
85
+ });
86
+ }
84
87
  const graph = codeWorkGraph(context, project.id, input.runId);
85
88
  const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
86
89
  appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "code_started", work_id: rootWork.work_id, graph });
@@ -357,7 +360,8 @@ export function addVnextCodeRepair(context, input) {
357
360
  ...(receipt ? ["Stop and report the blocker if the proposed repair contradicts an accepted requirement or non-goal."] : ["Do not modify accepted PLAN artifacts, code-work-batch.json, or review decisions. Stop and report if the finding cannot be repaired within accepted scope."])
358
361
  ]),
359
362
  depends_on: origins.map((work) => work.work_id),
360
- result_schema: "dd-flow/code-work-result@2"
363
+ result_schema: "dd-flow/code-work-result@3",
364
+ ...(input.reviewCycle ? { review_cycle: input.reviewCycle } : {})
361
365
  };
362
366
  const file = path.join(home, stageDir, `.repair-${crypto.randomUUID()}.json`);
363
367
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -545,8 +549,8 @@ export function verificationProjection(works, receipts, options = {}) {
545
549
  for (const work of works) {
546
550
  const result = readJsonResult(work.result);
547
551
  for (const item of result.evidence ?? [])
548
- if (item.criterion_id)
549
- rawEvidence.set(item.criterion_id, unique([...(rawEvidence.get(item.criterion_id) ?? []), ...(item.refs ?? [])]));
552
+ if (item.obligation_ref)
553
+ rawEvidence.set(item.obligation_ref, unique([...(rawEvidence.get(item.obligation_ref) ?? []), ...(item.refs ?? [])]));
550
554
  }
551
555
  const acceptance = uniqueBy(packets.flatMap((value) => value.acceptance), (value) => value.criterion_id);
552
556
  const historicalReceiptRefs = new Set((options.historicalReceipts ?? receipts).flatMap((receipt) => receiptReferences(receipt, options)));
@@ -726,6 +730,8 @@ export function writeProtocolFlowStatus(workspaceRoot, runHome, runId, status) {
726
730
  }
727
731
  function nextAction(run, changedPaths) {
728
732
  const index = JSON.parse(run.index_json);
733
+ if (index.variables?.["merge.source_repair"])
734
+ return "start_code_review";
729
735
  const requested = index.settings?.code_review?.mode ?? "auto";
730
736
  const enabled = requested === "off" ? false : requested === "auto" ? changedPaths.length > 0 : true;
731
737
  const stopTarget = index.execution_profile?.settings?.stop_target;
@@ -741,6 +747,15 @@ function stageStatus(run, name) {
741
747
  return null;
742
748
  }
743
749
  }
750
+ function isMergeSourceRepairAttempt(run) {
751
+ try {
752
+ const value = JSON.parse(run.index_json);
753
+ return Boolean(value.variables?.["merge.source_repair"]);
754
+ }
755
+ catch {
756
+ return false;
757
+ }
758
+ }
744
759
  function changedPathsFromReport(file) {
745
760
  try {
746
761
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -8,11 +8,12 @@ import { resolveProjectRoot } from "../storage/paths.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
9
  import { nextMergeRequestId } from "./ids.js";
10
10
  import { readProjectConfig } from "./config.js";
11
- import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts } from "./runs.js";
11
+ import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts, prepareVnextMergeSourceRepairAttempt } from "./runs.js";
12
12
  import { flowCommand, stagePauseCommandTemplate } from "./stage-pause.js";
13
13
  import { validateSchema } from "./schema-validation.js";
14
14
  import { writeStageReport } from "./stage-report-renderer.js";
15
- import { bindStageCoordinatorWork, createChildWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
15
+ import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
16
+ import { addVnextCodeRepair } from "./vnext-code.js";
16
17
  import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
17
18
  import { applyExternalStageContext } from "./stage-context.js";
18
19
  import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
@@ -27,7 +28,7 @@ export function ensureVnextMergeRequest(context, input) {
27
28
  const projectRoot = resolveProjectRoot(input.projectRoot);
28
29
  const project = requireProjectByRoot(context, projectRoot);
29
30
  const run = requireRun(context, project.id, input.runId);
30
- const existing = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at LIMIT 1", [project.id, run.id]);
31
+ const existing = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') ORDER BY created_at DESC LIMIT 1", [project.id, run.id]);
31
32
  if (existing)
32
33
  return requestView(context, existing);
33
34
  const settings = executionSettings(run);
@@ -88,14 +89,14 @@ export function ensureVnextMergeRequest(context, input) {
88
89
  const now = context.now();
89
90
  context.db.exec("BEGIN IMMEDIATE");
90
91
  try {
91
- const raced = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ?", [project.id, run.id]);
92
+ const raced = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') ORDER BY created_at DESC LIMIT 1", [project.id, run.id]);
92
93
  if (raced) {
93
94
  context.db.exec("COMMIT");
94
95
  return requestView(context, raced);
95
96
  }
96
97
  child = createChildWork(context, { parentWorkId: root.work_id, slug: "merge", task: `Integrate frozen source ${sourceCommit} into ${targetBranch}, resolve material conflicts, and preserve accepted behavior.`, launchPolicy: route === "server" ? "fresh_agent_required" : "reuse_allowed", resultSchema: "dd-flow/merge-result@1", payload: { kind: "merge", checks: semantic, protocol_ids: protocols } });
97
98
  requestId = nextMergeRequestId(context);
98
- context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, readCodeCheckProfile(targetWorkspace).hash, now, now]);
99
+ context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, replacement_of_merge_request_id, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, readCodeCheckProfile(targetWorkspace).hash, replacementOf(run), now, now]);
99
100
  context.db.exec("COMMIT");
100
101
  }
101
102
  catch (error) {
@@ -231,7 +232,7 @@ export async function finishVnextMerge(context, input) {
231
232
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
232
233
  if (failed.length) {
233
234
  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]);
234
- throw new AppError("merge_gate_failed", "Integrated target checks failed; repair in the same MERGE Work and retry finish", 2, { failures: failed });
235
+ 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` });
235
236
  }
236
237
  const requiredRefs = mergeAcceptanceRefs(run.workspace_root, JSON.parse(request.protocol_ids_json));
237
238
  const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
@@ -303,7 +304,33 @@ catch (error) {
303
304
  context.db.exec("ROLLBACK");
304
305
  throw error;
305
306
  } refreshRunWorkProjection(context, request.project_id, request.run_id); return requestView(context, requireRequest(context, request.merge_request_id)); }
306
- 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`; 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 integration conflicts in the integration workspace. Do not repeat merge apply.", `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 check fails, inspect only the returned receipt/logs, repair the integrated target in this same Work, update the semantic result, and repeat the same finish command. Do not create a repair Work or rerun independent review.", "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"); }
307
+ /** Move a failed integration gate back to the source CODE/CODE-REVIEW loop. */
308
+ export async function repairVnextMerge(context, input) {
309
+ const projectRoot = resolveProjectRoot(input.projectRoot);
310
+ const project = requireProjectByRoot(context, projectRoot);
311
+ const request = requireRequest(context, input.requestId);
312
+ if (request.project_id !== project.id || request.status !== "action_required" || errorCode(request) !== "merge_gate_failed") {
313
+ 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) });
314
+ }
315
+ abortIntegration(request);
316
+ const originWorkIds = sourceCodeWorkIds(context, request.project_id, request.run_id);
317
+ if (!originWorkIds.length)
318
+ throw new AppError("runtime_missing", "MERGE source repair requires accepted CODE Work evidence", 1, { run_id: request.run_id });
319
+ const failed = failedReceiptIds(request);
320
+ if (!failed.length)
321
+ throw new AppError("runtime_missing", "MERGE failure has no durable failed receipt", 1, { merge_request_id: request.merge_request_id });
322
+ // Settle the child while its canonical result path still exists; the next
323
+ // step archives the entire MERGE attempt as immutable evidence.
324
+ await failWork(context, request.executor_work_id, `Superseded by source repair after failed integration gate ${failed[0]}.`);
325
+ const prepared = prepareVnextMergeSourceRepairAttempt(context, { projectRoot, runId: request.run_id, mergeRequestId: request.merge_request_id });
326
+ const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
327
+ const now = context.now();
328
+ 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]);
329
+ releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
330
+ 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 });
331
+ 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` } };
332
+ }
333
+ 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"); }
307
334
  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" } }; }
308
335
  function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
309
336
  function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
@@ -365,7 +392,7 @@ function releaseMergeLane(context, projectRoot, request, reason) {
365
392
  }
366
393
  catch { /* An expired lease cannot invalidate an already completed MERGE. */ }
367
394
  }
368
- 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') 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; }
395
+ 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; }
369
396
  function queueStatus(context, request) { return { position: queueAhead(context, request) + 1, requests_ahead: queueAhead(context, request), status: request.status, route: request.execution_route }; }
370
397
  function requireOwnedActiveRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = requireRequest(context, input.requestId); if (request.project_id !== project.id || request.executor_work_id !== input.workId || !["active", "action_required"].includes(request.status))
371
398
  throw new AppError("invalid_merge_state", "MRG/Work/project is not the active integration owner", 2, { merge_request_id: input.requestId, work_id: input.workId, status: request.status }); return request; }
@@ -392,11 +419,42 @@ catch (error) {
392
419
  function gitValue(cwd, args, allowFailure = false) { const result = spawnSync("git", args, { cwd, encoding: "utf8" }); if (result.status !== 0 && !allowFailure)
393
420
  throw new AppError("git_operation_failed", `git ${args[0]} failed`, 1, { cwd, args, stderr: result.stderr }); return result.status === 0 ? result.stdout.trim() : ""; }
394
421
  function applyReceiptPath(context, request) { const run = requireRun(context, request.project_id, request.run_id); return path.join(requireHome(run), stageDir, "works", request.executor_work_id, "apply-receipt.json"); }
395
- function requestForRun(context, projectId, runId) { const request = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at LIMIT 1", [projectId, runId]); if (!request)
422
+ function requestForRun(context, projectId, runId) { const request = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC LIMIT 1", [projectId, runId]); if (!request)
396
423
  throw new AppError("merge_request_missing", "MERGE request was not materialized by the prior terminal stage", 1, { run_id: runId }); return request; }
397
424
  function requireRequest(context, id) { const request = context.db.get("SELECT * FROM merge_requests WHERE merge_request_id = ?", [id]); if (!request)
398
425
  throw new AppError("not_found", "MERGE request is not registered", 1, { merge_request_id: id }); return request; }
399
- function requestView(context, request) { return { ok: true, merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source: { workspace: request.source_workspace, branch: request.source_branch, commit: request.source_commit }, target: { workspace: request.target_workspace, branch: request.target_branch, enqueue_head: request.enqueue_target_head, execution_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit }, route: request.execution_route, status: request.status, checkpoint: request.checkpoint, queue: queueStatus(context, request), created_at: request.created_at, completed_at: request.completed_at }; }
426
+ function requestView(context, request) { return { ok: true, merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source: { workspace: request.source_workspace, branch: request.source_branch, commit: request.source_commit }, target: { workspace: request.target_workspace, branch: request.target_branch, enqueue_head: request.enqueue_target_head, execution_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit }, route: request.execution_route, status: request.status, checkpoint: request.checkpoint, ...(request.replacement_of_merge_request_id ? { replacement_of_merge_request_id: request.replacement_of_merge_request_id } : {}), queue: queueStatus(context, request), created_at: request.created_at, completed_at: request.completed_at }; }
427
+ function replacementOf(run) { try {
428
+ const variables = JSON.parse(run.index_json).variables;
429
+ const repair = variables?.["merge.source_repair"];
430
+ return repair && typeof repair === "object" && !Array.isArray(repair) && typeof repair.merge_request_id === "string" ? String(repair.merge_request_id) : null;
431
+ }
432
+ catch {
433
+ return null;
434
+ } }
435
+ function errorCode(request) { try {
436
+ const value = JSON.parse(request.last_error_json ?? "{}");
437
+ return typeof value?.code === "string" ? value.code : null;
438
+ }
439
+ catch {
440
+ return null;
441
+ } }
442
+ function failedReceiptIds(request) { try {
443
+ const value = JSON.parse(request.last_error_json ?? "{}");
444
+ return (value.failures ?? []).flatMap((failure) => typeof failure === "object" && failure !== null && typeof failure.id === "string" ? [String(failure.id)] : []);
445
+ }
446
+ catch {
447
+ return [];
448
+ } }
449
+ function sourceCodeWorkIds(context, projectId, runId) { return context.db.all("SELECT work_id, payload_json FROM works WHERE project_id = ? AND run_id = ? AND status = 'completed' ORDER BY created_at, work_id", [projectId, runId]).flatMap((work) => { try {
450
+ return JSON.parse(work.payload_json ?? "").schema_id === "dd-flow/code-work-packet@5" ? [work.work_id] : [];
451
+ }
452
+ catch {
453
+ return [];
454
+ } }); }
455
+ function abortIntegration(request) { if (gitValue(request.target_workspace, ["rev-parse", "-q", "--verify", "MERGE_HEAD"], true))
456
+ git(request.target_workspace, ["merge", "--abort"]); const head = gitValue(request.target_workspace, ["rev-parse", "HEAD"]); const branch = gitValue(request.target_workspace, ["branch", "--show-current"]); const dirty = meaningfulStatus(request.target_workspace, []); if (head !== request.execution_target_head || branch !== request.target_branch || dirty.length)
457
+ throw new AppError("merge_recovery_required", "Integration workspace could not be restored to its locked baseline", 1, { merge_request_id: request.merge_request_id, expected_head: request.execution_target_head, head, expected_branch: request.target_branch, branch, dirty }); }
400
458
  function requireRun(context, projectId, runId) { const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (!run)
401
459
  throw new AppError("not_found", "RUN is not registered", 1); return run; }
402
460
  function requireHome(run) { if (!run.run_root)
@@ -431,7 +431,7 @@ function projectCodeWorkBatch(input) {
431
431
  provides_checks: value.checks.filter((check) => check.availability === "planned" && check.provided_by === item.id),
432
432
  stop_conditions: item.execution_context.stop_conditions,
433
433
  depends_on: item.depends_on.map((dependency) => `${protocolId}:${dependency}`),
434
- result_schema: "dd-flow/code-work-result@2"
434
+ result_schema: "dd-flow/code-work-result@3"
435
435
  });
436
436
  }));
437
437
  const byProtocol = new Map(input.plans.map((plan) => [plan.protocolId, plan.value.items]));
@@ -428,7 +428,7 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, r
428
428
  finally {
429
429
  fs.rmSync(candidate, { force: true });
430
430
  }
431
- if (work.result_schema === "dd-flow/code-work-result@2")
431
+ if (work.result_schema === "dd-flow/code-work-result@3")
432
432
  validateCodeWorkResult(work, parsed, workspaceRoot, runHome, runId);
433
433
  if (work.result_schema === "dd-flow/code-review-result@1")
434
434
  validateCodeReviewResult(work, parsed, { workspaceRoot, runHome, runId });
@@ -442,10 +442,15 @@ function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
442
442
  const packet = codePacket(work);
443
443
  if (!packet)
444
444
  return;
445
- const acceptedCriteria = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
445
+ const acceptedObligations = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
446
+ for (const check of packet.checks)
447
+ acceptedObligations.add(check.id);
448
+ for (const finding of packet.repair?.review_findings ?? [])
449
+ for (const ref of finding.obligation_refs ?? [])
450
+ acceptedObligations.add(ref);
446
451
  for (const item of result.evidence ?? []) {
447
- if (!item.criterion_id || !acceptedCriteria.has(item.criterion_id))
448
- throw new AppError("evidence_criterion_unknown", "CODE Work evidence must reference an acceptance criterion assigned to this Work", 2, { work_id: work.work_id, criterion_id: item.criterion_id ?? null });
452
+ if (!item.obligation_ref || !acceptedObligations.has(item.obligation_ref))
453
+ throw new AppError("evidence_obligation_unknown", "CODE Work evidence must reference an obligation assigned to this Work", 2, { work_id: work.work_id, obligation_ref: item.obligation_ref ?? null, accepted_obligation_refs: [...acceptedObligations] });
449
454
  for (const ref of item.refs ?? [])
450
455
  assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
451
456
  }
@@ -479,16 +484,29 @@ export function validateReviewResolutions(workId, assigned, resolutions) {
479
484
  function renderWorkerPrompt(context, work, run, dependencies) {
480
485
  const command = flowCommand(context);
481
486
  const packet = codePacket(work);
487
+ const mergeWork = parsePayload(work)?.kind === "merge";
488
+ const writeBoundary = mergeWork
489
+ ? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
490
+ : [`HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not."];
491
+ const completionRepair = mergeWork
492
+ ? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
493
+ : ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output."];
482
494
  const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies the materialization and then executes the check.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
483
495
  if (packet)
484
496
  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>", "");
485
- return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", `HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not.", "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "The CLI runs every declared required check before accepting this Work. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", "Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output.", "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
497
+ 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>", "The CLI runs every declared required check before accepting this Work. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...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");
486
498
  }
487
499
  function resultSchemaGuidance(work, runId) {
488
500
  const schema = work.result_schema;
489
501
  const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
490
- if (schema === "dd-flow/code-work-result@2")
491
- return [refs, "Use this complete minimal shape. For a review repair, include exactly one resolution per assigned finding; explain what changed and cite evidence that demonstrates the required outcome:", "```json", JSON.stringify({ schema_id: schema, summary: "What was implemented.", changed_paths: ["project-relative/path"], evidence: [{ criterion_id: "AC-001", refs: ["project-relative/evidence", `run://${runId}/05-code/checks/receipt.json`] }], deviations: [], blockers: [], resolutions: [{ finding_ref: "WRK-000/FIND-001", summary: "How the required outcome was achieved.", evidence_refs: ["project-relative/evidence"] }] }, null, 2), "```"];
502
+ if (schema === "dd-flow/code-work-result@3") {
503
+ const packet = codePacket(work);
504
+ const obligationRef = packet?.repair?.check_receipt_id ? packet.checks[0]?.id ?? "CHK-assigned-check" : "AC-001";
505
+ const obligationNote = packet?.repair?.check_receipt_id
506
+ ? `This is a gate-repair Work: cite its assigned check id (${obligationRef}) in evidence. Do not substitute an unrelated acceptance criterion.`
507
+ : "Cite an acceptance criterion or check explicitly assigned to this Work in evidence.";
508
+ return [refs, obligationNote, "For a review repair, include exactly one resolution per assigned finding; explain what changed and cite evidence that demonstrates the required outcome:", "```json", JSON.stringify({ schema_id: schema, summary: "What was implemented.", changed_paths: ["project-relative/path"], evidence: [{ obligation_ref: obligationRef, refs: ["project-relative/evidence"] }], deviations: [], blockers: [], resolutions: [{ finding_ref: "WRK-000/FIND-001", summary: "How the required outcome was achieved.", evidence_refs: ["project-relative/evidence"] }] }, null, 2), "```"];
509
+ }
492
510
  if (schema === "dd-flow/code-review-result@1") {
493
511
  return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Use this complete minimal shape. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: ["path/to/file", `run://${runId}/05-code/checks/receipt.json`] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
494
512
  }
@@ -574,6 +574,7 @@ function migrate(db, dbPath) {
574
574
  lock_acquired_at TEXT,
575
575
  checkpoint TEXT NOT NULL DEFAULT 'queued',
576
576
  profile_hash TEXT,
577
+ replacement_of_merge_request_id TEXT,
577
578
  adapter_receipt_json TEXT,
578
579
  result_json TEXT,
579
580
  last_error_json TEXT,
@@ -585,8 +586,8 @@ function migrate(db, dbPath) {
585
586
  );
586
587
  CREATE INDEX IF NOT EXISTS idx_merge_requests_fifo
587
588
  ON merge_requests(project_id, status, created_at, merge_request_id);
588
- CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_run
589
- ON merge_requests(project_id, run_id);
589
+ CREATE INDEX IF NOT EXISTS idx_merge_requests_run
590
+ ON merge_requests(project_id, run_id, created_at);
590
591
  CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_active_project
591
592
  ON merge_requests(project_id)
592
593
  WHERE status IN ('active', 'waiting_user', 'action_required', 'recovery_required');
@@ -811,6 +812,11 @@ function migrate(db, dbPath) {
811
812
  ensureColumn(db, "runs", "run_root", "ALTER TABLE runs ADD COLUMN run_root TEXT");
812
813
  db.prepare("UPDATE runs SET run_root = COALESCE(run_root, run_home_path, run_dir) WHERE run_root IS NULL OR run_root = ''").run();
813
814
  ensureColumn(db, "merge_requests", "accepted_tree", "ALTER TABLE merge_requests ADD COLUMN accepted_tree TEXT");
815
+ ensureColumn(db, "merge_requests", "replacement_of_merge_request_id", "ALTER TABLE merge_requests ADD COLUMN replacement_of_merge_request_id TEXT");
816
+ // A failed integration gate may create a new immutable source candidate in
817
+ // the same RUN. The historical unique index made that repair path impossible.
818
+ db.exec("DROP INDEX IF EXISTS idx_merge_requests_run");
819
+ db.exec("CREATE INDEX IF NOT EXISTS idx_merge_requests_run ON merge_requests(project_id, run_id, created_at)");
814
820
  ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
815
821
  ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
816
822
  ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.11",
3
+ "version": "0.9.0-beta.13",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {