@deksden-com/dd-flow-cli 0.4.0 → 0.4.2

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +25 -9
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +45 -29
  5. package/dist/cli/run-cli.js +229 -49
  6. package/dist/domain/entity-ids.js +4 -4
  7. package/dist/domain/flow-contract.js +502 -36
  8. package/dist/domain/validation.js +34 -0
  9. package/dist/protocol/local-files.js +8 -6
  10. package/dist/schemas/code-stage-report.schema.json +197 -2
  11. package/dist/schemas/flow-contract.schema.json +126 -0
  12. package/dist/schemas/flow-run-index-v3.schema.json +203 -0
  13. package/dist/schemas/flow-run-index.schema.json +22 -2
  14. package/dist/schemas/flow-run.schema.json +36 -0
  15. package/dist/schemas/merge-stage-report.schema.json +213 -2
  16. package/dist/schemas/plan-stage-report.schema.json +156 -2
  17. package/dist/schemas/release-impact.schema.json +16 -0
  18. package/dist/services/audit.js +3 -3
  19. package/dist/services/branch-context.js +17 -5
  20. package/dist/services/canon.js +0 -1
  21. package/dist/services/cleanup.js +6 -6
  22. package/dist/services/cli-operation-classifier.js +1 -1
  23. package/dist/services/compatibility-preflight.js +3 -75
  24. package/dist/services/dashboard.js +48 -11
  25. package/dist/services/engines.js +124 -13
  26. package/dist/services/hooks.js +6 -6
  27. package/dist/services/ids.js +40 -49
  28. package/dist/services/merge-queue.js +33 -26
  29. package/dist/services/merge-worker.js +2 -1
  30. package/dist/services/migrations.js +64 -0
  31. package/dist/services/plans.js +23 -16
  32. package/dist/services/projects.js +2 -2
  33. package/dist/services/prompts.js +322 -0
  34. package/dist/services/protocols.js +77 -46
  35. package/dist/services/run-projection.js +80 -0
  36. package/dist/services/runs.js +360 -22
  37. package/dist/services/schema-validation.js +35 -12
  38. package/dist/services/sessions.js +81 -3
  39. package/dist/services/status.js +32 -1
  40. package/dist/services/usage.js +233 -0
  41. package/dist/services/worktrees.js +24 -19
  42. package/dist/storage/database.js +223 -9
  43. package/package.json +1 -1
@@ -6,10 +6,19 @@ import { parseJsonObject } from "../shared/json.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { appendAudit } from "./audit.js";
8
8
  import { cancelLaneWaitersForWorker } from "./lanes.js";
9
+ import { checkpointSessionUsage } from "./usage.js";
10
+ import { refreshRunSessionProjection } from "./run-projection.js";
11
+ import { appendFlowRunTimelineEvent } from "./runs.js";
9
12
  export function registerFlowSession(context, input) {
10
13
  const payload = decodeFlowSessionPayload(input);
11
14
  const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
12
15
  const session = upsertFlowSession(context, project, payload, input.sessionId);
16
+ if (session.run_id)
17
+ appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_bound", session_id: session.session_id });
18
+ if (session.run_id)
19
+ refreshRunSessionProjection(context, project.id, session.run_id);
20
+ if (session.run_id)
21
+ checkpointSessionUsage(context, session, { checkpoint: "run_started", stage: session.current_stage });
13
22
  appendAudit(context, {
14
23
  projectId: project.id,
15
24
  eventType: "flow_session.registered",
@@ -38,9 +47,21 @@ export function getFlowSessionStatus(context, input) {
38
47
  export function stopFlowSession(context, input) {
39
48
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
40
49
  const session = requireFlowSession(context, project.id, input.sessionId);
50
+ if (session.run_id)
51
+ checkpointSessionUsage(context, session, { checkpoint: "session_stopped", stage: session.current_stage });
41
52
  markSessionStopped(context, project, session, input.reason);
53
+ if (session.run_id)
54
+ refreshRunSessionProjection(context, project.id, session.run_id);
42
55
  return { ok: true, session: flowSessionById(context, project.id, input.sessionId) };
43
56
  }
57
+ export function syncFlowSessionUsage(context, input) {
58
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
59
+ const session = requireFlowSession(context, project.id, input.sessionId);
60
+ if (!session.run_id) {
61
+ return { ok: true, session_id: session.session_id, extraction_status: "not_observable", diagnostic: "session_not_bound_to_run" };
62
+ }
63
+ return { ok: true, snapshot: checkpointSessionUsage(context, session, { checkpoint: "manual_sync", stage: session.current_stage }) };
64
+ }
44
65
  export function stopMergeWorker(context, input) {
45
66
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
46
67
  const sessions = flowSessionsForProject(context, project.id, { workerId: input.workerId }).filter((session) => session.flow_kind === "merge_worker" && ["active", "pending", "stopping"].includes(session.status));
@@ -101,6 +122,8 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
101
122
  context.db.run(`UPDATE flow_sessions
102
123
  SET continuation_count = ?, last_action_hash = ?, updated_at = ?
103
124
  WHERE project_id = ? AND session_id = ?`, [nextCount, actionHash, context.now(), projectId, sessionId]);
125
+ if (session.run_id)
126
+ refreshRunSessionProjection(context, projectId, session.run_id);
104
127
  return nextCount;
105
128
  }
106
129
  export function recordPendingFlowSessionBinding(context, project, input) {
@@ -148,6 +171,8 @@ export function confirmPendingFlowSessionBinding(context, project, input) {
148
171
  cwd: pending.cwd ?? payload.cwd ?? null,
149
172
  transcript_path: pending.transcript_path ?? payload.transcript_path ?? null
150
173
  }, input.sessionId);
174
+ if (session.run_id)
175
+ refreshRunSessionProjection(context, project.id, session.run_id);
151
176
  context.db.run(`UPDATE pending_flow_session_bindings SET status = 'confirmed', updated_at = ?
152
177
  WHERE project_id = ? AND session_id = ?`, [context.now(), project.id, input.sessionId]);
153
178
  appendAudit(context, {
@@ -205,6 +230,12 @@ function normalizeFlowSessionPayload(payload) {
205
230
  project_root: projectRoot,
206
231
  flow_kind: flowKind,
207
232
  run_id: stringField(payload, "run_id", false) ?? null,
233
+ parent_session_id: stringField(payload, "parent_session_id", false) ?? null,
234
+ role: stringField(payload, "role", false) ?? null,
235
+ aspect_id: stringField(payload, "aspect_id", false) ?? null,
236
+ plan_item_id: stringField(payload, "plan_item_id", false) ?? null,
237
+ session_kind: sessionKind(payload),
238
+ coverage_units: normalizeCoverageUnits(payload.coverage_units),
208
239
  protocol_id: stringField(payload, "protocol_id", false) ?? null,
209
240
  worker_id: stringField(payload, "worker_id", false) ?? null,
210
241
  workspace_path: stringField(payload, "workspace_path", false) ?? null,
@@ -221,15 +252,24 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
221
252
  const sessionId = forcedSessionId ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
222
253
  const workspacePath = payload.workspace_path ?? payload.cwd ?? project.root;
223
254
  context.db.run(`INSERT INTO flow_sessions
224
- (session_id, project_id, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path,
255
+ (session_id, project_id, project_root, flow_kind, status, run_id, parent_session_id, role, aspect_id, plan_item_id, session_kind, protocol_id, worker_id, workspace_path,
225
256
  continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason,
226
- transcript_path, cwd, metadata_json, created_at, updated_at, stopped_at)
227
- VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, NULL, 0, NULL, ?, ?, ?, ?, ?, NULL)
257
+ transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at)
258
+ VALUES (
259
+ ?, ?, ?, ?, 'active',
260
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, NULL,
261
+ ?, ?, ?, ?, ?, ?, NULL
262
+ )
228
263
  ON CONFLICT(session_id, project_id) DO UPDATE SET
229
264
  project_root = excluded.project_root,
230
265
  flow_kind = excluded.flow_kind,
231
266
  status = 'active',
232
267
  run_id = excluded.run_id,
268
+ parent_session_id = excluded.parent_session_id,
269
+ role = excluded.role,
270
+ aspect_id = excluded.aspect_id,
271
+ plan_item_id = excluded.plan_item_id,
272
+ session_kind = excluded.session_kind,
233
273
  protocol_id = excluded.protocol_id,
234
274
  worker_id = excluded.worker_id,
235
275
  workspace_path = excluded.workspace_path,
@@ -239,6 +279,7 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
239
279
  transcript_path = COALESCE(excluded.transcript_path, transcript_path),
240
280
  cwd = COALESCE(excluded.cwd, cwd),
241
281
  metadata_json = excluded.metadata_json,
282
+ coverage_units_json = excluded.coverage_units_json,
242
283
  updated_at = excluded.updated_at,
243
284
  stop_reason = NULL,
244
285
  stopped_at = NULL`, [
@@ -247,6 +288,11 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
247
288
  project.root,
248
289
  payload.flow_kind,
249
290
  payload.run_id ?? null,
291
+ payload.parent_session_id ?? null,
292
+ payload.role ?? null,
293
+ payload.aspect_id ?? null,
294
+ payload.plan_item_id ?? null,
295
+ payload.session_kind ?? null,
250
296
  payload.protocol_id,
251
297
  payload.worker_id,
252
298
  workspacePath,
@@ -256,6 +302,7 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
256
302
  payload.transcript_path ?? null,
257
303
  payload.cwd ?? null,
258
304
  JSON.stringify(payload.metadata ?? {}),
305
+ JSON.stringify(payload.coverage_units ?? []),
259
306
  now,
260
307
  now
261
308
  ]);
@@ -273,6 +320,10 @@ function markSessionStopped(context, project, session, reason) {
273
320
  context.db.run(`UPDATE flow_sessions
274
321
  SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
275
322
  WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
323
+ if (session.run_id)
324
+ appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_stopped", session_id: session.session_id });
325
+ if (session.run_id)
326
+ refreshRunSessionProjection(context, project.id, session.run_id);
276
327
  let lockRelease = undefined;
277
328
  let waiterCancellation = undefined;
278
329
  if (session.worker_id) {
@@ -364,6 +415,33 @@ function stringField(payload, key, required) {
364
415
  }
365
416
  return undefined;
366
417
  }
418
+ function sessionKind(payload) {
419
+ const value = stringField(payload, "session_kind", false);
420
+ if (!value)
421
+ return null;
422
+ if (["orchestrator", "llm_worker", "runtime_worker"].includes(value)) {
423
+ return value;
424
+ }
425
+ throw new AppError("validation", "session_kind is not supported", 2, { session_kind: value });
426
+ }
427
+ function normalizeCoverageUnits(value) {
428
+ if (value === undefined || value === null)
429
+ return [];
430
+ if (!Array.isArray(value)) {
431
+ throw new AppError("validation", "coverage_units must be an array", 2);
432
+ }
433
+ return value.map((item, index) => {
434
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
435
+ throw new AppError("validation", `coverage_units[${index}] must be an object`, 2);
436
+ }
437
+ const object = item;
438
+ if (typeof object.unit_id !== "string" || object.unit_id.length === 0) {
439
+ throw new AppError("validation", `coverage_units[${index}].unit_id is required`, 2);
440
+ }
441
+ const optional = (key) => object[key] === undefined || object[key] === null ? null : typeof object[key] === "string" ? object[key] : (() => { throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2); })();
442
+ return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
443
+ });
444
+ }
367
445
  function requiredStringField(payload, key) {
368
446
  const value = stringField(payload, key, true);
369
447
  if (!value) {
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
+ import { loadProjectFlowContract } from "../domain/flow-contract.js";
5
+ import { AppError } from "../shared/errors.js";
4
6
  import { getCanonStatus } from "./canon.js";
5
7
  import { getCliBuildInfo } from "./build-info.js";
6
8
  import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
@@ -25,6 +27,7 @@ export function getRuntimeStatus(context, input = {}) {
25
27
  const compatibilityManifest = resolvedCanonForProject ? readCompatibilityManifest(canon) : null;
26
28
  const cliCompatibility = cliCompatibilityVerdict(cli, compatibilityManifest);
27
29
  const engineSelection = projectRoot ? selectEngine(context, { projectRoot }) : null;
30
+ const flowContract = projectRoot ? projectFlowContractStatus(projectRoot) : null;
28
31
  const registry = input.checkRegistry ? checkNpmRegistry(context, cli.package_name) : undefined;
29
32
  return {
30
33
  ok: true,
@@ -45,7 +48,8 @@ export function getRuntimeStatus(context, input = {}) {
45
48
  status: project?.status ?? null,
46
49
  memory_bank: projectVersionStatus?.memory_bank ?? null,
47
50
  flow_pack: projectVersionStatus?.flow_pack ?? null,
48
- drift: projectVersionStatus?.drift ?? null
51
+ drift: projectVersionStatus?.drift ?? null,
52
+ flow_contract: flowContract
49
53
  },
50
54
  engine: engineSelection
51
55
  ? {
@@ -60,6 +64,33 @@ export function getRuntimeStatus(context, input = {}) {
60
64
  }
61
65
  };
62
66
  }
67
+ function projectFlowContractStatus(projectRoot) {
68
+ try {
69
+ const contract = loadProjectFlowContract(projectRoot);
70
+ return { status: "valid", id: contract.id, version: contract.version, diagnostic: null, action: null };
71
+ }
72
+ catch (error) {
73
+ if (!(error instanceof AppError))
74
+ throw error;
75
+ const file = typeof error.details.file === "string"
76
+ ? error.details.file
77
+ : path.join(projectRoot, ".memory-bank", "dd-flow", "flow-contract.json");
78
+ return {
79
+ status: "degraded",
80
+ id: null,
81
+ version: null,
82
+ diagnostic: {
83
+ code: "flow_contract_invalid",
84
+ path: typeof error.details.path === "string" ? error.details.path : "/",
85
+ message: error.message
86
+ },
87
+ action: {
88
+ code: "repair_or_upgrade_flow_contract",
89
+ command: `dd-flow schema validate --schema flow-contract --file "${file}" --project-root "${projectRoot}" --json`
90
+ }
91
+ };
92
+ }
93
+ }
63
94
  function asRecord(value) {
64
95
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
65
96
  }
@@ -0,0 +1,233 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ export function checkpointSessionUsage(context, session, input) {
4
+ const observedAt = context.now();
5
+ const result = readCodexTranscript(session.transcript_path, session.session_id);
6
+ const snapshot = {
7
+ id: `USG-${crypto.randomUUID()}`,
8
+ ...session,
9
+ checkpoint: input.checkpoint,
10
+ stage: input.stage ?? null,
11
+ stage_attempt: input.stageAttempt ?? null,
12
+ observed_at: observedAt,
13
+ total_tokens: result.counter?.usage.total_tokens ?? null,
14
+ input_tokens: result.counter?.usage.input_tokens ?? null,
15
+ cached_input_tokens: result.counter?.usage.cached_input_tokens ?? null,
16
+ output_tokens: result.counter?.usage.output_tokens ?? null,
17
+ reasoning_output_tokens: result.counter?.usage.reasoning_output_tokens ?? null,
18
+ source_kind: "codex_transcript_v1",
19
+ token_event_at: result.counter?.token_event_at ?? null,
20
+ turn_id: result.counter?.turn_id ?? null,
21
+ turn_attribution: result.counter?.turn_attribution ?? "unavailable",
22
+ parser_version: 1,
23
+ extraction_status: result.status,
24
+ diagnostic_code: result.diagnostic ?? null
25
+ };
26
+ const previous = context.db.get(`SELECT s.*, f.parent_session_id, f.role, f.aspect_id, f.plan_item_id, f.session_kind
27
+ FROM flow_run_usage_snapshots s
28
+ LEFT JOIN flow_sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
29
+ WHERE s.project_id = ? AND s.run_id = ? AND s.session_id = ? AND s.checkpoint = ?
30
+ ORDER BY s.observed_at DESC, s.id DESC LIMIT 1`, [session.project_id, session.run_id, session.session_id, input.checkpoint]);
31
+ if (previous && sameUsageSnapshot(previous, snapshot)) {
32
+ return snapshotForOutput(previous);
33
+ }
34
+ context.db.run(`INSERT INTO flow_run_usage_snapshots
35
+ (id, project_id, run_id, session_id, checkpoint, stage, stage_attempt, observed_at,
36
+ total_tokens, input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens,
37
+ source_kind, token_event_at, turn_id, turn_attribution, parser_version, extraction_status, diagnostic_code, created_at)
38
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
39
+ snapshot.id, snapshot.project_id, snapshot.run_id, snapshot.session_id, snapshot.checkpoint, snapshot.stage,
40
+ snapshot.stage_attempt, snapshot.observed_at, snapshot.total_tokens, snapshot.input_tokens,
41
+ snapshot.cached_input_tokens, snapshot.output_tokens, snapshot.reasoning_output_tokens, snapshot.source_kind,
42
+ snapshot.token_event_at, snapshot.turn_id, snapshot.turn_attribution, snapshot.parser_version,
43
+ snapshot.extraction_status, snapshot.diagnostic_code, observedAt
44
+ ]);
45
+ return snapshotForOutput(snapshot);
46
+ }
47
+ export function checkpointRunUsage(context, input) {
48
+ 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
49
+ FROM flow_sessions WHERE project_id = ? AND run_id = ?`, [input.projectId, input.runId]);
50
+ return sessions.map((session) => checkpointSessionUsage(context, session, input));
51
+ }
52
+ export function usageForRun(context, input) {
53
+ 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
54
+ FROM flow_sessions WHERE project_id = ? AND run_id = ?`, [input.projectId, input.runId]);
55
+ const refreshed = sessions.map((session) => checkpointSessionUsage(context, session, { checkpoint: "manual_sync" }));
56
+ const rows = context.db.all(`SELECT s.*, f.parent_session_id, f.role, f.aspect_id, f.plan_item_id, f.session_kind
57
+ FROM flow_run_usage_snapshots s
58
+ LEFT JOIN flow_sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
59
+ WHERE s.project_id = ? AND s.run_id = ? ORDER BY s.session_id, s.observed_at, s.id`, [input.projectId, input.runId]);
60
+ const deltas = usageDeltas(rows);
61
+ const supported = new Set(["session", "role", "aspect", "plan-item", "stage", "protocol"]);
62
+ const groupBy = supported.has(input.groupBy) ? input.groupBy : "session";
63
+ const groups = new Map();
64
+ for (const delta of deltas) {
65
+ const key = groupKey(delta, groupBy);
66
+ const group = groups.get(key) ?? { tokens: zeroUsage(), snapshots: 0, statuses: {}, transition_buckets: 0 };
67
+ group.snapshots += 1;
68
+ group.statuses[delta.extraction_status] = (group.statuses[delta.extraction_status] ?? 0) + 1;
69
+ if (delta.usage)
70
+ addUsage(group.tokens, delta.usage);
71
+ if (delta.transition)
72
+ group.transition_buckets += 1;
73
+ groups.set(key, group);
74
+ }
75
+ return {
76
+ ok: true,
77
+ schema_id: "dd-flow/run-usage@1",
78
+ run_id: input.runId,
79
+ source: { kind: "codex_transcript_v1", parser_version: 1, refreshed_sessions: refreshed.length },
80
+ sessions: sessions.map((session) => ({
81
+ session_id: session.session_id, parent_session_id: session.parent_session_id ?? null, role: session.role ?? null,
82
+ aspect_id: session.aspect_id ?? null, plan_item_id: session.plan_item_id ?? null, session_kind: session.session_kind ?? null
83
+ })),
84
+ groups: [...groups.entries()].map(([key, group]) => ({ key, ...group })),
85
+ deltas,
86
+ coverage: coverageForRows(rows)
87
+ };
88
+ }
89
+ function readCodexTranscript(transcriptPath, expectedSessionId) {
90
+ if (!transcriptPath)
91
+ return { status: "ephemeral_session", diagnostic: "transcript_path_missing" };
92
+ if (!fs.existsSync(transcriptPath))
93
+ return { status: "transcript_missing", diagnostic: "transcript_path_missing" };
94
+ try {
95
+ const lines = fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/);
96
+ let sessionId;
97
+ let nearestTurn = null;
98
+ let latest;
99
+ for (const line of lines) {
100
+ if (!line.trim())
101
+ continue;
102
+ let event;
103
+ try {
104
+ event = JSON.parse(line);
105
+ }
106
+ catch {
107
+ continue;
108
+ }
109
+ const payload = object(event.payload);
110
+ if (event.type === "session_meta")
111
+ sessionId = stringValue(payload?.id) ?? sessionId;
112
+ if (event.type === "event_msg" && payload?.type === "task_started")
113
+ nearestTurn = stringValue(payload.turn_id) ?? nearestTurn;
114
+ if (event.type !== "event_msg" || payload?.type !== "token_count")
115
+ continue;
116
+ const info = object(payload.info);
117
+ const total = object(info?.total_token_usage);
118
+ const parsed = parseUsage(total);
119
+ if (parsed)
120
+ latest = { usage: parsed, token_event_at: stringValue(event.timestamp) ?? null, turn_id: nearestTurn, turn_attribution: nearestTurn ? "inferred_nearest_task_started" : "unavailable" };
121
+ }
122
+ if (sessionId && sessionId !== expectedSessionId)
123
+ return { status: "session_mismatch", diagnostic: "transcript_session_id_mismatch" };
124
+ if (!latest)
125
+ return { status: "not_yet_emitted", diagnostic: "token_count_not_found" };
126
+ return { status: "measured", counter: latest };
127
+ }
128
+ catch {
129
+ return { status: "malformed_source", diagnostic: "transcript_read_failed" };
130
+ }
131
+ }
132
+ function usageDeltas(rows) {
133
+ const previous = new Map();
134
+ return rows.map((row) => {
135
+ const prior = previous.get(row.session_id);
136
+ previous.set(row.session_id, row);
137
+ const measured = row.extraction_status === "measured" && prior?.extraction_status === "measured";
138
+ const reset = measured && prior ? counterReset(row, prior) : false;
139
+ const usage = measured && prior && !reset ? subtractUsage(row, prior) : null;
140
+ const transition = reset
141
+ ? { transition: "counter_reset", attribution: "cross_stage_turn", confidence: "indeterminate" }
142
+ : measured && prior?.stage && row.stage && prior.stage !== row.stage
143
+ ? { transition: `${prior.stage} -> ${row.stage}`, attribution: "cross_stage_turn", confidence: "indeterminate" }
144
+ : null;
145
+ return {
146
+ snapshot_id: row.id, session_id: row.session_id, checkpoint: row.checkpoint, observed_at: row.observed_at,
147
+ stage: row.stage, role: row.role ?? null, aspect_id: row.aspect_id ?? null, plan_item_id: row.plan_item_id ?? null,
148
+ extraction_status: reset ? "stale_source" : row.extraction_status, usage: transition ? null : usage,
149
+ ...(transition ? transition : {}), source: { kind: row.source_kind, token_event_at: row.token_event_at, turn_id: row.turn_id, turn_attribution: row.turn_attribution }
150
+ };
151
+ });
152
+ }
153
+ function subtractUsage(current, previous) {
154
+ return {
155
+ total_tokens: (current.total_tokens ?? 0) - (previous.total_tokens ?? 0),
156
+ input_tokens: current.input_tokens === null || previous.input_tokens === null ? null : current.input_tokens - previous.input_tokens,
157
+ cached_input_tokens: current.cached_input_tokens === null || previous.cached_input_tokens === null ? null : current.cached_input_tokens - previous.cached_input_tokens,
158
+ output_tokens: current.output_tokens === null || previous.output_tokens === null ? null : current.output_tokens - previous.output_tokens,
159
+ reasoning_output_tokens: current.reasoning_output_tokens === null || previous.reasoning_output_tokens === null ? null : current.reasoning_output_tokens - previous.reasoning_output_tokens
160
+ };
161
+ }
162
+ function counterReset(current, previous) {
163
+ return [
164
+ [current.total_tokens, previous.total_tokens],
165
+ [current.input_tokens, previous.input_tokens],
166
+ [current.cached_input_tokens, previous.cached_input_tokens],
167
+ [current.output_tokens, previous.output_tokens],
168
+ [current.reasoning_output_tokens, previous.reasoning_output_tokens]
169
+ ].some(([now, before]) => typeof now === "number" && typeof before === "number" && now < before);
170
+ }
171
+ function groupKey(delta, groupBy) {
172
+ if (groupBy === "stage")
173
+ return String(delta.stage ?? delta.transition ?? "unattributed");
174
+ if (groupBy === "role")
175
+ return delta.role ?? "unassigned";
176
+ if (groupBy === "aspect")
177
+ return delta.aspect_id ?? "unassigned";
178
+ if (groupBy === "plan-item")
179
+ return delta.plan_item_id ?? "unassigned";
180
+ return String(delta.session_id ?? "unknown");
181
+ }
182
+ function coverageForRows(rows) {
183
+ return rows.reduce((result, row) => {
184
+ result[row.extraction_status] = (result[row.extraction_status] ?? 0) + 1;
185
+ return result;
186
+ }, {});
187
+ }
188
+ function snapshotForOutput(snapshot) {
189
+ const safe = { ...snapshot };
190
+ delete safe.transcript_path;
191
+ return { schema_id: "dd-flow/session-usage-snapshot@1", ...safe };
192
+ }
193
+ function sameUsageSnapshot(previous, current) {
194
+ return previous.stage === current.stage
195
+ && previous.stage_attempt === current.stage_attempt
196
+ && previous.extraction_status === current.extraction_status
197
+ && previous.total_tokens === current.total_tokens
198
+ && previous.input_tokens === current.input_tokens
199
+ && previous.cached_input_tokens === current.cached_input_tokens
200
+ && previous.output_tokens === current.output_tokens
201
+ && previous.reasoning_output_tokens === current.reasoning_output_tokens
202
+ && previous.token_event_at === current.token_event_at
203
+ && previous.turn_id === current.turn_id;
204
+ }
205
+ function parseUsage(value) {
206
+ if (!value)
207
+ return undefined;
208
+ const total = numberValue(value.total_tokens);
209
+ if (total === undefined)
210
+ return undefined;
211
+ return {
212
+ total_tokens: total,
213
+ input_tokens: numberValue(value.input_tokens) ?? null,
214
+ cached_input_tokens: numberValue(value.cached_input_tokens) ?? null,
215
+ output_tokens: numberValue(value.output_tokens) ?? null,
216
+ reasoning_output_tokens: numberValue(value.reasoning_output_tokens) ?? null
217
+ };
218
+ }
219
+ function addUsage(target, source) {
220
+ target.total_tokens += source.total_tokens;
221
+ if (source.input_tokens !== null)
222
+ target.input_tokens += source.input_tokens;
223
+ if (source.cached_input_tokens !== null)
224
+ target.cached_input_tokens += source.cached_input_tokens;
225
+ if (source.output_tokens !== null)
226
+ target.output_tokens += source.output_tokens;
227
+ if (source.reasoning_output_tokens !== null)
228
+ target.reasoning_output_tokens += source.reasoning_output_tokens;
229
+ }
230
+ function zeroUsage() { return { total_tokens: 0, input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0 }; }
231
+ function object(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; }
232
+ function stringValue(value) { return typeof value === "string" && value ? value : undefined; }
233
+ function numberValue(value) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
@@ -4,12 +4,13 @@ import { spawnSync } from "node:child_process";
4
4
  import { flowContractForState } from "../domain/flow-contract.js";
5
5
  import { requireStage } from "../domain/validation.js";
6
6
  import { AppError } from "../shared/errors.js";
7
- import { projectCheckoutRoot } from "../storage/paths.js";
7
+ import { projectCheckoutRoot, resolveProjectRoot } from "../storage/paths.js";
8
8
  import { appendAudit } from "./audit.js";
9
9
  import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
10
+ import { requireProjectByRoot } from "./projects.js";
10
11
  const worktrunkBinEnv = "DD_FLOW_WORKTRUNK_BIN";
11
12
  export function planWorktree(context, input) {
12
- const protocol = requireProtocol(context, input.protocolId);
13
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
13
14
  const state = readProtocolRuntimeState(context, protocol).state;
14
15
  const suggestedPath = path.join(projectCheckoutRoot(context.ddFlowHome, protocol.project_id), "worktrees", protocol.id, path.basename(protocol.project_root));
15
16
  return {
@@ -28,8 +29,8 @@ export function planWorktree(context, input) {
28
29
  };
29
30
  }
30
31
  export function createWorktreeRecord(context, input) {
31
- const protocol = requireProtocol(context, input.protocolId);
32
- const existing = activeWorktreeRecord(context, protocol.id);
32
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
33
+ const existing = activeWorktreeRecord(context, protocol.project_id, protocol.id);
33
34
  if (existing) {
34
35
  throw new AppError("worktree_already_exists", "Protocol already has an active worktree record", 1, {
35
36
  protocol_id: protocol.id,
@@ -81,15 +82,15 @@ export function createWorktreeRecord(context, input) {
81
82
  eventType: "worktree.created",
82
83
  payload: { protocol_id: protocol.id, feature_branch: input.branch, base: input.base, path: worktreePath }
83
84
  });
84
- return { ok: true, worktree: activeWorktreeRecord(context, protocol.id) };
85
+ return { ok: true, worktree: activeWorktreeRecord(context, protocol.project_id, protocol.id) };
85
86
  }
86
87
  export function getWorktreeStatus(context, input) {
87
- const protocol = requireProtocol(context, input.protocolId);
88
- return { ok: true, protocol_id: protocol.id, worktree: worktreeRecord(context, protocol.id), worktrunk: detectWorktrunk(context) };
88
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
89
+ return { ok: true, protocol_id: protocol.id, worktree: worktreeRecord(context, protocol.project_id, protocol.id), worktrunk: detectWorktrunk(context) };
89
90
  }
90
91
  export function bootstrapWorktree(context, input) {
91
- const protocol = requireProtocol(context, input.protocolId);
92
- const record = activeWorktreeRecord(context, protocol.id);
92
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
93
+ const record = activeWorktreeRecord(context, protocol.project_id, protocol.id);
93
94
  if (!record) {
94
95
  throw new AppError("not_found", `Worktree record is not active for protocol: ${protocol.id}`, 1);
95
96
  }
@@ -101,7 +102,7 @@ export function bootstrapWorktree(context, input) {
101
102
  };
102
103
  context.db.run(`UPDATE worktree_records
103
104
  SET bootstrap_status = 'succeeded', last_command_result_json = ?, updated_at = ?
104
- WHERE protocol_id = ?`, [JSON.stringify(result), now, protocol.id]);
105
+ WHERE project_id = ? AND protocol_id = ?`, [JSON.stringify(result), now, protocol.project_id, protocol.id]);
105
106
  const state = readProtocolRuntimeState(context, protocol).state;
106
107
  persistProtocolState(context, protocol, {
107
108
  ...state,
@@ -117,14 +118,14 @@ export function bootstrapWorktree(context, input) {
117
118
  eventType: "worktree.bootstrap_succeeded",
118
119
  payload: { protocol_id: protocol.id, worktree_path: record.worktree_path }
119
120
  });
120
- return { ok: true, worktree: worktreeRecord(context, protocol.id) };
121
+ return { ok: true, worktree: worktreeRecord(context, protocol.project_id, protocol.id) };
121
122
  }
122
123
  export function closeWorktree(context, input) {
123
124
  if (!["keep", "remove"].includes(input.mode)) {
124
125
  throw new AppError("validation", "--mode must be keep or remove", 2);
125
126
  }
126
- const protocol = requireProtocol(context, input.protocolId);
127
- const record = activeWorktreeRecord(context, protocol.id);
127
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
128
+ const record = activeWorktreeRecord(context, protocol.project_id, protocol.id);
128
129
  if (!record) {
129
130
  throw new AppError("not_found", `Worktree record is not active for protocol: ${protocol.id}`, 1);
130
131
  }
@@ -137,7 +138,7 @@ export function closeWorktree(context, input) {
137
138
  }
138
139
  const now = context.now();
139
140
  const nextStatus = input.mode === "keep" ? "kept" : fs.existsSync(record.worktree_path) ? "kept" : "removed";
140
- context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ? WHERE protocol_id = ?`, [nextStatus, now, now, protocol.id]);
141
+ context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ? WHERE project_id = ? AND protocol_id = ?`, [nextStatus, now, now, protocol.project_id, protocol.id]);
141
142
  appendAudit(context, {
142
143
  protocolId: protocol.id,
143
144
  projectId: protocol.project_id,
@@ -146,21 +147,25 @@ export function closeWorktree(context, input) {
146
147
  });
147
148
  return {
148
149
  ok: true,
149
- worktree: worktreeRecord(context, protocol.id),
150
+ worktree: worktreeRecord(context, protocol.project_id, protocol.id),
150
151
  outcome: {
151
152
  status: nextStatus,
152
153
  reason: nextStatus === "removed" ? "path_absent" : input.mode === "remove" ? "physical_checkout_kept" : "keep_requested"
153
154
  }
154
155
  };
155
156
  }
157
+ function scopedProtocol(context, projectRoot, protocolId) {
158
+ const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
159
+ return requireProtocol(context, protocolId, project.id);
160
+ }
156
161
  export function worktreeRecordsForProject(context, projectId) {
157
162
  return context.db.all(`SELECT * FROM worktree_records WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
158
163
  }
159
- export function worktreeRecord(context, protocolId) {
160
- return context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocolId]);
164
+ export function worktreeRecord(context, projectId, protocolId) {
165
+ return context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
161
166
  }
162
- function activeWorktreeRecord(context, protocolId) {
163
- return context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ? AND status = 'active'", [protocolId]);
167
+ function activeWorktreeRecord(context, projectId, protocolId) {
168
+ return context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ? AND status = 'active'", [projectId, protocolId]);
164
169
  }
165
170
  function detectWorktrunk(context) {
166
171
  const configured = context.env[worktrunkBinEnv];