@deksden-com/dd-flow-cli 0.7.0 → 0.8.0

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 (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. package/package.json +12 -12
@@ -1,8 +1,66 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
+ import { parseJsonObject } from "../shared/json.js";
4
+ import { resolveProjectRoot } from "../storage/paths.js";
5
+ import { registerProject, requireProjectByRoot } from "./projects.js";
6
+ export function ingestZcodeUsage(context, input) {
7
+ return ingestHarnessUsage(context, input, { harness: "zcode-acp", label: "ZCode", idPrefix: "ZUSG", sourceKind: "zcode_session_usage_v1" });
8
+ }
9
+ /** Store a Grok Build ACP usage snapshot. Root snapshots may include child usage. */
10
+ export function ingestGrokUsage(context, input) {
11
+ return ingestHarnessUsage(context, input, { harness: "grok-acp", label: "Grok Build", idPrefix: "GUSG", sourceKind: "grok_session_usage_v1" });
12
+ }
13
+ /** Store an OpenCode cumulative Session usage snapshot. */
14
+ export function ingestOpenCodeUsage(context, input) {
15
+ return ingestHarnessUsage(context, input, { harness: "opencode-server", label: "OpenCode", idPrefix: "OUSG", sourceKind: "opencode_session_usage_v1" });
16
+ }
17
+ /** Store an Antigravity CLI cumulative conversation usage snapshot. */
18
+ export function ingestAgyUsage(context, input) {
19
+ return ingestHarnessUsage(context, input, { harness: "antigravity-cli", label: "Antigravity", idPrefix: "AUSG", sourceKind: "antigravity_cli_session_usage_v1" });
20
+ }
21
+ function ingestHarnessUsage(context, input, harness) {
22
+ const payload = parseJsonObject(input.stdin || "{}", `${harness.label} usage snapshot`);
23
+ const usage = object(payload.usage);
24
+ const providerSessionId = stringValue(payload.provider_session_id) ?? stringValue(usage?.sessionId);
25
+ if (!usage || !providerSessionId)
26
+ throw new Error(`${harness.label} usage snapshot requires usage and provider_session_id`);
27
+ const projectRoot = resolveProjectRoot(input.projectRoot);
28
+ registerProject(context, { root: projectRoot });
29
+ const project = requireProjectByRoot(context, projectRoot);
30
+ const observedAt = stringValue(payload.observed_at) ?? context.now();
31
+ const requestUsageMeasured = stringValue(usage.requestUsageStatus) === "measured";
32
+ const total = tokenField(usage, requestUsageMeasured ? ["requestTotalTokens"] : ["totalTokens", "total_tokens"]);
33
+ if (total === undefined || total === null)
34
+ throw new Error(`${harness.label} usage snapshot has no total token count`);
35
+ const toolUsage = object(payload.tool_calls);
36
+ const values = {
37
+ total,
38
+ input: tokenField(usage, requestUsageMeasured ? ["requestInputTokens"] : ["inputTokens", "input_tokens"]) ?? null,
39
+ cacheRead: tokenField(usage, requestUsageMeasured ? ["requestCacheReadTokens"] : ["cacheReadTokens", "cachedReadTokens", "cacheReadInputTokens", "cache_read_input_tokens"]) ?? null,
40
+ cacheWrite: tokenField(usage, requestUsageMeasured ? ["requestCacheCreationTokens"] : ["cacheCreationTokens", "cacheCreationInputTokens", "cache_write_input_tokens"]) ?? null,
41
+ output: tokenField(usage, requestUsageMeasured ? ["requestOutputTokens"] : ["outputTokens", "output_tokens"]) ?? null,
42
+ reasoning: tokenField(usage, requestUsageMeasured ? ["requestReasoningTokens"] : ["reasoningTokens", "reasoning_output_tokens"]) ?? null,
43
+ requests: tokenField(usage, requestUsageMeasured ? ["requestCount"] : ["modelRequestCount", "modelCalls", "request_count"]) ?? null,
44
+ errors: tokenField(usage, ["modelErrorCount", "error_count"]) ?? null,
45
+ toolCalls: toolUsage ? tokenField(toolUsage, ["total"]) ?? null : null,
46
+ toolFailures: toolUsage ? tokenField(toolUsage, ["failures"]) ?? null : null,
47
+ toolByName: object(toolUsage?.by_tool) ?? object(toolUsage?.by_name) ?? null
48
+ };
49
+ const id = `${harness.idPrefix}-${crypto.createHash("sha256").update(JSON.stringify({ project: project.id, providerSessionId, observedAt, values })).digest("hex")}`;
50
+ const inserted = context.db.run(`INSERT OR IGNORE INTO harness_usage_snapshots
51
+ (id, project_id, harness, provider_session_id, daemon_id, observed_at, total_tokens, input_tokens,
52
+ cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens,
53
+ request_count, error_count, tool_calls, tool_failures, tool_by_name_json, source_kind, usage_scope, completeness, created_at)
54
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, project.id, harness.harness, providerSessionId, stringValue(payload.daemon_id) ?? null, observedAt, values.total,
55
+ values.input, values.cacheRead, values.cacheWrite, values.output, values.reasoning, values.requests, values.errors,
56
+ values.toolCalls, values.toolFailures, values.toolByName ? JSON.stringify(values.toolByName) : null, harness.sourceKind,
57
+ 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
+ }
3
60
  export function checkpointSessionUsage(context, session, input) {
4
61
  const observedAt = context.now();
5
62
  const result = readCodexTranscript(session.transcript_path, session.session_id);
63
+ const source = sourceFacts(session.transcript_path);
6
64
  const snapshot = {
7
65
  id: `USG-${crypto.randomUUID()}`,
8
66
  ...session,
@@ -19,6 +77,10 @@ export function checkpointSessionUsage(context, session, input) {
19
77
  output_tokens: result.counter?.usage.output_tokens ?? null,
20
78
  reasoning_output_tokens: result.counter?.usage.reasoning_output_tokens ?? null,
21
79
  source_kind: "codex_transcript_v1",
80
+ source_locator: source.locator,
81
+ source_sha256: source.sha256,
82
+ source_size_bytes: source.size,
83
+ source_mtime_ms: source.mtime,
22
84
  token_event_at: result.counter?.token_event_at ?? null,
23
85
  turn_id: result.counter?.turn_id ?? null,
24
86
  turn_attribution: result.counter?.turn_attribution ?? "unavailable",
@@ -27,23 +89,25 @@ export function checkpointSessionUsage(context, session, input) {
27
89
  diagnostic_code: result.diagnostic ?? null
28
90
  };
29
91
  const previous = context.db.get(`SELECT s.*, f.parent_session_id, f.role, f.aspect_id, f.plan_item_id, f.session_kind
30
- FROM flow_run_usage_snapshots s
31
- LEFT JOIN flow_sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
92
+ FROM usage s
93
+ LEFT JOIN sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
32
94
  WHERE s.project_id = ? AND s.run_id = ? AND s.session_id = ? AND s.checkpoint = ?
33
95
  ORDER BY s.observed_at DESC, s.id DESC LIMIT 1`, [session.project_id, session.run_id, session.session_id, input.checkpoint]);
34
96
  if (previous && sameUsageSnapshot(previous, snapshot)) {
35
97
  return snapshotForOutput(previous);
36
98
  }
37
- context.db.run(`INSERT INTO flow_run_usage_snapshots
99
+ context.db.run(`INSERT INTO usage
38
100
  (id, project_id, run_id, session_id, checkpoint, stage, stage_attempt, observed_at,
39
101
  total_tokens, input_tokens, cached_input_tokens, cache_read_input_tokens, cache_write_input_tokens, uncached_input_tokens,
40
102
  output_tokens, reasoning_output_tokens,
41
- source_kind, token_event_at, turn_id, turn_attribution, parser_version, extraction_status, diagnostic_code, created_at)
42
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
103
+ source_kind, source_locator, source_sha256, source_size_bytes, source_mtime_ms,
104
+ token_event_at, turn_id, turn_attribution, parser_version, extraction_status, diagnostic_code, created_at)
105
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
43
106
  snapshot.id, snapshot.project_id, snapshot.run_id, snapshot.session_id, snapshot.checkpoint, snapshot.stage,
44
107
  snapshot.stage_attempt, snapshot.observed_at, snapshot.total_tokens, snapshot.input_tokens,
45
108
  snapshot.cached_input_tokens, snapshot.cache_read_input_tokens, snapshot.cache_write_input_tokens,
46
109
  snapshot.uncached_input_tokens, snapshot.output_tokens, snapshot.reasoning_output_tokens, snapshot.source_kind,
110
+ snapshot.source_locator, snapshot.source_sha256, snapshot.source_size_bytes, snapshot.source_mtime_ms,
47
111
  snapshot.token_event_at, snapshot.turn_id, snapshot.turn_attribution, snapshot.parser_version,
48
112
  snapshot.extraction_status, snapshot.diagnostic_code, observedAt
49
113
  ]);
@@ -51,22 +115,400 @@ export function checkpointSessionUsage(context, session, input) {
51
115
  }
52
116
  export function checkpointRunUsage(context, input) {
53
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
54
- FROM flow_sessions WHERE project_id = ? AND run_id = ?`, [input.projectId, input.runId]);
118
+ FROM sessions WHERE project_id = ? AND run_id = ?`, [input.projectId, input.runId]);
55
119
  return sessions.map((session) => checkpointSessionUsage(context, session, input));
56
120
  }
57
121
  export function usageForRun(context, input) {
122
+ return usageReport(context, input, true);
123
+ }
124
+ /** Reads saved checkpoints only; reporting must not create a new measurement. */
125
+ export function storedUsageForRun(context, input) {
126
+ return usageReport(context, input, false);
127
+ }
128
+ /**
129
+ * The vNext controller owns this operation. It rereads source transcripts and
130
+ * replaces the current RUN projection; it does not build a checkpoint ledger.
131
+ */
132
+ export function recalculateRunUsage(context, input) {
133
+ 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;
137
+ 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
139
+ FROM work_sessions ws
140
+ JOIN works w ON w.work_id = ws.work_id
141
+ LEFT JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
142
+ WHERE w.project_id = ? AND w.run_id = ?
143
+ ORDER BY ws.created_at, ws.id`, [input.projectId, input.runId]).filter((link) => (!input.sessionId || link.session_id === input.sessionId) && (!input.stage || overlapsStage(link, input.stage, observedAt)))
144
+ .map((link) => input.stage ? clipToStage(link, input.stage, observedAt) : link);
145
+ if (input.sessionId && links.length === 0)
146
+ throw new Error(`Session is not associated with RUN: ${input.sessionId}`);
147
+ const final = !context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND status IN ('created', 'running') LIMIT 1", [input.projectId, input.runId]);
148
+ const rows = usageRowsForPhysicalSessions(context, links, accountingAt, input.stage);
149
+ const totals = emptyUsage();
150
+ for (const row of rows)
151
+ if (row.tokens)
152
+ addUsage(totals, row.tokens);
153
+ const settledSessions = settledRunSessions(context, links, observedAt);
154
+ const unsettledSessions = [...new Set(links.map((link) => link.session_id))].filter((sessionId) => !settledSessions.some((session) => session.session_id === sessionId));
155
+ 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)
161
+ 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
171
+ SET status = 'stopped', stop_reason = 'provider_turn_complete', updated_at = ?, stopped_at = ?
172
+ WHERE project_id = ? AND run_id = ? AND session_id = ?
173
+ AND status = 'idle'
174
+ 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]);
175
+ }
176
+ context.db.exec("COMMIT");
177
+ }
178
+ catch (error) {
179
+ context.db.exec("ROLLBACK");
180
+ throw error;
181
+ }
182
+ return {
183
+ ok: true,
184
+ schema_id: "dd-flow/run-usage@2",
185
+ run_id: input.runId,
186
+ status: usageFinal ? "final" : "provisional",
187
+ scope: input.stage ? { kind: "stage", stage: input.stage.name, started_at: input.stage.startedAt, completed_at: input.stage.completedAt } : { kind: "run" },
188
+ observed_at: observedAt,
189
+ totals: outputUsageTotals(totals),
190
+ sessions: rows,
191
+ session_reconciliation: {
192
+ stopped: settledSessions.map((session) => session.session_id),
193
+ unsettled: unsettledSessions
194
+ },
195
+ tool_calls: toolCallsForLinks(context, links, accountingAt),
196
+ coverage: rows.reduce((coverage, row) => ({ ...coverage, [row.status]: (coverage[row.status] ?? 0) + 1 }), {})
197
+ };
198
+ }
199
+ function overlapsStage(link, stage, observedAt) {
200
+ const linkStart = Date.parse(link.created_at);
201
+ const linkEnd = Date.parse(link.completed_at ?? observedAt);
202
+ const stageStart = Date.parse(stage.startedAt);
203
+ const stageEnd = Date.parse(stage.completedAt ?? observedAt);
204
+ return (!Number.isFinite(linkEnd) || !Number.isFinite(stageStart) || linkEnd >= stageStart)
205
+ && (!Number.isFinite(linkStart) || !Number.isFinite(stageEnd) || linkStart <= stageEnd);
206
+ }
207
+ function clipToStage(link, stage, observedAt) {
208
+ const startedAt = Date.parse(link.created_at) < Date.parse(stage.startedAt) ? stage.startedAt : link.created_at;
209
+ const naturalEnd = link.completed_at ?? observedAt;
210
+ const stageEnd = stage.completedAt ?? observedAt;
211
+ const completedAt = Date.parse(naturalEnd) > Date.parse(stageEnd) ? stageEnd : naturalEnd;
212
+ return { ...link, created_at: startedAt, completed_at: completedAt };
213
+ }
214
+ function toolCallsForLinks(context, links, observedAt) {
215
+ const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
216
+ const sessions = new Map();
217
+ for (const link of links)
218
+ sessions.set(link.session_id, [...(sessions.get(link.session_id) ?? []), link]);
219
+ for (const [sessionId, windows] of sessions) {
220
+ const first = windows[0];
221
+ const parsed = isExternalHarness(first.harness)
222
+ ? 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);
224
+ if (!parsed) {
225
+ result.unavailable_sessions.push(sessionId);
226
+ continue;
227
+ }
228
+ result.observed_sessions += 1;
229
+ result.total += parsed.total;
230
+ result.failures += parsed.failures;
231
+ for (const [name, count] of Object.entries(parsed.by_tool))
232
+ result.by_tool[name] = (result.by_tool[name] ?? 0) + count;
233
+ }
234
+ result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length ? "partial" : "measured";
235
+ return result;
236
+ }
237
+ function isExternalHarness(harness) { return harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
238
+ function harnessSourceKind(harness) {
239
+ return harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
240
+ }
241
+ function readHarnessToolCallsWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
242
+ if (!providerSessionId)
243
+ return null;
244
+ const snapshots = context.db.all(`SELECT observed_at, tool_calls, tool_failures, tool_by_name_json FROM harness_usage_snapshots
245
+ WHERE project_id = ? AND harness = ? AND provider_session_id = ? AND observed_at <= ?
246
+ ORDER BY observed_at, id`, [projectId, harness, providerSessionId, observedAt]);
247
+ const last = snapshots.find((snapshot) => Date.parse(snapshot.observed_at) > Date.parse(endedAt))
248
+ ?? snapshots.filter((snapshot) => Date.parse(snapshot.observed_at) <= Date.parse(endedAt)).at(-1);
249
+ const before = snapshots.filter((snapshot) => Date.parse(snapshot.observed_at) <= Date.parse(startedAt)).at(-1);
250
+ if (!last || !before || last === before || last.tool_calls === null || before.tool_calls === null)
251
+ return null;
252
+ const parse = (value) => {
253
+ if (!value)
254
+ return {};
255
+ try {
256
+ return Object.fromEntries(Object.entries(JSON.parse(value)).filter((entry) => typeof entry[1] === "number"));
257
+ }
258
+ catch {
259
+ return {};
260
+ }
261
+ };
262
+ const latestByTool = parse(last.tool_by_name_json);
263
+ const baselineByTool = parse(before.tool_by_name_json);
264
+ const byTool = {};
265
+ for (const name of Object.keys(latestByTool).sort()) {
266
+ const count = Math.max(0, latestByTool[name] - (baselineByTool[name] ?? 0));
267
+ if (count > 0)
268
+ byTool[name] = count;
269
+ }
270
+ return { total: Math.max(0, last.tool_calls - before.tool_calls), failures: Math.max(0, (last.tool_failures ?? 0) - (before.tool_failures ?? 0)), by_tool: byTool };
271
+ }
272
+ function readToolCallsForWindows(transcriptPath, expectedSessionId, windows, observedAt) {
273
+ if (!transcriptPath || !fs.existsSync(transcriptPath))
274
+ return null;
275
+ try {
276
+ let sessionId;
277
+ let total = 0;
278
+ let failures = 0;
279
+ const byTool = {};
280
+ for (const line of fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/)) {
281
+ if (!line.trim())
282
+ continue;
283
+ let event;
284
+ try {
285
+ event = JSON.parse(line);
286
+ }
287
+ catch {
288
+ continue;
289
+ }
290
+ const payload = object(event.payload);
291
+ if (event.type === "session_meta")
292
+ sessionId ??= stringValue(payload?.id);
293
+ if (event.type !== "response_item" || !payload)
294
+ continue;
295
+ const eventAt = stringValue(event.timestamp);
296
+ if (eventAt && !windows.some((window) => eventInsideWindow(eventAt, window, observedAt)))
297
+ continue;
298
+ const type = stringValue(payload.type);
299
+ if (type === "function_call" || type === "custom_tool_call") {
300
+ const name = stringValue(payload.name) ?? "unknown";
301
+ byTool[name] = (byTool[name] ?? 0) + 1;
302
+ total += 1;
303
+ }
304
+ if ((type === "function_call_output" || type === "custom_tool_call_output") && outputIndicatesFailure(payload))
305
+ failures += 1;
306
+ }
307
+ if (sessionId && sessionId !== expectedSessionId)
308
+ return null;
309
+ return { total, failures, by_tool: Object.fromEntries(Object.entries(byTool).sort(([left], [right]) => left.localeCompare(right))) };
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ }
315
+ function eventInsideWindow(eventAt, window, observedAt) {
316
+ const at = Date.parse(eventAt);
317
+ const start = Date.parse(window.created_at);
318
+ const end = Date.parse(window.completed_at ?? observedAt);
319
+ return Number.isFinite(at) && (!Number.isFinite(start) || at >= start) && (!Number.isFinite(end) || at <= end);
320
+ }
321
+ function outputIndicatesFailure(payload) {
322
+ if (payload.error === true || typeof payload.error === "string")
323
+ return true;
324
+ return containsFailure(payload.output);
325
+ }
326
+ function containsFailure(value) {
327
+ if (Array.isArray(value))
328
+ return value.some(containsFailure);
329
+ if (typeof value === "string") {
330
+ if (/(?:^|\n)exit=(?:[1-9]\d*)\b|Process exited with code [1-9]\d*/.test(value))
331
+ return true;
332
+ const trimmed = value.trim();
333
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
334
+ try {
335
+ return containsFailure(JSON.parse(trimmed));
336
+ }
337
+ catch {
338
+ return false;
339
+ }
340
+ }
341
+ return false;
342
+ }
343
+ const item = object(value);
344
+ if (!item)
345
+ return false;
346
+ if (item.ok === false || item.is_error === true || item.isError === true || item.error === true || typeof item.error === "string")
347
+ return true;
348
+ if (typeof item.exit_code === "number" && item.exit_code !== 0)
349
+ return true;
350
+ return Object.values(item).some(containsFailure);
351
+ }
352
+ /** RUN accounting belongs to the physical provider Session, not to every Work link. */
353
+ function usageRowsForPhysicalSessions(context, links, observedAt, stage) {
354
+ const sessions = new Map();
355
+ for (const link of links)
356
+ sessions.set(link.session_id, [...(sessions.get(link.session_id) ?? []), link]);
357
+ const physicalSessions = [...sessions.values()].filter((sessionLinks) => {
358
+ const link = sessionLinks[0];
359
+ if (!link.parent_session_id)
360
+ return true;
361
+ const parent = links.find((candidate) => candidate.session_id === link.parent_session_id);
362
+ return !parent || !hasInclusiveUsageSnapshot(context, parent.project_id, parent.harness, parent.provider_session_id, observedAt);
363
+ });
364
+ return physicalSessions.map((sessionLinks) => {
365
+ const link = sessionLinks[0];
366
+ const startedAt = stage?.startedAt ?? sessionLinks.map((item) => item.created_at).sort()[0];
367
+ const completedTurn = stage ? null : completedProviderTurn(link.transcript_path, link.session_id);
368
+ const naturalEnd = isExternalHarness(link.harness)
369
+ ? observedAt
370
+ : completedTurn?.completed_at ?? sessionLinks.map((item) => item.completed_at ?? observedAt).sort().at(-1);
371
+ const endedAt = stage?.completedAt ?? (Date.parse(naturalEnd) > Date.parse(observedAt) ? observedAt : naturalEnd);
372
+ const harnessUsage = isExternalHarness(link.harness)
373
+ ? readHarnessUsageWindow(context, link.harness, link.project_id, link.provider_session_id, startedAt, endedAt, observedAt)
374
+ : null;
375
+ const parsed = harnessUsage ?? readCodexTranscriptWindow(link.transcript_path, link.session_id, startedAt, endedAt);
376
+ return {
377
+ work_session_id: link.id,
378
+ work_id: link.work_id,
379
+ session_id: link.session_id,
380
+ provider_session_id: link.provider_session_id,
381
+ agent_id: link.agent_id,
382
+ parent_session_id: link.parent_session_id,
383
+ status: parsed.status,
384
+ diagnostic: parsed.diagnostic ?? null,
385
+ observed_at: observedAt,
386
+ tokens: parsed.counter?.usage ?? null,
387
+ source: harnessUsage ? { locator: link.provider_session_id ? `${link.harness}:${link.provider_session_id}` : null, sha256: null, size: null, mtime: null } : sourceFacts(link.transcript_path),
388
+ source_kind: harnessUsage ? harnessSourceKind(link.harness) : "codex_transcript_v1"
389
+ };
390
+ });
391
+ }
392
+ function hasInclusiveUsageSnapshot(context, projectId, harness, providerSessionId, observedAt) {
393
+ if (!providerSessionId)
394
+ return false;
395
+ return Boolean(context.db.get("SELECT 1 FROM harness_usage_snapshots WHERE project_id = ? AND harness = ? AND provider_session_id = ? AND usage_scope = 'execution_tree_inclusive' AND observed_at <= ? LIMIT 1", [projectId, harness, providerSessionId, observedAt]));
396
+ }
397
+ function readHarnessUsageWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
398
+ if (!providerSessionId)
399
+ return { status: "not_observable", diagnostic: "provider_session_id_missing" };
400
+ const snapshots = context.db.all(`SELECT observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
401
+ FROM harness_usage_snapshots
402
+ WHERE project_id = ? AND harness = ? AND provider_session_id = ? AND observed_at <= ?
403
+ ORDER BY observed_at, id`, [projectId, harness, providerSessionId, observedAt]);
404
+ const last = snapshots.find((snapshot) => Date.parse(snapshot.observed_at) > Date.parse(endedAt))
405
+ ?? snapshots.filter((snapshot) => Date.parse(snapshot.observed_at) <= Date.parse(endedAt)).at(-1);
406
+ const before = snapshots.filter((snapshot) => Date.parse(snapshot.observed_at) <= Date.parse(startedAt)).at(-1);
407
+ if (!last || !before || last === before)
408
+ return { status: "not_yet_emitted", diagnostic: `${harness}_usage_window_incomplete` };
409
+ const delta = (value, baseline) => value === null || baseline === null ? null : Math.max(0, value - baseline);
410
+ const input = delta(last.input_tokens, before.input_tokens);
411
+ const cacheRead = delta(last.cache_read_input_tokens, before.cache_read_input_tokens);
412
+ return {
413
+ status: "measured",
414
+ counter: {
415
+ usage: {
416
+ total_tokens: Math.max(0, last.total_tokens - before.total_tokens),
417
+ input_tokens: input,
418
+ cache_read_input_tokens: cacheRead,
419
+ cache_write_input_tokens: delta(last.cache_write_input_tokens, before.cache_write_input_tokens),
420
+ uncached_input_tokens: input === null || cacheRead === null ? null : Math.max(0, input - cacheRead),
421
+ output_tokens: delta(last.output_tokens, before.output_tokens),
422
+ reasoning_output_tokens: delta(last.reasoning_output_tokens, before.reasoning_output_tokens)
423
+ },
424
+ token_event_at: last.observed_at,
425
+ turn_id: null,
426
+ turn_attribution: "unavailable"
427
+ }
428
+ };
429
+ }
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
+ function settledRunSessions(context, links, observedAt) {
440
+ const sessions = new Map();
441
+ for (const link of links)
442
+ sessions.set(link.session_id, [...(sessions.get(link.session_id) ?? []), link]);
443
+ return [...sessions.entries()].flatMap(([sessionId, sessionLinks]) => {
444
+ if (sessionLinks.some((link) => !link.completed_at))
445
+ return [];
446
+ 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) {
449
+ const snapshot = context.db.get(`SELECT observed_at FROM harness_usage_snapshots
450
+ 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]);
453
+ return snapshot ? [{ session_id: sessionId, completed_at: snapshot.observed_at }] : [];
454
+ }
455
+ const completed = completedProviderTurn(sessionLinks[0].transcript_path, sessionId);
456
+ if (!completed || (latestWorkFinish !== undefined && Date.parse(completed.completed_at) < latestWorkFinish))
457
+ return [];
458
+ return [{ session_id: sessionId, completed_at: completed.completed_at }];
459
+ });
460
+ }
461
+ /** The final lifecycle event must be task_complete; a later task_started means the Session is live again. */
462
+ function completedProviderTurn(transcriptPath, expectedSessionId) {
463
+ if (!transcriptPath || !fs.existsSync(transcriptPath))
464
+ return null;
465
+ try {
466
+ let sessionId;
467
+ let latestLifecycle = null;
468
+ for (const line of fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/)) {
469
+ if (!line.trim())
470
+ continue;
471
+ let event;
472
+ try {
473
+ event = JSON.parse(line);
474
+ }
475
+ catch {
476
+ continue;
477
+ }
478
+ const payload = object(event.payload);
479
+ if (event.type === "session_meta")
480
+ sessionId ??= stringValue(payload?.id);
481
+ const type = event.type === "event_msg" ? stringValue(payload?.type) : null;
482
+ const at = stringValue(event.timestamp);
483
+ if ((type === "task_started" || type === "task_complete") && at)
484
+ latestLifecycle = { type, at };
485
+ }
486
+ if (sessionId && sessionId !== expectedSessionId)
487
+ return null;
488
+ return latestLifecycle?.type === "task_complete" ? { completed_at: latestLifecycle.at } : null;
489
+ }
490
+ catch {
491
+ return null;
492
+ }
493
+ }
494
+ function usageReport(context, input, refresh) {
58
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
59
- FROM flow_sessions WHERE project_id = ? AND run_id = ?
496
+ FROM sessions WHERE project_id = ? AND run_id = ?
60
497
  UNION
61
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
62
- FROM flow_session_segments s JOIN flow_sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
499
+ FROM flow_session_segments s JOIN sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
63
500
  WHERE s.project_id = ? AND s.run_id = ?`, [input.runId, input.projectId, input.runId, input.runId, input.projectId, input.runId]);
64
- const refreshed = sessions.map((session) => checkpointSessionUsage(context, session, { checkpoint: "manual_sync" }));
501
+ const selectedSessions = input.sessionId ? sessions.filter((session) => session.session_id === input.sessionId) : sessions;
502
+ if (input.sessionId && selectedSessions.length === 0) {
503
+ throw new Error(`Session is not associated with RUN: ${input.sessionId}`);
504
+ }
505
+ const refreshed = refresh ? selectedSessions.map((session) => checkpointSessionUsage(context, session, { checkpoint: "manual_sync" })) : [];
65
506
  const rows = context.db.all(`SELECT s.*, f.parent_session_id, f.role, f.aspect_id, f.plan_item_id, f.session_kind
66
- FROM flow_run_usage_snapshots s
67
- LEFT JOIN flow_sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
507
+ FROM usage s
508
+ LEFT JOIN sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
68
509
  WHERE s.project_id = ? AND s.run_id = ? ORDER BY s.session_id, s.observed_at, s.id`, [input.projectId, input.runId]);
69
- const deltas = usageDeltas(rows);
510
+ const selectedRows = input.sessionId ? rows.filter((row) => row.session_id === input.sessionId) : rows;
511
+ const deltas = usageDeltas(selectedRows);
70
512
  const segments = context.db.all(`SELECT id, session_id, run_id, protocol_id, started_at, ended_at, cwd, tool_name
71
513
  FROM flow_session_segments WHERE project_id = ? AND run_id = ? ORDER BY started_at, id`, [input.projectId, input.runId]);
72
514
  const supported = new Set(["session", "role", "aspect", "plan-item", "stage", "protocol"]);
@@ -87,13 +529,14 @@ export function usageForRun(context, input) {
87
529
  ok: true,
88
530
  schema_id: "dd-flow/run-usage@1",
89
531
  run_id: input.runId,
90
- source: { kind: "codex_transcript_v1", parser_version: 1, refreshed_sessions: refreshed.length },
91
- sessions: sessions.map((session) => ({
532
+ source: { kind: "codex_transcript_v1", parser_version: 1, refreshed_sessions: refreshed.length, checkpoint_source: refresh ? "refreshed" : "stored" },
533
+ sessions: selectedSessions.map((session) => ({
92
534
  session_id: session.session_id, parent_session_id: session.parent_session_id ?? null, role: session.role ?? null,
93
535
  aspect_id: session.aspect_id ?? null, plan_item_id: session.plan_item_id ?? null, session_kind: session.session_kind ?? null
94
536
  })),
95
537
  groups: [...groups.entries()].map(([key, group]) => ({ key, ...group, tokens: outputUsageTotals(group.tokens) })),
96
538
  deltas,
539
+ tool_calls: toolCallsForSessions(sessions),
97
540
  segments: segments.map((segment) => ({
98
541
  segment_id: segment.id,
99
542
  session_id: segment.session_id,
@@ -101,11 +544,70 @@ export function usageForRun(context, input) {
101
544
  protocol_id: segment.protocol_id,
102
545
  started_at: segment.started_at,
103
546
  ended_at: segment.ended_at,
104
- usage: segmentUsage(context, sessions.find((session) => session.session_id === segment.session_id), segment)
547
+ usage: segmentUsage(context, selectedSessions.find((session) => session.session_id === segment.session_id), segment)
105
548
  })),
106
- coverage: coverageForRows(rows)
549
+ coverage: coverageForRows(selectedRows)
107
550
  };
108
551
  }
552
+ function toolCallsForSessions(sessions) {
553
+ const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
554
+ for (const session of sessions) {
555
+ const parsed = readToolCalls(session.transcript_path, session.session_id);
556
+ if (!parsed) {
557
+ result.unavailable_sessions.push(session.session_id);
558
+ continue;
559
+ }
560
+ result.observed_sessions += 1;
561
+ result.total += parsed.total;
562
+ result.failures += parsed.failures;
563
+ for (const [name, count] of Object.entries(parsed.by_tool))
564
+ result.by_tool[name] = (result.by_tool[name] ?? 0) + count;
565
+ }
566
+ result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length === 0 ? "measured" : "partial";
567
+ return result;
568
+ }
569
+ function readToolCalls(transcriptPath, expectedSessionId) {
570
+ if (!transcriptPath || !fs.existsSync(transcriptPath))
571
+ return null;
572
+ try {
573
+ let sessionId;
574
+ const byTool = {};
575
+ let total = 0;
576
+ let failures = 0;
577
+ for (const line of fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/)) {
578
+ if (!line.trim())
579
+ continue;
580
+ let event;
581
+ try {
582
+ event = JSON.parse(line);
583
+ }
584
+ catch {
585
+ continue;
586
+ }
587
+ const payload = object(event.payload);
588
+ if (!payload)
589
+ continue;
590
+ if (event.type === "session_meta")
591
+ sessionId = stringValue(payload.id) ?? sessionId;
592
+ if (event.type !== "response_item")
593
+ continue;
594
+ const type = stringValue(payload.type);
595
+ if (type === "function_call" || type === "custom_tool_call") {
596
+ const name = stringValue(payload.name) ?? "unknown";
597
+ byTool[name] = (byTool[name] ?? 0) + 1;
598
+ total += 1;
599
+ }
600
+ if ((type === "function_call_output" || type === "custom_tool_call_output") && outputIndicatesFailure(payload))
601
+ failures += 1;
602
+ }
603
+ if (sessionId && sessionId !== expectedSessionId)
604
+ return null;
605
+ return { total, failures, by_tool: Object.fromEntries(Object.entries(byTool).sort(([left], [right]) => left.localeCompare(right))) };
606
+ }
607
+ catch {
608
+ return null;
609
+ }
610
+ }
109
611
  function segmentUsage(context, session, segment) {
110
612
  if (!session)
111
613
  return { status: "unavailable", diagnostic: "session_record_missing" };
@@ -134,7 +636,7 @@ function readCodexTranscript(transcriptPath, expectedSessionId) {
134
636
  }
135
637
  const payload = object(event.payload);
136
638
  if (event.type === "session_meta")
137
- sessionId = stringValue(payload?.id) ?? sessionId;
639
+ sessionId ??= stringValue(payload?.id);
138
640
  if (event.type === "event_msg" && payload?.type === "task_started")
139
641
  nearestTurn = stringValue(payload.turn_id) ?? nearestTurn;
140
642
  if (event.type !== "event_msg" || payload?.type !== "token_count")
@@ -195,7 +697,7 @@ function readTranscriptEvents(transcriptPath, expectedSessionId) {
195
697
  }
196
698
  const payload = object(event.payload);
197
699
  if (event.type === "session_meta")
198
- sessionId = stringValue(payload?.id) ?? sessionId;
700
+ sessionId ??= stringValue(payload?.id);
199
701
  if (event.type === "event_msg" && payload?.type === "task_started")
200
702
  nearestTurn = stringValue(payload.turn_id) ?? nearestTurn;
201
703
  if (event.type !== "event_msg" || payload?.type !== "token_count")
@@ -293,6 +795,12 @@ function snapshotForOutput(snapshot) {
293
795
  delete safe.cached_input_tokens;
294
796
  return { schema_id: "dd-flow/session-usage-snapshot@1", ...safe };
295
797
  }
798
+ function sourceFacts(file) {
799
+ if (!file || !fs.existsSync(file))
800
+ return { locator: file, sha256: null, size: null, mtime: null };
801
+ const stat = fs.statSync(file);
802
+ return { locator: file, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"), size: stat.size, mtime: Math.trunc(stat.mtimeMs) };
803
+ }
296
804
  function sameUsageSnapshot(previous, current) {
297
805
  return previous.stage === current.stage
298
806
  && previous.stage_attempt === current.stage_attempt