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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -131,11 +132,12 @@ export function storedUsageForRun(context, input) {
131
132
  */
132
133
  export function recalculateRunUsage(context, input) {
133
134
  const observedAt = context.now();
134
- const runRow = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [input.projectId, input.runId]);
135
- const runCompletedAt = runRow ? terminalTimestamp(runRow.index_json) : null;
136
- const accountingAt = input.stage?.completedAt ?? runCompletedAt ?? observedAt;
135
+ // Provider completion, not the controller's later lifecycle write, is the
136
+ // accounting boundary. A final refresh must therefore see tokens emitted
137
+ // after `work finish` but before the agent's actual terminal event.
138
+ const accountingAt = observedAt;
137
139
  const links = context.db.all(`SELECT ws.id, ws.work_id, ws.session_id, ws.created_at, ws.completed_at,
138
- 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
139
141
  FROM work_sessions ws
140
142
  JOIN works w ON w.work_id = ws.work_id
141
143
  LEFT JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
@@ -153,31 +155,34 @@ export function recalculateRunUsage(context, input) {
153
155
  const settledSessions = settledRunSessions(context, links, observedAt);
154
156
  const unsettledSessions = [...new Set(links.map((link) => link.session_id))].filter((sessionId) => !settledSessions.some((session) => session.session_id === sessionId));
155
157
  const usageFinal = final && unsettledSessions.length === 0;
156
- context.db.exec("BEGIN IMMEDIATE");
157
- try {
158
- context.db.run("DELETE FROM usage WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId]);
159
- for (const row of rows) {
160
- 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)
161
165
  VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'windowed_work_session', 1, ?, ?, ?)`, [
162
- `USG-${crypto.randomUUID()}`, input.projectId, input.runId, row.session_id, usageFinal ? "final" : "provisional", input.stage?.name ?? null, row.observed_at,
163
- row.tokens?.total_tokens ?? null, row.tokens?.input_tokens ?? null, row.tokens?.cache_read_input_tokens ?? null,
164
- row.tokens?.cache_read_input_tokens ?? null, row.tokens?.cache_write_input_tokens ?? null, row.tokens?.uncached_input_tokens ?? null,
165
- 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,
166
- row.status, row.diagnostic, observedAt
167
- ]);
168
- }
169
- for (const session of settledSessions) {
170
- 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
171
175
  SET status = 'stopped', stop_reason = 'provider_turn_complete', updated_at = ?, stopped_at = ?
172
176
  WHERE project_id = ? AND run_id = ? AND session_id = ?
173
177
  AND status = 'idle'
174
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;
175
185
  }
176
- context.db.exec("COMMIT");
177
- }
178
- catch (error) {
179
- context.db.exec("ROLLBACK");
180
- throw error;
181
186
  }
182
187
  return {
183
188
  ok: true,
@@ -186,16 +191,28 @@ export function recalculateRunUsage(context, input) {
186
191
  status: usageFinal ? "final" : "provisional",
187
192
  scope: input.stage ? { kind: "stage", stage: input.stage.name, started_at: input.stage.startedAt, completed_at: input.stage.completedAt } : { kind: "run" },
188
193
  observed_at: observedAt,
194
+ persisted: persistRunProjection,
189
195
  totals: outputUsageTotals(totals),
190
- sessions: rows,
196
+ sessions: publicUsageRows(context, input.projectId, rows),
191
197
  session_reconciliation: {
192
- stopped: settledSessions.map((session) => session.session_id),
193
- unsettled: unsettledSessions
198
+ stopped: publicSessionIdentities(context, input.projectId, settledSessions.map((session) => session.session_id)),
199
+ unsettled: publicSessionIdentities(context, input.projectId, unsettledSessions)
194
200
  },
195
201
  tool_calls: toolCallsForLinks(context, links, accountingAt),
196
202
  coverage: rows.reduce((coverage, row) => ({ ...coverage, [row.status]: (coverage[row.status] ?? 0) + 1 }), {})
197
203
  };
198
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
+ }
199
216
  function overlapsStage(link, stage, observedAt) {
200
217
  const linkStart = Date.parse(link.created_at);
201
218
  const linkEnd = Date.parse(link.completed_at ?? observedAt);
@@ -211,6 +228,17 @@ function clipToStage(link, stage, observedAt) {
211
228
  const completedAt = Date.parse(naturalEnd) > Date.parse(stageEnd) ? stageEnd : naturalEnd;
212
229
  return { ...link, created_at: startedAt, completed_at: completedAt };
213
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
+ }
214
242
  function toolCallsForLinks(context, links, observedAt) {
215
243
  const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
216
244
  const sessions = new Map();
@@ -220,7 +248,7 @@ function toolCallsForLinks(context, links, observedAt) {
220
248
  const first = windows[0];
221
249
  const parsed = isExternalHarness(first.harness)
222
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)
223
- : readToolCallsForWindows(first.transcript_path, sessionId, windows, observedAt);
251
+ : readToolCallsForWindows(first.transcript_path, nativeSessionId(first), windows, observedAt);
224
252
  if (!parsed) {
225
253
  result.unavailable_sessions.push(sessionId);
226
254
  continue;
@@ -356,15 +384,15 @@ function usageRowsForPhysicalSessions(context, links, observedAt, stage) {
356
384
  sessions.set(link.session_id, [...(sessions.get(link.session_id) ?? []), link]);
357
385
  const physicalSessions = [...sessions.values()].filter((sessionLinks) => {
358
386
  const link = sessionLinks[0];
359
- if (!link.parent_session_id)
387
+ if (!link.provider_parent_session_id)
360
388
  return true;
361
- 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);
362
390
  return !parent || !hasInclusiveUsageSnapshot(context, parent.project_id, parent.harness, parent.provider_session_id, observedAt);
363
391
  });
364
392
  return physicalSessions.map((sessionLinks) => {
365
393
  const link = sessionLinks[0];
366
394
  const startedAt = stage?.startedAt ?? sessionLinks.map((item) => item.created_at).sort()[0];
367
- const completedTurn = stage ? null : completedProviderTurn(link.transcript_path, link.session_id);
395
+ const completedTurn = stage ? null : completedProviderTurn(link.transcript_path, nativeSessionId(link));
368
396
  const naturalEnd = isExternalHarness(link.harness)
369
397
  ? observedAt
370
398
  : completedTurn?.completed_at ?? sessionLinks.map((item) => item.completed_at ?? observedAt).sort().at(-1);
@@ -372,7 +400,7 @@ function usageRowsForPhysicalSessions(context, links, observedAt, stage) {
372
400
  const harnessUsage = isExternalHarness(link.harness)
373
401
  ? readHarnessUsageWindow(context, link.harness, link.project_id, link.provider_session_id, startedAt, endedAt, observedAt)
374
402
  : null;
375
- const parsed = harnessUsage ?? readCodexTranscriptWindow(link.transcript_path, link.session_id, startedAt, endedAt);
403
+ const parsed = harnessUsage ?? readCodexTranscriptWindow(link.transcript_path, nativeSessionId(link), startedAt, endedAt);
376
404
  return {
377
405
  work_session_id: link.id,
378
406
  work_id: link.work_id,
@@ -427,15 +455,6 @@ function readHarnessUsageWindow(context, harness, projectId, providerSessionId,
427
455
  }
428
456
  };
429
457
  }
430
- function terminalTimestamp(indexJson) {
431
- try {
432
- const index = JSON.parse(indexJson);
433
- return ["done", "blocked", "cancelled", "failed"].includes(index.status ?? "") && typeof index.completed_at === "string" ? index.completed_at : null;
434
- }
435
- catch {
436
- return null;
437
- }
438
- }
439
458
  function settledRunSessions(context, links, observedAt) {
440
459
  const sessions = new Map();
441
460
  for (const link of links)
@@ -444,16 +463,17 @@ function settledRunSessions(context, links, observedAt) {
444
463
  if (sessionLinks.some((link) => !link.completed_at))
445
464
  return [];
446
465
  const link = sessionLinks[0];
447
- const latestWorkFinish = sessionLinks.map((item) => Date.parse(item.completed_at)).filter(Number.isFinite).sort((left, right) => right - left)[0];
448
- if (isExternalHarness(link.harness) && link.provider_session_id && latestWorkFinish !== undefined) {
466
+ const sessionStartedAt = sessionLinks.map((item) => item.created_at).sort()[0];
467
+ const workCompletedAt = sessionLinks.map((item) => item.completed_at).sort().at(-1);
468
+ if (isExternalHarness(link.harness) && link.provider_session_id) {
449
469
  const snapshot = context.db.get(`SELECT observed_at FROM harness_usage_snapshots
450
470
  WHERE project_id = ? AND harness = ? AND provider_session_id = ?
451
- AND observed_at >= ? AND observed_at <= ?
452
- ORDER BY observed_at DESC, id DESC LIMIT 1`, [link.project_id, link.harness, link.provider_session_id, new Date(latestWorkFinish).toISOString(), observedAt]);
471
+ AND completeness = 'complete' AND observed_at >= ? AND observed_at <= ?
472
+ ORDER BY observed_at DESC, id DESC LIMIT 1`, [link.project_id, link.harness, link.provider_session_id, workCompletedAt, observedAt]);
453
473
  return snapshot ? [{ session_id: sessionId, completed_at: snapshot.observed_at }] : [];
454
474
  }
455
- const completed = completedProviderTurn(sessionLinks[0].transcript_path, sessionId);
456
- if (!completed || (latestWorkFinish !== undefined && Date.parse(completed.completed_at) < latestWorkFinish))
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))
457
477
  return [];
458
478
  return [{ session_id: sessionId, completed_at: completed.completed_at }];
459
479
  });
@@ -492,10 +512,10 @@ function completedProviderTurn(transcriptPath, expectedSessionId) {
492
512
  }
493
513
  }
494
514
  function usageReport(context, input, refresh) {
495
- 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
496
516
  FROM sessions WHERE project_id = ? AND run_id = ?
497
517
  UNION
498
- 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
499
519
  FROM flow_session_segments s JOIN sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
500
520
  WHERE s.project_id = ? AND s.run_id = ?`, [input.runId, input.projectId, input.runId, input.runId, input.projectId, input.runId]);
501
521
  const selectedSessions = input.sessionId ? sessions.filter((session) => session.session_id === input.sessionId) : sessions;
@@ -508,13 +528,15 @@ function usageReport(context, input, refresh) {
508
528
  LEFT JOIN sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
509
529
  WHERE s.project_id = ? AND s.run_id = ? ORDER BY s.session_id, s.observed_at, s.id`, [input.projectId, input.runId]);
510
530
  const selectedRows = input.sessionId ? rows.filter((row) => row.session_id === input.sessionId) : rows;
511
- 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 }));
512
534
  const segments = context.db.all(`SELECT id, session_id, run_id, protocol_id, started_at, ended_at, cwd, tool_name
513
535
  FROM flow_session_segments WHERE project_id = ? AND run_id = ? ORDER BY started_at, id`, [input.projectId, input.runId]);
514
536
  const supported = new Set(["session", "role", "aspect", "plan-item", "stage", "protocol"]);
515
537
  const groupBy = supported.has(input.groupBy) ? input.groupBy : "session";
516
538
  const groups = new Map();
517
- for (const delta of deltas) {
539
+ for (const delta of rawDeltas) {
518
540
  const key = groupKey(delta, groupBy);
519
541
  const group = groups.get(key) ?? { tokens: emptyUsage(), snapshots: 0, statuses: {}, transition_buckets: 0 };
520
542
  group.snapshots += 1;
@@ -531,7 +553,9 @@ function usageReport(context, input, refresh) {
531
553
  run_id: input.runId,
532
554
  source: { kind: "codex_transcript_v1", parser_version: 1, refreshed_sessions: refreshed.length, checkpoint_source: refresh ? "refreshed" : "stored" },
533
555
  sessions: selectedSessions.map((session) => ({
534
- 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,
535
559
  aspect_id: session.aspect_id ?? null, plan_item_id: session.plan_item_id ?? null, session_kind: session.session_kind ?? null
536
560
  })),
537
561
  groups: [...groups.entries()].map(([key, group]) => ({ key, ...group, tokens: outputUsageTotals(group.tokens) })),
@@ -539,7 +563,7 @@ function usageReport(context, input, refresh) {
539
563
  tool_calls: toolCallsForSessions(sessions),
540
564
  segments: segments.map((segment) => ({
541
565
  segment_id: segment.id,
542
- session_id: segment.session_id,
566
+ session: publicSessions.get(segment.session_id) ?? null,
543
567
  run_id: segment.run_id,
544
568
  protocol_id: segment.protocol_id,
545
569
  started_at: segment.started_at,
@@ -552,7 +576,7 @@ function usageReport(context, input, refresh) {
552
576
  function toolCallsForSessions(sessions) {
553
577
  const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
554
578
  for (const session of sessions) {
555
- const parsed = readToolCalls(session.transcript_path, session.session_id);
579
+ const parsed = readToolCalls(session.transcript_path, nativeSessionId(session));
556
580
  if (!parsed) {
557
581
  result.unavailable_sessions.push(session.session_id);
558
582
  continue;
@@ -611,7 +635,7 @@ function readToolCalls(transcriptPath, expectedSessionId) {
611
635
  function segmentUsage(context, session, segment) {
612
636
  if (!session)
613
637
  return { status: "unavailable", diagnostic: "session_record_missing" };
614
- 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());
615
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 };
616
640
  }
617
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(/^.* -> /, ""));