@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import { parseJsonObject } from "../shared/json.js";
4
4
  import { resolveProjectRoot } from "../storage/paths.js";
5
5
  import { registerProject, requireProjectByRoot } from "./projects.js";
6
+ import { publicSessionIdentity } from "./session-identity.js";
6
7
  export function ingestZcodeUsage(context, input) {
7
8
  return ingestHarnessUsage(context, input, { harness: "zcode-acp", label: "ZCode", idPrefix: "ZUSG", sourceKind: "zcode_session_usage_v1" });
8
9
  }
@@ -55,11 +56,11 @@ function ingestHarnessUsage(context, input, harness) {
55
56
  values.input, values.cacheRead, values.cacheWrite, values.output, values.reasoning, values.requests, values.errors,
56
57
  values.toolCalls, values.toolFailures, values.toolByName ? JSON.stringify(values.toolByName) : null, harness.sourceKind,
57
58
  stringValue(payload.usage_scope) ?? "physical_session", stringValue(payload.completeness) ?? "complete", context.now()]);
58
- return { ok: true, observed: inserted.changes === 1, duplicate: inserted.changes === 0, snapshot_id: id, provider_session_id: providerSessionId, harness: harness.harness };
59
+ return { ok: true, observed: inserted.changes === 1, duplicate: inserted.changes === 0, snapshot_id: id, session: publicSessionIdentity({ harness: harness.harness, provider_session_id: providerSessionId, session_id: providerSessionId }) };
59
60
  }
60
61
  export function checkpointSessionUsage(context, session, input) {
61
62
  const observedAt = context.now();
62
- const result = readCodexTranscript(session.transcript_path, session.session_id);
63
+ const result = readCodexTranscript(session.transcript_path, nativeSessionId(session));
63
64
  const source = sourceFacts(session.transcript_path);
64
65
  const snapshot = {
65
66
  id: `USG-${crypto.randomUUID()}`,
@@ -114,7 +115,7 @@ export function checkpointSessionUsage(context, session, input) {
114
115
  return snapshotForOutput(snapshot);
115
116
  }
116
117
  export function checkpointRunUsage(context, input) {
117
- const sessions = context.db.all(`SELECT session_id, project_id, run_id, transcript_path, parent_session_id, role, aspect_id, plan_item_id, session_kind
118
+ const sessions = context.db.all(`SELECT session_id, project_id, run_id, transcript_path, provider_session_id, parent_session_id, role, aspect_id, plan_item_id, session_kind
118
119
  FROM sessions WHERE project_id = ? AND run_id = ?`, [input.projectId, input.runId]);
119
120
  return sessions.map((session) => checkpointSessionUsage(context, session, input));
120
121
  }
@@ -136,7 +137,7 @@ export function recalculateRunUsage(context, input) {
136
137
  // after `work finish` but before the agent's actual terminal event.
137
138
  const accountingAt = observedAt;
138
139
  const links = context.db.all(`SELECT ws.id, ws.work_id, ws.session_id, ws.created_at, ws.completed_at,
139
- w.project_id, s.harness, s.transcript_path, s.parent_session_id, s.agent_id, s.provider_session_id
140
+ w.project_id, s.harness, s.transcript_path, s.parent_session_id, s.provider_parent_session_id, s.agent_id, s.provider_session_id
140
141
  FROM work_sessions ws
141
142
  JOIN works w ON w.work_id = ws.work_id
142
143
  LEFT JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
@@ -154,31 +155,34 @@ export function recalculateRunUsage(context, input) {
154
155
  const settledSessions = settledRunSessions(context, links, observedAt);
155
156
  const unsettledSessions = [...new Set(links.map((link) => link.session_id))].filter((sessionId) => !settledSessions.some((session) => session.session_id === sessionId));
156
157
  const usageFinal = final && unsettledSessions.length === 0;
157
- context.db.exec("BEGIN IMMEDIATE");
158
- try {
159
- context.db.run("DELETE FROM usage WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId]);
160
- for (const row of rows) {
161
- context.db.run(`INSERT INTO usage (id, project_id, run_id, session_id, checkpoint, stage, stage_attempt, observed_at, total_tokens, input_tokens, cached_input_tokens, cache_read_input_tokens, cache_write_input_tokens, uncached_input_tokens, output_tokens, reasoning_output_tokens, source_kind, source_locator, source_sha256, source_size_bytes, source_mtime_ms, token_event_at, turn_id, turn_attribution, parser_version, extraction_status, diagnostic_code, created_at)
158
+ const persistRunProjection = !input.sessionId && !input.stage;
159
+ if (persistRunProjection) {
160
+ context.db.exec("BEGIN IMMEDIATE");
161
+ try {
162
+ context.db.run("DELETE FROM usage WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId]);
163
+ for (const row of rows) {
164
+ context.db.run(`INSERT INTO usage (id, project_id, run_id, session_id, checkpoint, stage, stage_attempt, observed_at, total_tokens, input_tokens, cached_input_tokens, cache_read_input_tokens, cache_write_input_tokens, uncached_input_tokens, output_tokens, reasoning_output_tokens, source_kind, source_locator, source_sha256, source_size_bytes, source_mtime_ms, token_event_at, turn_id, turn_attribution, parser_version, extraction_status, diagnostic_code, created_at)
162
165
  VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'windowed_work_session', 1, ?, ?, ?)`, [
163
- `USG-${crypto.randomUUID()}`, input.projectId, input.runId, row.session_id, usageFinal ? "final" : "provisional", input.stage?.name ?? null, row.observed_at,
164
- row.tokens?.total_tokens ?? null, row.tokens?.input_tokens ?? null, row.tokens?.cache_read_input_tokens ?? null,
165
- row.tokens?.cache_read_input_tokens ?? null, row.tokens?.cache_write_input_tokens ?? null, row.tokens?.uncached_input_tokens ?? null,
166
- row.tokens?.output_tokens ?? null, row.tokens?.reasoning_output_tokens ?? null, row.source_kind, row.source.locator, row.source.sha256, row.source.size, row.source.mtime,
167
- row.status, row.diagnostic, observedAt
168
- ]);
169
- }
170
- for (const session of settledSessions) {
171
- context.db.run(`UPDATE sessions
166
+ `USG-${crypto.randomUUID()}`, input.projectId, input.runId, row.session_id, usageFinal ? "final" : "provisional", input.stage?.name ?? null, row.observed_at,
167
+ row.tokens?.total_tokens ?? null, row.tokens?.input_tokens ?? null, row.tokens?.cache_read_input_tokens ?? null,
168
+ row.tokens?.cache_read_input_tokens ?? null, row.tokens?.cache_write_input_tokens ?? null, row.tokens?.uncached_input_tokens ?? null,
169
+ row.tokens?.output_tokens ?? null, row.tokens?.reasoning_output_tokens ?? null, row.source_kind, row.source.locator, row.source.sha256, row.source.size, row.source.mtime,
170
+ row.status, row.diagnostic, observedAt
171
+ ]);
172
+ }
173
+ for (const session of settledSessions) {
174
+ context.db.run(`UPDATE sessions
172
175
  SET status = 'stopped', stop_reason = 'provider_turn_complete', updated_at = ?, stopped_at = ?
173
176
  WHERE project_id = ? AND run_id = ? AND session_id = ?
174
177
  AND status = 'idle'
175
178
  AND NOT EXISTS (SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running')`, [observedAt, session.completed_at, input.projectId, input.runId, session.session_id, session.session_id]);
179
+ }
180
+ context.db.exec("COMMIT");
181
+ }
182
+ catch (error) {
183
+ context.db.exec("ROLLBACK");
184
+ throw error;
176
185
  }
177
- context.db.exec("COMMIT");
178
- }
179
- catch (error) {
180
- context.db.exec("ROLLBACK");
181
- throw error;
182
186
  }
183
187
  return {
184
188
  ok: true,
@@ -187,16 +191,28 @@ export function recalculateRunUsage(context, input) {
187
191
  status: usageFinal ? "final" : "provisional",
188
192
  scope: input.stage ? { kind: "stage", stage: input.stage.name, started_at: input.stage.startedAt, completed_at: input.stage.completedAt } : { kind: "run" },
189
193
  observed_at: observedAt,
194
+ persisted: persistRunProjection,
190
195
  totals: outputUsageTotals(totals),
191
- sessions: rows,
196
+ sessions: publicUsageRows(context, input.projectId, rows),
192
197
  session_reconciliation: {
193
- stopped: settledSessions.map((session) => session.session_id),
194
- unsettled: unsettledSessions
198
+ stopped: publicSessionIdentities(context, input.projectId, settledSessions.map((session) => session.session_id)),
199
+ unsettled: publicSessionIdentities(context, input.projectId, unsettledSessions)
195
200
  },
196
201
  tool_calls: toolCallsForLinks(context, links, accountingAt),
197
202
  coverage: rows.reduce((coverage, row) => ({ ...coverage, [row.status]: (coverage[row.status] ?? 0) + 1 }), {})
198
203
  };
199
204
  }
205
+ function publicUsageRows(context, projectId, rows) {
206
+ const identityFor = (storageId) => {
207
+ const row = context.db.get("SELECT harness, provider_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [projectId, storageId]);
208
+ return row ? publicSessionIdentity({ harness: row.harness, provider_session_id: row.provider_session_id, session_id: storageId }) : null;
209
+ };
210
+ return rows.map(({ session_id, provider_session_id: _providerSessionId, parent_session_id, ...row }) => ({
211
+ ...row,
212
+ session: identityFor(session_id),
213
+ ...(parent_session_id ? { parent_session: identityFor(parent_session_id) } : {})
214
+ }));
215
+ }
200
216
  function overlapsStage(link, stage, observedAt) {
201
217
  const linkStart = Date.parse(link.created_at);
202
218
  const linkEnd = Date.parse(link.completed_at ?? observedAt);
@@ -212,6 +228,17 @@ function clipToStage(link, stage, observedAt) {
212
228
  const completedAt = Date.parse(naturalEnd) > Date.parse(stageEnd) ? stageEnd : naturalEnd;
213
229
  return { ...link, created_at: startedAt, completed_at: completedAt };
214
230
  }
231
+ function nativeSessionId(session) {
232
+ return session.provider_session_id ?? session.session_id;
233
+ }
234
+ function publicSessionIdentities(context, projectId, storageIds) {
235
+ if (storageIds.length === 0)
236
+ return [];
237
+ const marks = storageIds.map(() => "?").join(", ");
238
+ const rows = context.db.all(`SELECT session_id, harness, provider_session_id FROM sessions WHERE project_id = ? AND session_id IN (${marks})`, [projectId, ...storageIds]);
239
+ const byStorage = new Map(rows.map((row) => [row.session_id, publicSessionIdentity(row)]));
240
+ return storageIds.flatMap((id) => byStorage.get(id) ?? []);
241
+ }
215
242
  function toolCallsForLinks(context, links, observedAt) {
216
243
  const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
217
244
  const sessions = new Map();
@@ -221,7 +248,7 @@ function toolCallsForLinks(context, links, observedAt) {
221
248
  const first = windows[0];
222
249
  const parsed = isExternalHarness(first.harness)
223
250
  ? readHarnessToolCallsWindow(context, first.harness, first.project_id, first.provider_session_id, windows.map((item) => item.created_at).sort()[0], windows.map((item) => item.completed_at ?? observedAt).sort().at(-1), observedAt)
224
- : readToolCallsForWindows(first.transcript_path, sessionId, windows, observedAt);
251
+ : readToolCallsForWindows(first.transcript_path, nativeSessionId(first), windows, observedAt);
225
252
  if (!parsed) {
226
253
  result.unavailable_sessions.push(sessionId);
227
254
  continue;
@@ -357,15 +384,15 @@ function usageRowsForPhysicalSessions(context, links, observedAt, stage) {
357
384
  sessions.set(link.session_id, [...(sessions.get(link.session_id) ?? []), link]);
358
385
  const physicalSessions = [...sessions.values()].filter((sessionLinks) => {
359
386
  const link = sessionLinks[0];
360
- if (!link.parent_session_id)
387
+ if (!link.provider_parent_session_id)
361
388
  return true;
362
- const parent = links.find((candidate) => candidate.session_id === link.parent_session_id);
389
+ const parent = links.find((candidate) => candidate.session_id === link.provider_parent_session_id);
363
390
  return !parent || !hasInclusiveUsageSnapshot(context, parent.project_id, parent.harness, parent.provider_session_id, observedAt);
364
391
  });
365
392
  return physicalSessions.map((sessionLinks) => {
366
393
  const link = sessionLinks[0];
367
394
  const startedAt = stage?.startedAt ?? sessionLinks.map((item) => item.created_at).sort()[0];
368
- const completedTurn = stage ? null : completedProviderTurn(link.transcript_path, link.session_id);
395
+ const completedTurn = stage ? null : completedProviderTurn(link.transcript_path, nativeSessionId(link));
369
396
  const naturalEnd = isExternalHarness(link.harness)
370
397
  ? observedAt
371
398
  : completedTurn?.completed_at ?? sessionLinks.map((item) => item.completed_at ?? observedAt).sort().at(-1);
@@ -373,7 +400,7 @@ function usageRowsForPhysicalSessions(context, links, observedAt, stage) {
373
400
  const harnessUsage = isExternalHarness(link.harness)
374
401
  ? readHarnessUsageWindow(context, link.harness, link.project_id, link.provider_session_id, startedAt, endedAt, observedAt)
375
402
  : null;
376
- const parsed = harnessUsage ?? readCodexTranscriptWindow(link.transcript_path, link.session_id, startedAt, endedAt);
403
+ const parsed = harnessUsage ?? readCodexTranscriptWindow(link.transcript_path, nativeSessionId(link), startedAt, endedAt);
377
404
  return {
378
405
  work_session_id: link.id,
379
406
  work_id: link.work_id,
@@ -437,15 +464,16 @@ function settledRunSessions(context, links, observedAt) {
437
464
  return [];
438
465
  const link = sessionLinks[0];
439
466
  const sessionStartedAt = sessionLinks.map((item) => item.created_at).sort()[0];
467
+ const workCompletedAt = sessionLinks.map((item) => item.completed_at).sort().at(-1);
440
468
  if (isExternalHarness(link.harness) && link.provider_session_id) {
441
469
  const snapshot = context.db.get(`SELECT observed_at FROM harness_usage_snapshots
442
470
  WHERE project_id = ? AND harness = ? AND provider_session_id = ?
443
471
  AND completeness = 'complete' AND observed_at >= ? AND observed_at <= ?
444
- ORDER BY observed_at DESC, id DESC LIMIT 1`, [link.project_id, link.harness, link.provider_session_id, sessionStartedAt, observedAt]);
472
+ ORDER BY observed_at DESC, id DESC LIMIT 1`, [link.project_id, link.harness, link.provider_session_id, workCompletedAt, observedAt]);
445
473
  return snapshot ? [{ session_id: sessionId, completed_at: snapshot.observed_at }] : [];
446
474
  }
447
- const completed = completedProviderTurn(sessionLinks[0].transcript_path, sessionId);
448
- if (!completed || Date.parse(completed.completed_at) < Date.parse(sessionStartedAt))
475
+ const completed = completedProviderTurn(sessionLinks[0].transcript_path, nativeSessionId(sessionLinks[0]));
476
+ if (!completed || Date.parse(completed.completed_at) < Date.parse(workCompletedAt) || Date.parse(completed.completed_at) < Date.parse(sessionStartedAt))
449
477
  return [];
450
478
  return [{ session_id: sessionId, completed_at: completed.completed_at }];
451
479
  });
@@ -484,10 +512,10 @@ function completedProviderTurn(transcriptPath, expectedSessionId) {
484
512
  }
485
513
  }
486
514
  function usageReport(context, input, refresh) {
487
- const sessions = context.db.all(`SELECT DISTINCT session_id, project_id, ? AS run_id, transcript_path, parent_session_id, role, aspect_id, plan_item_id, session_kind
515
+ const sessions = context.db.all(`SELECT DISTINCT session_id, project_id, ? AS run_id, transcript_path, harness, provider_session_id, parent_session_id, role, aspect_id, plan_item_id, session_kind
488
516
  FROM sessions WHERE project_id = ? AND run_id = ?
489
517
  UNION
490
- SELECT DISTINCT f.session_id, f.project_id, ? AS run_id, f.transcript_path, f.parent_session_id, f.role, f.aspect_id, f.plan_item_id, f.session_kind
518
+ SELECT DISTINCT f.session_id, f.project_id, ? AS run_id, f.transcript_path, f.harness, f.provider_session_id, f.parent_session_id, f.role, f.aspect_id, f.plan_item_id, f.session_kind
491
519
  FROM flow_session_segments s JOIN sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
492
520
  WHERE s.project_id = ? AND s.run_id = ?`, [input.runId, input.projectId, input.runId, input.runId, input.projectId, input.runId]);
493
521
  const selectedSessions = input.sessionId ? sessions.filter((session) => session.session_id === input.sessionId) : sessions;
@@ -500,13 +528,15 @@ function usageReport(context, input, refresh) {
500
528
  LEFT JOIN sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
501
529
  WHERE s.project_id = ? AND s.run_id = ? ORDER BY s.session_id, s.observed_at, s.id`, [input.projectId, input.runId]);
502
530
  const selectedRows = input.sessionId ? rows.filter((row) => row.session_id === input.sessionId) : rows;
503
- const deltas = usageDeltas(selectedRows);
531
+ const publicSessions = new Map(sessions.map((session) => [session.session_id, publicSessionIdentity({ harness: session.harness ?? "codex-desktop", provider_session_id: session.provider_session_id ?? null, session_id: session.session_id })]));
532
+ const rawDeltas = usageDeltas(selectedRows);
533
+ const deltas = rawDeltas.map(({ session_id, ...delta }) => ({ ...delta, session: publicSessions.get(session_id) ?? null }));
504
534
  const segments = context.db.all(`SELECT id, session_id, run_id, protocol_id, started_at, ended_at, cwd, tool_name
505
535
  FROM flow_session_segments WHERE project_id = ? AND run_id = ? ORDER BY started_at, id`, [input.projectId, input.runId]);
506
536
  const supported = new Set(["session", "role", "aspect", "plan-item", "stage", "protocol"]);
507
537
  const groupBy = supported.has(input.groupBy) ? input.groupBy : "session";
508
538
  const groups = new Map();
509
- for (const delta of deltas) {
539
+ for (const delta of rawDeltas) {
510
540
  const key = groupKey(delta, groupBy);
511
541
  const group = groups.get(key) ?? { tokens: emptyUsage(), snapshots: 0, statuses: {}, transition_buckets: 0 };
512
542
  group.snapshots += 1;
@@ -523,7 +553,9 @@ function usageReport(context, input, refresh) {
523
553
  run_id: input.runId,
524
554
  source: { kind: "codex_transcript_v1", parser_version: 1, refreshed_sessions: refreshed.length, checkpoint_source: refresh ? "refreshed" : "stored" },
525
555
  sessions: selectedSessions.map((session) => ({
526
- session_id: session.session_id, parent_session_id: session.parent_session_id ?? null, role: session.role ?? null,
556
+ session: publicSessionIdentity({ harness: session.harness ?? "codex-desktop", provider_session_id: session.provider_session_id ?? null, session_id: session.session_id }),
557
+ ...(session.parent_session_id ? { parent_session: publicSessions.get(session.parent_session_id) ?? null } : {}),
558
+ role: session.role ?? null,
527
559
  aspect_id: session.aspect_id ?? null, plan_item_id: session.plan_item_id ?? null, session_kind: session.session_kind ?? null
528
560
  })),
529
561
  groups: [...groups.entries()].map(([key, group]) => ({ key, ...group, tokens: outputUsageTotals(group.tokens) })),
@@ -531,7 +563,7 @@ function usageReport(context, input, refresh) {
531
563
  tool_calls: toolCallsForSessions(sessions),
532
564
  segments: segments.map((segment) => ({
533
565
  segment_id: segment.id,
534
- session_id: segment.session_id,
566
+ session: publicSessions.get(segment.session_id) ?? null,
535
567
  run_id: segment.run_id,
536
568
  protocol_id: segment.protocol_id,
537
569
  started_at: segment.started_at,
@@ -544,7 +576,7 @@ function usageReport(context, input, refresh) {
544
576
  function toolCallsForSessions(sessions) {
545
577
  const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
546
578
  for (const session of sessions) {
547
- const parsed = readToolCalls(session.transcript_path, session.session_id);
579
+ const parsed = readToolCalls(session.transcript_path, nativeSessionId(session));
548
580
  if (!parsed) {
549
581
  result.unavailable_sessions.push(session.session_id);
550
582
  continue;
@@ -603,7 +635,7 @@ function readToolCalls(transcriptPath, expectedSessionId) {
603
635
  function segmentUsage(context, session, segment) {
604
636
  if (!session)
605
637
  return { status: "unavailable", diagnostic: "session_record_missing" };
606
- const result = readCodexTranscriptWindow(session.transcript_path, session.session_id, segment.started_at, segment.ended_at ?? context.now());
638
+ const result = readCodexTranscriptWindow(session.transcript_path, nativeSessionId(session), segment.started_at, segment.ended_at ?? context.now());
607
639
  return result.counter ? { status: result.status, tokens: result.counter.usage, token_event_at: result.counter.token_event_at } : { status: result.status, diagnostic: result.diagnostic ?? null };
608
640
  }
609
641
  function readCodexTranscript(transcriptPath, expectedSessionId) {
@@ -10,14 +10,15 @@ import { validateSchema } from "./schema-validation.js";
10
10
  import { flowCommand } from "./stage-pause.js";
11
11
  import { assertStageStartHookEvent } from "./hooks.js";
12
12
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
13
- import { addVnextCodeRepair, writeProtocolFlowStatus } from "./vnext-code.js";
14
- import { finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures } from "./code-checks.js";
13
+ import { addVnextCodeRepair } from "./vnext-code.js";
14
+ import { finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, 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";
18
18
  import { applyExternalStageContext } from "./stage-context.js";
19
19
  import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
20
20
  import { ensureVnextMergeRequest } from "./vnext-merge.js";
21
+ import { validateVnextCodeHandoff } from "./vnext-plan.js";
21
22
  const stage = "code-review";
22
23
  const stageDir = vnextStageDirectory(stage);
23
24
  const baselineAspects = ["goal_traceability", "coding_standards_design_review", "verification_evidence_review"];
@@ -46,8 +47,10 @@ export function startVnextCodeReview(context, input) {
46
47
  const orchestration = readFanoutDescriptor(root);
47
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")) } };
48
49
  }
49
- const mode = effectiveMode(run, changedPaths(run.workspace_root));
50
+ const mode = effectiveMode(run, acceptedCodeChangedPaths(home));
50
51
  const promptPath = path.join(root, "stage-prompt.md");
52
+ const reviewContextPath = path.join(root, "review-context.json");
53
+ fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", mode, workspace_fingerprint: workspaceFingerprint(run.workspace_root), code_report_sha256: sha256File(path.join(home, "05-code", "stage-report.json")) }, null, 2)}\n`);
51
54
  if (mode === "off") {
52
55
  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`;
53
56
  fs.writeFileSync(promptPath, prompt);
@@ -96,6 +99,8 @@ export function addVnextCodeReviewRepair(context, input) {
96
99
  if (unknown.length)
97
100
  throw new AppError("review_check_reference_unknown", "CODE-REVIEW finding cites a check absent from the accepted CODE handoff", 2, { check_refs: unknown });
98
101
  const reviewChecks = requestedCheckRefs.map((ref) => checks.get(ref)).filter((check) => check.run_at !== "external");
102
+ if (!reviewChecks.length)
103
+ throw new AppError("review_repair_executable_check_required", "CODE-REVIEW repair requires at least one executable causal check; external-only evidence cannot validate a repair Work", 2, { finding_ids: input.findingIds, check_refs: requestedCheckRefs });
99
104
  return addVnextCodeRepair(context, {
100
105
  projectRoot,
101
106
  runId: run.id,
@@ -113,7 +118,15 @@ export async function finishVnextCodeReview(context, input) {
113
118
  const run = requireRun(context, project.id, input.runId);
114
119
  const home = requireHome(run);
115
120
  const root = path.join(home, stageDir);
116
- const mode = effectiveMode(run, changedPaths(run.workspace_root));
121
+ const reviewContext = readJson(path.join(root, "review-context.json"));
122
+ const mode = reviewContext.mode;
123
+ if (!mode)
124
+ throw new AppError("runtime_missing", "CODE-REVIEW effective mode was not frozen at stage start", 1);
125
+ if (reviewContext.code_report_sha256 !== sha256File(path.join(home, "05-code", "stage-report.json")))
126
+ throw new AppError("code_review_input_changed", "Accepted CODE report changed after CODE-REVIEW start", 2);
127
+ const handoffFailures = validateVnextCodeHandoff(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home });
128
+ if (handoffFailures.length)
129
+ throw new AppError("validation", "CODE-REVIEW handoff no longer matches the accepted PLAN", 2, { errors: handoffFailures });
117
130
  const reportPath = path.join(root, "stage-report.json");
118
131
  if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
119
132
  const report = readJson(reportPath);
@@ -157,10 +170,10 @@ export async function finishVnextCodeReview(context, input) {
157
170
  const outcome = decision.findings.some((item) => item.disposition === "defer") ? "accepted_with_DEF" : "accepted";
158
171
  const repairs = reviewRepairWorks(context, project.id, run.id, root);
159
172
  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 : []; }));
160
- const unchangedFailures = repairs.length ? unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks }) : [];
173
+ const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks });
161
174
  if (unchangedFailures.length)
162
175
  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 });
163
- const receipts = repairs.length ? 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 } : {}) }) : [];
176
+ 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 } : {}) });
164
177
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
165
178
  if (failed.length)
166
179
  throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, {
@@ -174,8 +187,11 @@ export async function finishVnextCodeReview(context, input) {
174
187
  const nextAction = stopTarget === "merge_completed" ? "start_merge" : "code_review_completed";
175
188
  const now = context.now();
176
189
  const startedAt = stageStartedAt(run, now);
177
- writeProtocolFlowStatus(run.workspace_root, home, run.id, nextAction === "start_merge" ? "CODE-REVIEW complete; MERGE is queued." : "CODE-REVIEW complete; this RUN reached its configured terminal boundary.");
178
- const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage, generated_at: now, verdict: "done", semantic: { result: `Independent CODE review completed: ${reviewers.length} reviewer Work(s), ${repairs.length} repair Work(s).`, acceptance: ["all_selected_review_groups_completed", "all_material_findings_classified", ...(repairs.length ? ["all_accepted_repairs_completed", "post_repair_final_gate_passed"] : [])], run_changed_files: changedPaths(run.workspace_root), checks: receipts.map((receipt) => receipt.command), evidence: [...reviewers.flatMap((work) => readJsonString(work.result).findings.flatMap((finding) => finding.evidence_refs)), ...receipts.map((receipt) => runRef(run.id, home, receipt.receipt_path))], next_action: nextAction, code_review: { mode, reviewer_work_ids: reviewers.map((work) => work.work_id), repair_work_ids: repairs.map((work) => work.work_id), decision } }, mechanical: { started_at: startedAt, finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(startedAt)), git: gitFacts(run.workspace_root), session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json` }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } };
190
+ const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage, generated_at: now, verdict: "done", semantic: { result: `Independent CODE review completed: ${reviewers.length} reviewer Work(s), ${repairs.length} repair Work(s).`, acceptance: ["all_selected_review_groups_completed", "all_material_findings_classified", ...(repairs.length ? ["all_accepted_repairs_completed"] : []), "current_final_gate_passed"], run_changed_files: changedPaths(run.workspace_root), checks: receipts.map((receipt) => receipt.command), evidence: [...reviewers.flatMap((work) => readJsonString(work.result).findings.flatMap((finding) => finding.evidence_refs)), ...receipts.map((receipt) => runRef(run.id, home, receipt.receipt_path))], next_action: nextAction, code_review: { mode, reviewer_work_ids: reviewers.map((work) => work.work_id), repair_work_ids: repairs.map((work) => work.work_id), decision } }, mechanical: { started_at: startedAt, finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(startedAt)), git: gitFacts(run.workspace_root), session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json` }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } };
191
+ // The request must exist before CODE-REVIEW becomes terminal. Otherwise a
192
+ // merge-gate error creates an irrecoverable `done` report with no MRG-*.
193
+ const acceptedPaths = changedPaths(run.workspace_root);
194
+ const mergeRequest = nextAction === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths }) : null;
179
195
  writeReport(root, report);
180
196
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
181
197
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
@@ -186,7 +202,6 @@ export async function finishVnextCodeReview(context, input) {
186
202
  completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
187
203
  }
188
204
  refreshRunWorkProjection(context, project.id, run.id);
189
- const mergeRequest = nextAction === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id }) : null;
190
205
  return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {}) };
191
206
  }
192
207
  function reviewGroups(run, home, mode) {
@@ -202,7 +217,7 @@ function reviewGroups(run, home, mode) {
202
217
  const chunk = mode === "deep" ? 1 : 3;
203
218
  return Array.from({ length: Math.ceil(all.length / chunk) }, (_, index) => ({ key: `group-${index + 1}`, aspect_ids: all.slice(index * chunk, (index + 1) * chunk) }));
204
219
  }
205
- function reviewerTask(home, group) { return `Read-only independent CODE review for ${group.key}. Assess every assigned aspect exactly once: ${group.aspect_ids.join(", ")}. Read the accepted PLAN, ${path.join(home, "05-code", "stage-report.json")}, and any files needed under the bounded CODE evidence root ${path.join(home, "05-code")}. Report only material, evidenced defects: a violated obligation or rule, direct evidence, impact, and minimum required outcome. Do not report taste, cosmetics, or untargeted refactoring. Finding ids must use the unique prefix FIND-${group.key}-. Return dd-flow/code-review-result@1.`; }
220
+ function reviewerTask(home, group) { return `Read-only independent CODE review for ${group.key}. Assess every assigned aspect exactly once: ${group.aspect_ids.join(", ")}. Read the accepted PLAN, ${path.join(home, "05-code", "stage-report.json")}, and any files needed under the bounded CODE evidence root ${path.join(home, "05-code")}. Report only material, evidenced defects: a violated obligation or rule, direct evidence, impact, and minimum required outcome. Do not report taste, cosmetics, or untargeted refactoring. Use local finding ids FIND-001, FIND-002, and so on; dd-flow adds the Work-qualified canonical reference. Return dd-flow/code-review-result@1.`; }
206
221
  function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); const checks = acceptedCodeChecks(context, input.run.project_id, input.run.id).map(({ id, purpose }) => ({ id, purpose })); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_home_path, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. For every disposition fix, check_refs is mandatory: choose the one or more causal checks from the accepted list below that the repair must rerun. Do not guess a CHK id or copy every check. The first successful Finish freezes this decision and creates one repair Work when needed. Run it, then call the same Finish command again. Review is not repeated.", "If the post-repair aggregate gate fails, do not stop after `code_review_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use the returned repair command with the failed receipt, a relevant completed origin Work ID, and a concise repair objective; then stop so the runner can dispatch the new repair. Do not edit invisibly in the root orchestrator.", `Accepted repair checks: ${JSON.stringify(checks)}`, "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", check_refs: ["CHK-CAUSAL-CHECK only when disposition is fix"], def_id: "DEF-0001 only for an allowed P2 deferral", duplicate_of: "canonical finding_ref only for duplicate" }] }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n"); }
207
222
  function canonicalReviewEvidence(decision, reviewers) {
208
223
  const known = new Set(canonicalCodeFindings(reviewers).map((item) => item.finding_ref));
@@ -278,8 +293,20 @@ function validateDecision(context, input) {
278
293
  if (!known.has(item.finding_ref))
279
294
  throw new AppError("review_evidence_invalid", "Decision references an unknown reviewer finding", 2, { finding_ref: item.finding_ref });
280
295
  }
281
- function validateReviewerResult(context, work, run) { if (!work.result)
282
- throw new AppError("review_evidence_invalid", "Reviewer Work has no result", 2, { work_id: work.work_id }); const file = path.join(requireHome(run), stageDir, `${work.work_id}.result.json`); fs.writeFileSync(file, work.result); validateSchema({ schemaName: "code-review-result", file, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id }); }
296
+ function validateReviewerResult(_context, work, _run) {
297
+ if (!work.result)
298
+ throw new AppError("review_evidence_invalid", "Reviewer Work has no result", 2, { work_id: work.work_id });
299
+ // Schema, group identity and portable evidence references were validated
300
+ // before this Work was accepted. This fan-in guard only checks semantic
301
+ // relationships that depend on the aggregate review decision.
302
+ const result = readJsonString(work.result);
303
+ if (result.verdict === "blocked")
304
+ throw new AppError("review_blocked", "A blocked reviewer result cannot be accepted as completed review evidence", 2, { work_id: work.work_id });
305
+ if (result.verdict === "pass" && result.findings.length)
306
+ throw new AppError("review_evidence_invalid", "A passing reviewer result cannot contain material findings", 2, { work_id: work.work_id });
307
+ if (result.aspects.some((aspect) => aspect.verdict === "blocked"))
308
+ throw new AppError("review_blocked", "A blocked review aspect must be resolved before CODE-REVIEW can finish", 2, { work_id: work.work_id });
309
+ }
283
310
  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"); }); }
284
311
  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); }
285
312
  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 : []; }); }
@@ -311,6 +338,13 @@ function reviewCheckRefs(work) { const repair = readJsonString(work.payload_json
311
338
  return []; const refs = repair.review_check_refs; return Array.isArray(refs) ? refs.filter((value) => typeof value === "string") : []; }
312
339
  function canonicalCodeFindings(works) { return works.flatMap((work) => readJsonString(work.result).findings.map((finding) => ({ finding_ref: `${work.work_id}/${finding.finding_id}`, finding }))); }
313
340
  function effectiveMode(run, paths) { const index = JSON.parse(run.index_json); const requested = index.settings?.code_review?.mode ?? "auto"; return requested === "auto" ? (paths.length ? "standard" : "off") : requested; }
341
+ function acceptedCodeChangedPaths(home) { try {
342
+ const report = readJson(path.join(home, "05-code", "stage-report.json"));
343
+ return Array.isArray(report.semantic?.changed_files) ? report.semantic.changed_files.filter((item) => typeof item === "string") : [];
344
+ }
345
+ catch {
346
+ return [];
347
+ } }
314
348
  function executionStopTarget(run) { return JSON.parse(run.index_json).execution_profile?.settings?.stop_target ?? "code_review_completed"; }
315
349
  function changedPaths(workspaceRoot) { try {
316
350
  return execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd: workspaceRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split(/\r?\n/).filter(Boolean).map((line) => line.slice(3).replace(/^.* -> /, ""));
@@ -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, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
7
+ import { aggregateCheckDeclarations, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, 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";
@@ -135,8 +135,22 @@ export async function finishVnextCode(context, input) {
135
135
  }
136
136
  // Reject a malformed semantic receipt before running the expensive gate.
137
137
  const verification = verificationForFinish(context, { file: input.verificationFile, projectRoot: run.workspace_root, runId: run.id });
138
- if (verification.verdict !== "passed" || verification.unresolved.length > 0) {
139
- throw new AppError("code_verification_incomplete", "CODE verification is not accepted; create or complete the necessary repair Work first", 2, { verification });
138
+ if (verification.verdict === "blocked") {
139
+ return { ok: true, run_id: run.id, stage, outcome: "blocked", verification, instruction: "CODE remains running. Resolve the stated external or user-input blocker in this same coordinator session, update code-verification.json, then invoke this same stage finish command again." };
140
+ }
141
+ if (verification.verdict === "passed" && verification.unresolved.length > 0) {
142
+ throw new AppError("verification_contradictory", "A passed CODE verification cannot contain unresolved obligations", 2, { unresolved: verification.unresolved });
143
+ }
144
+ if (verification.verdict === "needs_repair") {
145
+ const repair = addVnextCodeRepair(context, {
146
+ projectRoot,
147
+ runId: run.id,
148
+ originWorkIds: works.filter((work) => work.status === "completed").map((work) => work.work_id),
149
+ semanticUnresolved: verification.unresolved.length ? verification.unresolved : [verification.summary],
150
+ verificationPath: input.verificationFile,
151
+ objective: verification.summary
152
+ });
153
+ 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." };
140
154
  }
141
155
  const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
142
156
  const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks });
@@ -174,18 +188,26 @@ export async function finishVnextCode(context, input) {
174
188
  }
175
189
  const allReceipts = checkReceipts(context, { projectId: project.id, runId: run.id });
176
190
  const finalReceipts = latestReceiptsByCommand(allReceipts);
191
+ const projectedVerification = verificationProjection(works, finalReceipts);
192
+ const unresolvedAcceptance = (projectedVerification.acceptance ?? []).filter((item) => item.status === "unresolved");
193
+ if (unresolvedAcceptance.length)
194
+ throw new AppError("code_acceptance_unresolved", "CODE cannot finish until every due acceptance criterion has current checks and evidence", 2, { unresolved: unresolvedAcceptance });
177
195
  const now = context.now();
178
196
  const timing = stageTiming(home, stage, now);
179
- const changedPaths = [...new Set(works.flatMap((work) => resultPaths(work.result)))];
197
+ const reportedPaths = [...new Set(works.flatMap((work) => resultPaths(work.result)))];
198
+ const observedChangedPaths = workspaceChangedPaths(run.workspace_root);
199
+ const changedPaths = observedChangedPaths ?? reportedPaths;
200
+ const missingReportedPaths = observedChangedPaths === null ? [] : reportedPaths.filter((item) => !observedChangedPaths.includes(item));
201
+ if (missingReportedPaths.length)
202
+ throw new AppError("changed_path_not_materialized", "CODE Work reported paths that are not changed in the accepted workspace", 2, { paths: missingReportedPaths });
180
203
  const next = nextAction(run, changedPaths);
181
- writeProtocolFlowStatus(run.workspace_root, home, run.id, next === "start_code_review" ? "CODE complete; CODE-REVIEW is next." : next === "start_merge" ? "CODE complete; MERGE is queued." : "CODE complete; this RUN reached its configured terminal boundary.");
182
204
  const report = {
183
205
  schema_id: "dd-flow/stage-report@1",
184
206
  run_id: run.id,
185
207
  stage,
186
208
  generated_at: now,
187
209
  verdict: "done",
188
- verification: verificationProjection(works, finalReceipts),
210
+ verification: projectedVerification,
189
211
  semantic: {
190
212
  result: `Completed ${works.length} CODE Work item${works.length === 1 ? "" : "s"}; ${finalReceipts.filter((receipt) => receipt.status === "passed").length} current final check receipt${finalReceipts.length === 1 ? "" : "s"} passed.`,
191
213
  acceptance: [...expected],
@@ -213,6 +235,10 @@ export async function finishVnextCode(context, input) {
213
235
  artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html", summary: "stage-report.md" },
214
236
  validation: { permission_scope: "known_targets_only", memory_bank_scope: "changed_files_and_links_only", status: "passed" }
215
237
  };
238
+ // Creating the MERGE request is part of accepting this terminal handoff.
239
+ // Do it before materialising a terminal stage report or changing RUN state,
240
+ // so an invalid merge policy leaves CODE safely running and retryable.
241
+ const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
216
242
  writeReport(root, report);
217
243
  completeFlowRunStage(context, {
218
244
  projectRoot,
@@ -237,7 +263,6 @@ export async function finishVnextCode(context, input) {
237
263
  else {
238
264
  advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
239
265
  }
240
- const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id }) : null;
241
266
  return {
242
267
  ok: true,
243
268
  run_id: run.id,
@@ -255,8 +280,8 @@ export function addVnextCodeRepair(context, input) {
255
280
  const home = requireHome(run);
256
281
  if (!input.objective.trim())
257
282
  throw new AppError("validation", "Repair objective must not be empty", 2);
258
- if (!input.checkReceiptId && !input.reviewFindingIds?.length) {
259
- throw new AppError("validation", "Repair needs a failed check receipt or CODE-REVIEW finding", 2);
283
+ if (!input.checkReceiptId && !input.reviewFindingIds?.length && !input.semanticUnresolved?.length) {
284
+ throw new AppError("validation", "Repair needs failed check evidence, a CODE-REVIEW finding, or an unresolved CODE verification", 2);
260
285
  }
261
286
  const receipt = input.checkReceiptId ? context.db.get("SELECT id, declaration_id, run_id, command, status, receipt_path, stdout_path, stderr_path FROM check_receipts WHERE project_id = ? AND id = ?", [
262
287
  project.id,
@@ -269,11 +294,19 @@ export function addVnextCodeRepair(context, input) {
269
294
  }
270
295
  const origins = [...new Set(input.originWorkIds)].map((id) => requireCodeWork(context, project.id, run.id, id));
271
296
  const packets = origins.map((work) => packet(work));
272
- const invariantPackets = receipt
297
+ const invariantPackets = receipt || input.semanticUnresolved?.length
273
298
  ? codeWorks(context, project.id, run.id).map((work) => packet(work)).filter((value) => !value.repair)
274
299
  : packets;
275
300
  const first = packets[0];
276
- const declaredChecks = finalCodeCheckDeclarations(run.workspace_root, packets.flatMap((value) => value.checks));
301
+ // A later repair may be caused by any accepted aggregate declaration, not
302
+ // only the checks copied into its immediate repair parent. Otherwise a
303
+ // repair that fixes one failed aggregate check can make a second, already
304
+ // declared aggregate failure impossible to repair. The immutable source
305
+ // of repair eligibility is the original CODE graph.
306
+ const declaredChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
307
+ .map((work) => packet(work))
308
+ .filter((value) => !value.repair)
309
+ .flatMap((value) => value.checks));
277
310
  const failedCheck = receipt ? declaredChecks.find((check) => check.id === receipt.declaration_id) : undefined;
278
311
  if (receipt && !failedCheck) {
279
312
  throw new AppError("repair_check_declaration_missing", "Repair receipt is not backed by an accepted CODE check declaration", 2, {
@@ -281,7 +314,8 @@ export function addVnextCodeRepair(context, input) {
281
314
  declaration_id: receipt.declaration_id
282
315
  });
283
316
  }
284
- const key = input.reviewFindingIds?.length ? "code-review-repair" : "code-gate-repair";
317
+ const semanticRepair = Boolean(input.semanticUnresolved?.length);
318
+ const key = input.reviewFindingIds?.length ? "code-review-repair" : semanticRepair ? "code-verification-repair" : "code-gate-repair";
285
319
  const receiptWriteScope = receipt ? receiptRepairPaths(run.workspace_root, receipt) : [];
286
320
  const repair = {
287
321
  schema_id: "dd-flow/code-work-packet@5",
@@ -291,22 +325,23 @@ export function addVnextCodeRepair(context, input) {
291
325
  repair: {
292
326
  origin_work_ids: origins.map((work) => work.work_id),
293
327
  ...(receipt ? { check_receipt_id: receipt.id, failure_receipt_path: receipt.receipt_path } : {}),
294
- ...(input.reviewFindingIds?.length ? { review_finding_ids: unique(input.reviewFindingIds), review_evidence_refs: unique(input.reviewEvidenceRefs ?? []), review_check_refs: unique(input.reviewCheckRefs ?? (input.reviewChecks ?? []).map((check) => check.id)) } : {})
328
+ ...(input.reviewFindingIds?.length ? { review_finding_ids: unique(input.reviewFindingIds), review_evidence_refs: unique(input.reviewEvidenceRefs ?? []), review_check_refs: unique(input.reviewCheckRefs ?? (input.reviewChecks ?? []).map((check) => check.id)) } : {}),
329
+ ...(semanticRepair ? { semantic_unresolved: unique(input.semanticUnresolved ?? []), verification_path: input.verificationPath } : {})
295
330
  },
296
331
  task: input.objective,
297
332
  semantic_spine: {
298
- user_outcome: receipt ? `Restore the accepted behavior after aggregate failure: ${input.objective}` : `Resolve accepted CODE-REVIEW finding: ${input.objective}`,
333
+ user_outcome: receipt ? `Restore the accepted behavior after aggregate failure: ${input.objective}` : semanticRepair ? `Close the unresolved CODE verification: ${input.objective}` : `Resolve accepted CODE-REVIEW finding: ${input.objective}`,
299
334
  component_responsibility: "Diagnose and repair the failed accepted CODE result without changing unrelated behavior.",
300
335
  must_preserve: unique(invariantPackets.flatMap((value) => value.semantic_spine.must_preserve)),
301
336
  non_goals: unique(invariantPackets.flatMap((value) => value.semantic_spine.non_goals)),
302
- acceptance_contribution: receipt ? `Make failed check pass: ${receipt.command}` : `Resolve CODE-REVIEW finding(s): ${input.reviewFindingIds.join(", ")}`
337
+ acceptance_contribution: receipt ? `Make failed check pass: ${receipt.command}` : semanticRepair ? `Resolve CODE verification gap(s): ${input.semanticUnresolved.join("; ")}` : `Resolve CODE-REVIEW finding(s): ${input.reviewFindingIds.join(", ")}`
303
338
  },
304
339
  requirements: uniqueBy(invariantPackets.flatMap((value) => value.requirements), (value) => value.id),
305
340
  acceptance: uniqueBy(invariantPackets.flatMap((value) => value.acceptance), (value) => JSON.stringify(value)),
306
341
  // A CODE-REVIEW repair changes delivered code/evidence, never the
307
342
  // already accepted PLAN or its ownership declaration.
308
343
  document_updates: [],
309
- required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewEvidenceRefs ?? []), ...packets.flatMap((value) => value.required_read)]),
344
+ required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewEvidenceRefs ?? []), ...(semanticRepair && input.verificationPath ? [input.verificationPath] : []), ...packets.flatMap((value) => value.required_read)]),
310
345
  discovery_boundary: unique(packets.flatMap((value) => value.discovery_boundary)),
311
346
  // These are collision-avoidance hints. Receipt paths enrich the coordinator
312
347
  // picture but never restrict the repair's project-local edits.
@@ -314,7 +349,7 @@ export function addVnextCodeRepair(context, input) {
314
349
  // Receipts record the resolved shell command. A repair must retain the
315
350
  // accepted declaration (including its immutable @check alias) and only
316
351
  // change when it runs, so work finish can validate it again.
317
- checks: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}) }),
352
+ checks: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}), ...(semanticRepair ? { semanticChecks: declaredChecks } : {}) }),
318
353
  provides_checks: [],
319
354
  stop_conditions: unique([
320
355
  ...invariantPackets.flatMap((value) => value.stop_conditions),
@@ -339,16 +374,18 @@ export function addVnextCodeRepair(context, input) {
339
374
  type: "code_repair_created",
340
375
  work_id: id,
341
376
  origin_work_ids: input.originWorkIds,
342
- ...(receipt ? { check_receipt_id: receipt.id } : { review_finding_ids: input.reviewFindingIds })
377
+ ...(receipt ? { check_receipt_id: receipt.id } : input.reviewFindingIds?.length ? { review_finding_ids: input.reviewFindingIds } : { semantic_unresolved: input.semanticUnresolved })
343
378
  });
344
379
  refreshRunWorkProjection(context, project.id, run.id);
345
380
  return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
346
381
  }
347
- /** The repair Work runs only the check that motivated it; the stage retains the aggregate gate. */
382
+ /** Retain the causal declaration for worker context; run_at keeps aggregate gates at stage scope. */
348
383
  export function selectRepairChecks(input) {
349
- const checks = input.failedCheck
350
- ? [{ ...input.failedCheck, purpose: "Re-run the failed accepted check.", run_at: "work" }]
351
- : (input.reviewChecks ?? []).map((check) => ({ ...check, purpose: `Prove the CODE-REVIEW repair: ${check.purpose}`, run_at: "work" }));
384
+ // Gate placement belongs to the accepted PLAN. `runCodeChecks` executes only
385
+ // work-scoped entries, while retaining the aggregate declaration tells the
386
+ // worker exactly what will be rerun by the coordinator.
387
+ const candidates = input.failedCheck ? [input.failedCheck] : input.reviewChecks ?? input.semanticChecks ?? [];
388
+ const checks = candidates.map((check) => ({ ...check, purpose: `${input.semanticChecks ? "Prove the CODE verification repair" : "Prove the CODE-REVIEW repair"}: ${check.purpose}` }));
352
389
  return uniqueBy(checks, (check) => check.id);
353
390
  }
354
391
  function receiptRepairPaths(workspaceRoot, receipt) {