pan-wizard 3.20.0 → 3.21.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.
@@ -20,6 +20,48 @@ const METRICS_DIR = 'metrics';
20
20
  const TOKENS_FILE = 'tokens.jsonl';
21
21
  const CURSOR_FILE = '.cost-cursor.json';
22
22
 
23
+ // Ledger row schema version. Bump when the record shape changes so readers can
24
+ // tell which shape a row was written in (pre-versioned rows read as v1). Kept as
25
+ // a literal in both hooks + cost.cjs — the hooks are standalone zero-dep scripts
26
+ // that can't import from pan-wizard-core, so this MUST stay in sync by hand.
27
+ const SCHEMA_V = 2;
28
+
29
+ // Reverse-map a resolved model id to its cost tier so the "By tier" dashboard
30
+ // section isn't blind on the hook path. Anthropic families only (the tiers PAN
31
+ // resolves); unknown models stay null rather than guess.
32
+ function tierForModel(model) {
33
+ if (typeof model !== 'string' || !model) return null;
34
+ if (/opus|fable|mythos/i.test(model)) return 'reasoning';
35
+ if (/sonnet/i.test(model)) return 'mid';
36
+ if (/haiku/i.test(model)) return 'fast';
37
+ return null;
38
+ }
39
+
40
+ // Best-effort read of the active trace session's command/phase so hook rows can
41
+ // be attributed to the command that spawned them. current-session →
42
+ // traces/<sid>/session.json (both written by the trace logger / optimize.cjs).
43
+ // Never throws — returns {} on any miss.
44
+ function readActiveSessionMeta(cwd) {
45
+ try {
46
+ const optDir = path.join(cwd, '.planning', 'optimization');
47
+ const sid = fs.readFileSync(path.join(optDir, 'current-session'), 'utf-8').trim();
48
+ if (!sid) return {};
49
+ const meta = JSON.parse(fs.readFileSync(path.join(optDir, 'traces', sid, 'session.json'), 'utf-8'));
50
+ return meta && typeof meta === 'object' ? meta : {};
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ // Duration of a transcript slice from its first→last record timestamp. Returns
57
+ // null when either bound is absent/unparseable — never a fabricated 0.
58
+ function durationFromSpan(firstTs, lastTs) {
59
+ if (!firstTs || !lastTs) return null;
60
+ const a = Date.parse(firstTs);
61
+ const b = Date.parse(lastTs);
62
+ return Number.isFinite(a) && Number.isFinite(b) ? b - a : null;
63
+ }
64
+
23
65
  // Per-transcript high-water mark: the count of JSONL records already attributed
24
66
  // to earlier SubagentStop events, keyed by transcript path. Each event then sums
25
67
  // ONLY its own slice (records past the cursor) instead of re-summing the whole
@@ -69,6 +111,12 @@ function buildCostRecord(data, cwd) {
69
111
  let outputTokens = 0;
70
112
  let cacheRead = 0;
71
113
  let cacheWrite = 0;
114
+ let durationMs = null;
115
+ // token_source records WHICH path produced the counts; clamped marks a value
116
+ // dropped to 0 by the plausibility guard so a guarded zero is distinguishable
117
+ // from a genuine zero-token run.
118
+ let tokenSource = data.transcript_path ? 'transcript' : 'usage-fallback';
119
+ let clamped = false;
72
120
  if (data.transcript_path) {
73
121
  const cursor = readCursor(cwd);
74
122
  const since = cursor[data.transcript_path] || 0;
@@ -77,6 +125,7 @@ function buildCostRecord(data, cwd) {
77
125
  outputTokens = fromTranscript.output_tokens;
78
126
  cacheRead = fromTranscript.cache_read_input_tokens;
79
127
  cacheWrite = fromTranscript.cache_creation_input_tokens;
128
+ durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts);
80
129
  if (!model) model = fromTranscript.model;
81
130
  // Advance the cursor so the next subagent's record starts fresh — the slices
82
131
  // partition the transcript, so it is never re-summed on every event.
@@ -86,27 +135,43 @@ function buildCostRecord(data, cwd) {
86
135
  }
87
136
  } else {
88
137
  // No transcript to slice — best-effort from data.usage, plausibility-guarded
89
- // so a cumulative counter can never slip through as a per-call value.
90
- inputTokens = clampPlausible(extractNumber(data.usage, 'input_tokens'));
91
- outputTokens = clampPlausible(extractNumber(data.usage, 'output_tokens'));
92
- cacheRead = clampPlausible(extractNumber(data.usage, 'cache_read_input_tokens'));
93
- cacheWrite = clampPlausible(extractNumber(data.usage, 'cache_creation_input_tokens'));
138
+ // so a cumulative counter can never slip through as a per-call value. Flag
139
+ // when the guard actually fired so a dropped value isn't read as a real zero.
140
+ const rawIn = extractNumber(data.usage, 'input_tokens');
141
+ const rawOut = extractNumber(data.usage, 'output_tokens');
142
+ const rawCr = extractNumber(data.usage, 'cache_read_input_tokens');
143
+ const rawCw = extractNumber(data.usage, 'cache_creation_input_tokens');
144
+ inputTokens = clampPlausible(rawIn);
145
+ outputTokens = clampPlausible(rawOut);
146
+ cacheRead = clampPlausible(rawCr);
147
+ cacheWrite = clampPlausible(rawCw);
148
+ clamped = [rawIn, rawOut, rawCr, rawCw].some((n) => n > PLAUSIBLE_MAX);
94
149
  }
95
150
 
151
+ // Backfill command/phase from the active trace session when the payload omits
152
+ // them (real SubagentStop payloads carry neither); tier is derived from the model.
153
+ const sessionMeta = readActiveSessionMeta(cwd);
154
+ const command = data.command || sessionMeta.command || null;
155
+ const phase = data.phase || sessionMeta.phase || null;
156
+
96
157
  const record = {
158
+ v: SCHEMA_V,
97
159
  ts: new Date().toISOString(),
98
160
  agent: data.agent_type || data.subagent_type || null,
99
- command: null,
161
+ command,
100
162
  model,
101
- tier: null,
163
+ tier: tierForModel(model),
102
164
  input_tokens: inputTokens,
103
165
  output_tokens: outputTokens,
104
166
  cache_read_tokens: cacheRead,
105
167
  cache_write_tokens: cacheWrite,
106
168
  cost_usd: null,
107
- phase: data.phase || null,
169
+ duration_ms: durationMs,
170
+ phase,
108
171
  session: data.session_id || null,
109
172
  source: 'hook',
173
+ token_source: tokenSource,
174
+ clamped,
110
175
  };
111
176
 
112
177
  return record;
@@ -148,6 +213,8 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
148
213
  cache_read_input_tokens: 0,
149
214
  cache_creation_input_tokens: 0,
150
215
  model: null,
216
+ first_ts: null,
217
+ last_ts: null,
151
218
  lineCount: 0,
152
219
  };
153
220
  if (!transcriptPath || typeof transcriptPath !== 'string') return totals;
@@ -161,6 +228,13 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
161
228
  let entry;
162
229
  try { entry = JSON.parse(line); } catch { continue; }
163
230
  if (sessionId && entry.session_id && entry.session_id !== sessionId) continue;
231
+ // Span of THIS subagent's slice (after the session filter) — first→last
232
+ // record timestamp gives a measured runtime rather than an idle-gap proxy.
233
+ const entryTs = typeof entry.timestamp === 'string' ? entry.timestamp : null;
234
+ if (entryTs) {
235
+ if (!totals.first_ts) totals.first_ts = entryTs;
236
+ totals.last_ts = entryTs;
237
+ }
164
238
  // Assistant messages carry the model id alongside their usage — keep the
165
239
  // last one seen (mid-session model switches resolve to the final model).
166
240
  const entryModel = entry.message?.model || entry.model || null;
@@ -23,6 +23,68 @@ const TRACES_DIR = 'traces';
23
23
  const CURRENT_SESSION_FILE = 'current-session';
24
24
  const TRACE_EVENT_FILE = 'trace.jsonl';
25
25
 
26
+ // Trace event schema version — kept in sync by hand with pan-cost-logger.js +
27
+ // cost.cjs (standalone zero-dep hooks can't share a module). See that file.
28
+ const SCHEMA_V = 2;
29
+
30
+ // YYYYMMDD stamp for a Date (the day-scope of an auto-session id).
31
+ function dayStamp(d) {
32
+ return d.toISOString().replace(/[-:T]/g, '').slice(0, 8);
33
+ }
34
+
35
+ // Duration of a transcript slice from its first→last record timestamp; null when
36
+ // either bound is missing/unparseable (never a fabricated 0).
37
+ function durationFromSpan(firstTs, lastTs) {
38
+ if (!firstTs || !lastTs) return null;
39
+ const a = Date.parse(firstTs);
40
+ const b = Date.parse(lastTs);
41
+ return Number.isFinite(a) && Number.isFinite(b) ? b - a : null;
42
+ }
43
+
44
+ // Read a session's persisted command/phase (written by optimize.cjs
45
+ // initTraceSession) so hook events can inherit them. Best-effort → {} on miss.
46
+ function readSessionMetaById(cwd, sid) {
47
+ try {
48
+ const meta = JSON.parse(fs.readFileSync(path.join(getTracesDir(cwd), sid, 'session.json'), 'utf-8'));
49
+ return meta && typeof meta === 'object' ? meta : {};
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ // Finalize a session in place: recompute event_count / type_counts / agents from
56
+ // its trace.jsonl and stamp ended_at. Inlined (the hook can't import optimize.cjs)
57
+ // and mirrors optimize.cjs endTraceSession's count loop so day-rollover leaves a
58
+ // properly-closed session behind. Best-effort; never throws.
59
+ function finalizeSession(cwd, sid) {
60
+ try {
61
+ const sessionDir = path.join(getTracesDir(cwd), sid);
62
+ const metaPath = path.join(sessionDir, 'session.json');
63
+ let meta = {};
64
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { return; }
65
+ let eventCount = 0;
66
+ const agentNames = new Set();
67
+ const typeCounts = {};
68
+ try {
69
+ const raw = fs.readFileSync(path.join(sessionDir, TRACE_EVENT_FILE), 'utf-8');
70
+ raw.trim().split('\n').filter(Boolean).forEach((line) => {
71
+ try {
72
+ const e = JSON.parse(line);
73
+ eventCount++;
74
+ if (e.agent) agentNames.add(e.agent);
75
+ typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
76
+ } catch { /* skip malformed */ }
77
+ });
78
+ } catch { /* no trace.jsonl */ }
79
+ meta.ended_at = new Date().toISOString();
80
+ meta.event_count = eventCount;
81
+ meta.agent_count = agentNames.size;
82
+ meta.agents = Array.from(agentNames);
83
+ meta.type_counts = typeCounts;
84
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
85
+ } catch { /* best-effort */ }
86
+ }
87
+
26
88
  function getOptimizeDir(cwd) {
27
89
  return path.join(cwd, PLANNING_DIR, OPTIMIZE_DIR);
28
90
  }
@@ -65,12 +127,23 @@ function writeTraceCursor(cwd, cursor) {
65
127
  * @returns {string} The active session ID
66
128
  */
67
129
  function ensureSessionId(cwd) {
130
+ const now = new Date();
131
+ const stamp = dayStamp(now); // YYYYMMDD
68
132
  const existing = getCurrentSessionId(cwd);
69
- if (existing) return existing;
133
+ if (existing) {
134
+ // Day-rollover: a stale day-scoped auto-session from a previous day must not
135
+ // keep accumulating today's rows. Finalize it and mint a fresh one. Explicit
136
+ // (non-auto) sessions stay sticky — only auto-sessions roll over.
137
+ const m = /^sess_auto_(\d{8})$/.exec(existing);
138
+ if (m && m[1] !== stamp) {
139
+ finalizeSession(cwd, existing);
140
+ // fall through to mint a new day-scoped session below
141
+ } else {
142
+ return existing;
143
+ }
144
+ }
70
145
 
71
146
  // Create a day-scoped auto session
72
- const now = new Date();
73
- const stamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 8); // YYYYMMDD
74
147
  const sessionId = `sess_auto_${stamp}`;
75
148
  try {
76
149
  const sessionDir = path.join(getTracesDir(cwd), sessionId);
@@ -131,6 +204,9 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
131
204
  output_tokens: 0,
132
205
  cache_read_input_tokens: 0,
133
206
  cache_creation_input_tokens: 0,
207
+ model: null,
208
+ first_ts: null,
209
+ last_ts: null,
134
210
  lineCount: 0,
135
211
  };
136
212
  if (!transcriptPath || typeof transcriptPath !== 'string') return totals;
@@ -154,6 +230,15 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
154
230
  // Filter to entries from this subagent if a session_id is provided.
155
231
  // The transcript may include parent + child traffic; session_id discriminates.
156
232
  if (sessionId && entry.session_id && entry.session_id !== sessionId) continue;
233
+ // Span of this subagent's slice (after the session filter) for duration_ms,
234
+ // and the model id (mirrors pan-cost-logger — keep the last model seen).
235
+ const entryTs = typeof entry.timestamp === 'string' ? entry.timestamp : null;
236
+ if (entryTs) {
237
+ if (!totals.first_ts) totals.first_ts = entryTs;
238
+ totals.last_ts = entryTs;
239
+ }
240
+ const entryModel = entry.message?.model || entry.model || null;
241
+ if (typeof entryModel === 'string' && entryModel) totals.model = entryModel;
157
242
  // Usage typically lives on assistant message records.
158
243
  const usage = entry.usage
159
244
  || entry.message?.usage
@@ -196,9 +281,13 @@ function buildTraceEvents(data, sessionId, cwd) {
196
281
  // logging it verbatim produced impossible per-row magnitudes (see pan-cost-logger
197
282
  // for the full rationale). The slice is authoritative; data.usage is only a
198
283
  // plausibility-guarded fallback when no transcript is available.
284
+ let model = typeof data.model === 'string' && data.model ? data.model : null;
199
285
  let inputTokens = 0;
200
286
  let outputTokens = 0;
201
287
  let cacheRead = 0;
288
+ let durationMs = null;
289
+ let tokenSource = data.transcript_path ? 'transcript' : 'usage-fallback';
290
+ let clamped = false;
202
291
  if (data.transcript_path) {
203
292
  const cursor = readTraceCursor(cwd);
204
293
  const since = cursor[data.transcript_path] || 0;
@@ -206,35 +295,52 @@ function buildTraceEvents(data, sessionId, cwd) {
206
295
  inputTokens = fromTranscript.input_tokens;
207
296
  outputTokens = fromTranscript.output_tokens;
208
297
  cacheRead = fromTranscript.cache_read_input_tokens;
298
+ durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts);
299
+ if (!model) model = fromTranscript.model;
209
300
  if (cwd && fromTranscript.lineCount > since) {
210
301
  cursor[data.transcript_path] = fromTranscript.lineCount;
211
302
  writeTraceCursor(cwd, cursor);
212
303
  }
213
304
  } else {
214
- inputTokens = clampPlausible(extractNumber(data.usage, 'input_tokens'));
215
- outputTokens = clampPlausible(extractNumber(data.usage, 'output_tokens'));
216
- cacheRead = clampPlausible(extractNumber(data.usage, 'cache_read_input_tokens'));
305
+ const rawIn = extractNumber(data.usage, 'input_tokens');
306
+ const rawOut = extractNumber(data.usage, 'output_tokens');
307
+ const rawCr = extractNumber(data.usage, 'cache_read_input_tokens');
308
+ inputTokens = clampPlausible(rawIn);
309
+ outputTokens = clampPlausible(rawOut);
310
+ cacheRead = clampPlausible(rawCr);
311
+ clamped = [rawIn, rawOut, rawCr].some((n) => n > PLAUSIBLE_MAX);
217
312
  }
218
313
  const totalTokens = inputTokens + outputTokens;
219
314
 
315
+ // Inherit command/phase from the active session when the payload omits them
316
+ // (mirrors optimize.cjs logTraceEvent's W3 phase-inheritance, which the hook
317
+ // path otherwise bypasses).
318
+ const sessionMeta = cwd ? readSessionMetaById(cwd, sessionId) : {};
319
+ const phase = data.phase || sessionMeta.phase || null;
320
+
220
321
  const events = [];
221
322
 
222
323
  // Core completion event
223
324
  events.push({
325
+ v: SCHEMA_V,
224
326
  ts,
225
327
  session: sessionId,
226
328
  agent,
227
- phase: data.phase || null,
329
+ phase,
228
330
  type: 'decision',
229
331
  category: 'agent_completion',
230
332
  description: `${agent} completed`,
231
333
  context: {
232
- model: data.model || null,
334
+ model,
335
+ command: data.command || sessionMeta.command || null,
233
336
  input_tokens: inputTokens,
234
337
  output_tokens: outputTokens,
235
338
  cache_read_tokens: cacheRead,
236
339
  total_tokens: totalTokens,
340
+ duration_ms: durationMs,
237
341
  exit_code: data.exit_code || 0,
342
+ token_source: tokenSource,
343
+ clamped,
238
344
  },
239
345
  impact: 'trivial',
240
346
  correction: null,
@@ -245,10 +351,11 @@ function buildTraceEvents(data, sessionId, cwd) {
245
351
  // (expensive agent run that wasn't cached — may be repeated research)
246
352
  if (outputTokens > 3000 && cacheRead === 0) {
247
353
  events.push({
354
+ v: SCHEMA_V,
248
355
  ts,
249
356
  session: sessionId,
250
357
  agent,
251
- phase: data.phase || null,
358
+ phase,
252
359
  type: 'redundancy',
253
360
  category: 'uncached_heavy_run',
254
361
  description: `${agent} produced ${outputTokens} output tokens with zero cache hits — possible repeated research`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.20.0",
3
+ "version": "3.21.0",
4
4
  "description": "Command a bot army for your codebase: an Opus Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -13,7 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { output, error } = require('./core.cjs');
16
- const { PLANNING_DIR } = require('./constants.cjs');
16
+ const { PLANNING_DIR, VERIFY_RESERVE_FRACTION } = require('./constants.cjs');
17
17
 
18
18
  const ORCH_DIR = 'orchestration';
19
19
  const SCHEDULE_FILE = 'schedule.json';
@@ -169,12 +169,19 @@ function cmdCampaignStatus(cwd, raw) {
169
169
  if (!schedule) return output({ scheduled: false }, raw, 'No campaign scheduled');
170
170
  const at = new Date();
171
171
  const d = isRunDue(schedule, at);
172
+ // Advisory verify-reserve indicators (status only — does not move the isRunDue
173
+ // trip threshold): how much of the daily budget is held back for re-verification
174
+ // and whether today's spend has crossed into it.
175
+ const fraction = typeof schedule.verify_reserve === 'number' ? schedule.verify_reserve : VERIFY_RESERVE_FRACTION;
176
+ const verifyReserve = schedule.daily_budget != null && fraction > 0 ? Math.ceil(schedule.daily_budget * fraction) : 0;
177
+ const intoReserve = verifyReserve > 0 && schedule.daily_budget != null && d.spent_today >= schedule.daily_budget - verifyReserve;
172
178
  const result = {
173
179
  scheduled: true, enabled: schedule.enabled, paused: schedule.paused,
174
180
  cadence: schedule.cadence, daily_budget: schedule.daily_budget,
175
181
  next_due: schedule.next_due, last_run: schedule.last_run,
176
182
  spent_today: d.spent_today, runs: (schedule.history || []).length,
177
183
  due: d.due, reason: d.reason,
184
+ verify_reserve: verifyReserve, into_verify_reserve: intoReserve,
178
185
  };
179
186
  const human = `Campaign ${schedule.enabled ? (schedule.paused ? 'paused' : 'active') : 'disabled'} · ${schedule.cadence} · spent ${d.spent_today}/${schedule.daily_budget} today · next ${schedule.next_due} · ${d.due ? 'DUE NOW' : d.reason}`;
180
187
  output(result, raw, human);
@@ -66,6 +66,9 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
66
66
  // (HUD, telemetry) but never STOP a run. Set enforce:true (or pass
67
67
  // --enforce-budget) to make the cap a hard stop again.
68
68
  enforce: false,
69
+ // Fraction of the spawn budget held back for re-verification (0..0.5).
70
+ // Surfaced as an indicator always; a hard early stop only under enforce.
71
+ verify_reserve: 0.15,
69
72
  },
70
73
  commit: {
71
74
  safety_checks: true,
@@ -183,6 +183,12 @@ const DEFAULT_MAX_CYCLES = 10;
183
183
 
184
184
  /** Default cumulative budget cap for auto-runner */
185
185
  const DEFAULT_TOTAL_BUDGET = 500;
186
+ // Fraction of the spawn budget held back for re-verification so a run can't
187
+ // exhaust its points on Build/Execute before the quality gate re-checks the
188
+ // work. Advisory by default (an indicator); a hard early stop only under
189
+ // --enforce-budget. FLOOR guards tiny budgets from a zero reserve.
190
+ const VERIFY_RESERVE_FRACTION = 0.15;
191
+ const VERIFY_RESERVE_FLOOR = 1;
186
192
 
187
193
  // ─── Standards ──────────────────────────────────────────────────────────────
188
194
 
@@ -717,6 +723,8 @@ module.exports = {
717
723
  CATEGORY_DEFAULTS,
718
724
  DEFAULT_MAX_CYCLES,
719
725
  DEFAULT_TOTAL_BUDGET,
726
+ VERIFY_RESERVE_FRACTION,
727
+ VERIFY_RESERVE_FLOOR,
720
728
  // Standards
721
729
  STANDARDS_FILE,
722
730
  STANDARDS_CATEGORIES,
@@ -296,6 +296,9 @@ function loadConfig(cwd) {
296
296
  model_overrides: parsed.model_overrides || {},
297
297
  effort_overrides: parsed.effort_overrides || {},
298
298
  routing: parsed.routing || { strategy: 'static', provider: 'auto' },
299
+ // Cost dashboard config: `cost.rates` per-model overrides (surfaced so the
300
+ // documented override actually reaches cost.cjs — it was dropped before).
301
+ cost: parsed.cost || {},
299
302
  // ADR-0031: project build/verification commands. null = not configured
300
303
  // (focus-auto --clean-seal then asks or skips rather than guessing).
301
304
  build: parsed.build || null,
@@ -312,6 +315,7 @@ function loadConfig(cwd) {
312
315
  model_overrides: {},
313
316
  effort_overrides: {},
314
317
  routing: { strategy: 'static', provider: 'auto' },
318
+ cost: {},
315
319
  build: null,
316
320
  verification: null,
317
321
  concurrency: { serial_build: false },
@@ -148,10 +148,13 @@ function appendRecord(cwd, rec) {
148
148
  phase: rec.phase || null,
149
149
  session: rec.session || null,
150
150
  };
151
- // Allow caller-supplied cost override; otherwise compute.
151
+ // Allow caller-supplied cost override; otherwise compute at the user's
152
+ // configured rates (config.cost.rates) — without this, CLI-appended rows froze
153
+ // at DEFAULT_RATES while hook rows (cost_usd:null) got config rates at read
154
+ // time, so the two producers priced identical tokens differently.
152
155
  normalized.cost_usd = typeof rec.cost_usd === 'number'
153
156
  ? rec.cost_usd
154
- : computeCost(normalized);
157
+ : computeCost(normalized, loadConfig(cwd)?.cost?.rates);
155
158
 
156
159
  try {
157
160
  fs.mkdirSync(metricsDir(cwd), { recursive: true });
@@ -167,7 +170,16 @@ function appendRecord(cwd, rec) {
167
170
  * @param {string} cwd
168
171
  * @returns {Array<Object>}
169
172
  */
173
+ // Count of malformed (unparseable) rows dropped by the most recent readRecords
174
+ // call. A crash mid-append or interleaved concurrent appends can leave a torn
175
+ // row; surfacing the count (rather than swallowing it) mirrors the existing
176
+ // suspect_excluded contract. Module-level so aggregate() can read it without a
177
+ // breaking change to readRecords' bare-array return (consumed as an array by
178
+ // aggregate, memory.cjs, and hygiene.cjs).
179
+ let _lastReadMalformed = 0;
180
+
170
181
  function readRecords(cwd) {
182
+ _lastReadMalformed = 0;
171
183
  const raw = safeReadFile(tokensFile(cwd));
172
184
  if (!raw) return [];
173
185
  const records = [];
@@ -175,7 +187,7 @@ function readRecords(cwd) {
175
187
  if (!line.trim()) continue;
176
188
  try {
177
189
  records.push(JSON.parse(line));
178
- } catch { /* skip malformed line */ }
190
+ } catch { _lastReadMalformed += 1; }
179
191
  }
180
192
  return records;
181
193
  }
@@ -207,6 +219,7 @@ function isSuspectRecord(r) {
207
219
 
208
220
  function aggregate(cwd, opts) {
209
221
  const records = readRecords(cwd);
222
+ const malformedSkipped = _lastReadMalformed; // captured before any later read
210
223
  const since = opts?.since ? new Date(opts.since).getTime() : null;
211
224
  const until = opts?.until ? new Date(opts.until).getTime() : null;
212
225
  const config = loadConfig(cwd);
@@ -229,6 +242,7 @@ function aggregate(cwd, opts) {
229
242
  cost_usd: 0,
230
243
  cost_unknown: 0,
231
244
  suspect_excluded: 0,
245
+ malformed_skipped: malformedSkipped,
232
246
  };
233
247
 
234
248
  const byAgent = {};
@@ -305,7 +319,7 @@ function renderTable(agg) {
305
319
  lines.push(window);
306
320
  lines.push('');
307
321
  lines.push('Totals');
308
- lines.push(` Calls : ${agg.totals.calls}`);
322
+ lines.push(` Calls : ${agg.totals.calls}${agg.totals.malformed_skipped > 0 ? ` (+${agg.totals.malformed_skipped} malformed)` : ''}`);
309
323
  lines.push(` Input tokens : ${agg.totals.input_tokens.toLocaleString()}`);
310
324
  lines.push(` Output tokens : ${agg.totals.output_tokens.toLocaleString()}`);
311
325
  lines.push(` Cache read : ${agg.totals.cache_read_tokens.toLocaleString()}`);
@@ -15,7 +15,7 @@ const {
15
15
  BUDGET_LIMIT_BUGFIX, BUDGET_LIMIT_FULL, STABILITY_RATIO, FEATURE_RATIO,
16
16
  DIMINISHING_RETURNS_THRESHOLD,
17
17
  AUTO_RUN_FILE, FOCUS_CATEGORIES, FOCUS_SOURCES, CATEGORY_PRIORITY_RANGE, CATEGORY_DEFAULTS,
18
- DEFAULT_MAX_CYCLES, DEFAULT_TOTAL_BUDGET,
18
+ DEFAULT_MAX_CYCLES, DEFAULT_TOTAL_BUDGET, VERIFY_RESERVE_FRACTION, VERIFY_RESERVE_FLOOR,
19
19
  BUDGET_MIN, BUDGET_MAX, MAX_CYCLES_MIN, MAX_CYCLES_MAX, TOTAL_BUDGET_MIN, TOTAL_BUDGET_MAX,
20
20
  AUTORUN_STATUSES, DOC_SYNC_FILES, COMMAND_RENAME_MAP,
21
21
  } = require('./constants.cjs');
@@ -693,12 +693,28 @@ function generateRunId(cwd) {
693
693
  * @param {boolean} raw - Raw output mode
694
694
  * @param {...string} args - CLI arguments
695
695
  */
696
+ /**
697
+ * Advisory budget indicators for a run: raw remaining, the verify reserve, the
698
+ * remaining budget for NEW work (excludes the reserve), and whether spend has
699
+ * crossed into the reserved headroom. Pure — no behavior change; surfaced so the
700
+ * human/HUD can see the reserve even when it isn't being enforced.
701
+ */
702
+ function budgetIndicators(run) {
703
+ const used = run.totals ? run.totals.points_used : 0;
704
+ const reserve = run.verify_reserve || 0;
705
+ return {
706
+ budget_remaining: run.total_budget - used,
707
+ verify_reserve: reserve,
708
+ new_work_budget_remaining: run.total_budget - reserve - used,
709
+ into_verify_reserve: reserve > 0 && used >= run.total_budget - reserve,
710
+ };
711
+ }
712
+
696
713
  function focusAutoStatus(cwd, raw) {
697
714
  const run = readAutoRun(cwd);
698
715
  if (!run) return error('No auto-run found. Start with: focus auto --category <name>');
699
- const budgetRemaining = run.total_budget - (run.totals ? run.totals.points_used : 0);
700
716
  const cyclesRemaining = run.max_cycles - (run.totals ? run.totals.cycles_completed : 0);
701
- return output({ ...run, budget_remaining: budgetRemaining, cycles_remaining: cyclesRemaining }, raw);
717
+ return output({ ...run, ...budgetIndicators(run), cycles_remaining: cyclesRemaining }, raw);
702
718
  }
703
719
 
704
720
  function focusAutoStop(cwd, raw) {
@@ -781,6 +797,30 @@ function focusAutoUpdate(cwd, raw, getVal) {
781
797
  cycle.tests_verified = tv.verified;
782
798
 
783
799
  if (!run.cycles) run.cycles = [];
800
+
801
+ // Attribution: anchor this cycle to a start, measure its wall-clock duration,
802
+ // name the driving command, and JOIN to the authoritative cost ledger so the
803
+ // cycle carries the agents + dollars behind its item counts (not just a
804
+ // self-reported points estimate). windowStart = the prior cycle's end, or the
805
+ // run start for cycle 1. Server-side join — no fakeable CLI arg.
806
+ const windowStart = run.cycles.length ? run.cycles[run.cycles.length - 1].timestamp : run.started_at;
807
+ cycle.started_at = windowStart || null;
808
+ const durMs = windowStart ? (new Date(cycle.timestamp) - new Date(windowStart)) : NaN;
809
+ cycle.duration_ms = Number.isFinite(durMs) ? durMs : null;
810
+ cycle.command = getVal('--command', run.category || run.source || null);
811
+ cycle.cost_usd = null;
812
+ cycle.tokens = null;
813
+ cycle.agents = [];
814
+ if (windowStart) {
815
+ try {
816
+ const agg = require('./cost.cjs').aggregate(cwd, { since: windowStart, until: cycle.timestamp });
817
+ if (agg && agg.totals) {
818
+ cycle.cost_usd = agg.totals.calls > 0 ? agg.totals.cost_usd : null;
819
+ cycle.tokens = { input: agg.totals.input_tokens, output: agg.totals.output_tokens, cache_read: agg.totals.cache_read_tokens };
820
+ cycle.agents = agg.by_agent ? Object.keys(agg.by_agent) : [];
821
+ }
822
+ } catch { /* cost is observability, never the critical path */ }
823
+ }
784
824
  run.cycles.push(cycle);
785
825
 
786
826
  if (!run.totals) {
@@ -868,7 +908,13 @@ function determineStopReason(cycle, run) {
868
908
  if (cycle.tests_after < cycle.tests_before) return 'regression';
869
909
  // Budget is advisory by default — it only STOPS the run when explicitly enforced.
870
910
  // Otherwise the overage is tracked/surfaced (indication) and the loop continues.
911
+ // Hard cap (full budget) is evaluated FIRST so it remains the absolute boundary;
912
+ // the softer verify-reserve stop only fires in the band below it.
871
913
  if (run.budget_enforce && run.totals.points_used >= run.total_budget) return 'budget_cap';
914
+ // Verify-reserve: under enforcement, stop once new-work spend crosses into the
915
+ // reserved headroom so re-verification still has points. Advisory mode never trips.
916
+ const reserve = run.verify_reserve || 0;
917
+ if (run.budget_enforce && reserve > 0 && run.totals.points_used >= run.total_budget - reserve) return 'budget_reserve_reached';
872
918
  if (run.totals.cycles_completed >= run.max_cycles) return 'max_cycles';
873
919
  if (cycle.items_completed === 0) {
874
920
  // Security category gets a descriptive stop reason rather than generic zero_completed
@@ -905,9 +951,8 @@ function focusAutoContinue(cwd, raw) {
905
951
  run.status = run.totals && run.totals.cycles_completed > 0 ? AUTORUN_STATUSES.IN_PROGRESS : AUTORUN_STATUSES.INITIALIZED;
906
952
  run.stop_reason = null;
907
953
  writeAutoRun(cwd, run);
908
- const budgetRemaining = run.total_budget - (run.totals ? run.totals.points_used : 0);
909
954
  const cyclesRemaining = run.max_cycles - (run.totals ? run.totals.cycles_completed : 0);
910
- return output({ ...run, budget_remaining: budgetRemaining, cycles_remaining: cyclesRemaining }, raw);
955
+ return output({ ...run, ...budgetIndicators(run), cycles_remaining: cyclesRemaining }, raw);
911
956
  }
912
957
 
913
958
  function focusAutoInit(cwd, raw, getVal, hasFlag) {
@@ -938,6 +983,15 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
938
983
  // only when the user opts in via config `budget.enforce` or `--enforce-budget`.
939
984
  const budgetConfig = loadConfig(cwd).budget || {};
940
985
  const budgetEnforce = hasFlag('--enforce-budget') || budgetConfig.enforce === true;
986
+ // Verify-reserve: hold back a fraction of the spawn budget so re-verification
987
+ // isn't starved of points. Advisory by default (surfaced as an indicator);
988
+ // only a hard early stop under --enforce-budget. Clamp the fraction to 0..0.5.
989
+ const reserveRaw = getVal('--verify-reserve', null);
990
+ let reserveFraction = reserveRaw != null ? Number(reserveRaw)
991
+ : (typeof budgetConfig.verify_reserve === 'number' ? budgetConfig.verify_reserve : VERIFY_RESERVE_FRACTION);
992
+ if (!Number.isFinite(reserveFraction) || reserveFraction < 0) reserveFraction = 0;
993
+ if (reserveFraction > 0.5) reserveFraction = 0.5;
994
+ const verifyReserve = reserveFraction > 0 ? Math.max(VERIFY_RESERVE_FLOOR, Math.ceil(totalBudget * reserveFraction)) : 0;
941
995
 
942
996
  if (!FOCUS_MODES.includes(mode)) return error(`Mode must be one of: ${FOCUS_MODES.join(', ')}`);
943
997
  if (budget < BUDGET_MIN || budget > BUDGET_MAX) return error(`Budget must be between ${BUDGET_MIN} and ${BUDGET_MAX}`);
@@ -947,6 +1001,7 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
947
1001
  const runData = {
948
1002
  run_id: generateRunId(cwd),
949
1003
  status: AUTORUN_STATUSES.INITIALIZED,
1004
+ started_at: new Date().toISOString(),
950
1005
  source: source,
951
1006
  category: category,
952
1007
  mode: mode,
@@ -957,6 +1012,7 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
957
1012
  max_cycles: maxCycles,
958
1013
  total_budget: totalBudget,
959
1014
  budget_enforce: budgetEnforce,
1015
+ verify_reserve: verifyReserve,
960
1016
  priority_range: category ? CATEGORY_PRIORITY_RANGE[category] : { min: 0, max: 6 },
961
1017
  deep_review_enabled: hasFlag('--deep-review'),
962
1018
  tests_baseline: null,
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { output, escapeRegex } = require('./core.cjs');
11
+ const { output, escapeRegex, execGit } = require('./core.cjs');
12
12
  const { PLANNING_DIR } = require('./constants.cjs');
13
13
 
14
14
  // ─── Storage layout ──────────────────────────────────────────────────────────
@@ -172,6 +172,65 @@ function logTraceEvent(cwd, event, sessionId) {
172
172
  }
173
173
  }
174
174
 
175
+ /**
176
+ * Recompute a session's counters straight from its trace.jsonl — the single
177
+ * source of truth for event_count/agent_count/agents/type_counts, plus a count
178
+ * of malformed (unparseable) rows that would otherwise vanish silently. Pure
179
+ * read; never writes. Used by endTraceSession, the reconcile subcommand, and the
180
+ * reconcile-on-read overlay so the counting logic lives in exactly one place.
181
+ */
182
+ function reconcileSessionMeta(cwd, sessionId) {
183
+ const sessionDir = path.join(getTracesDir(cwd), sessionId);
184
+ let eventCount = 0;
185
+ let malformed = 0;
186
+ const agentNames = new Set();
187
+ const typeCounts = {};
188
+ try {
189
+ const raw = fs.readFileSync(path.join(sessionDir, TRACE_EVENT_FILE), 'utf-8');
190
+ raw.trim().split('\n').filter(Boolean).forEach(line => {
191
+ let e;
192
+ try { e = JSON.parse(line); } catch { malformed++; return; }
193
+ eventCount++;
194
+ if (e.agent) agentNames.add(e.agent);
195
+ typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
196
+ });
197
+ } catch { /* no trace.jsonl yet */ }
198
+ return {
199
+ event_count: eventCount,
200
+ agent_count: agentNames.size,
201
+ agents: Array.from(agentNames),
202
+ type_counts: typeCounts,
203
+ malformed_count: malformed,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Compute the measured dollar cost + commit count for a session's [started_at,
209
+ * ended_at||now] window, folding the authoritative per-agent cost ledger
210
+ * (tokens.jsonl, suspect rows already quarantined) into the session so the
211
+ * autonomous-overhead metrics work. cost_usd is null when the ledger has no
212
+ * in-window rows; commit_count is null when git is unavailable — never a
213
+ * fabricated 0 (0 would make minutes_per_commit Infinity). Best-effort.
214
+ */
215
+ function computeSessionCostAndCommits(cwd, meta) {
216
+ const out = { cost_usd: null, commit_count: null };
217
+ if (!meta || !meta.started_at) return out;
218
+ const since = meta.started_at;
219
+ const until = meta.ended_at || new Date().toISOString();
220
+ try {
221
+ const cost = require('./cost.cjs');
222
+ const agg = cost.aggregate(cwd, { since, until });
223
+ if (agg && agg.totals && agg.totals.calls > 0) out.cost_usd = agg.totals.cost_usd;
224
+ } catch { /* cost is observability, never the critical path */ }
225
+ try {
226
+ const r = execGit(cwd, ['log', '--oneline', '--since', since, '--until', until]);
227
+ if (r && r.exitCode === 0) {
228
+ out.commit_count = r.stdout ? r.stdout.split('\n').filter(Boolean).length : 0;
229
+ }
230
+ } catch { /* non-repo / git absent → leave null */ }
231
+ return out;
232
+ }
233
+
175
234
  function endTraceSession(cwd, sessionId) {
176
235
  const sid = sessionId || getCurrentSessionId(cwd);
177
236
  if (!sid) return { error: 'No active session' };
@@ -183,35 +242,39 @@ function endTraceSession(cwd, sessionId) {
183
242
  let meta = {};
184
243
  try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch {}
185
244
 
186
- let eventCount = 0;
187
- const agentNames = new Set();
188
- const typeCounts = {};
189
-
190
- try {
191
- const raw = fs.readFileSync(path.join(sessionDir, TRACE_EVENT_FILE), 'utf-8');
192
- raw.trim().split('\n').filter(Boolean).forEach(line => {
193
- try {
194
- const e = JSON.parse(line);
195
- eventCount++;
196
- if (e.agent) agentNames.add(e.agent);
197
- typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
198
- } catch {}
199
- });
200
- } catch {}
201
-
245
+ const counts = reconcileSessionMeta(cwd, sid);
202
246
  meta.ended_at = new Date().toISOString();
203
- meta.event_count = eventCount;
204
- meta.agent_count = agentNames.size;
205
- meta.agents = Array.from(agentNames);
206
- meta.type_counts = typeCounts;
247
+ meta.event_count = counts.event_count;
248
+ meta.agent_count = counts.agent_count;
249
+ meta.agents = counts.agents;
250
+ meta.type_counts = counts.type_counts;
251
+ if (counts.malformed_count) meta.malformed_count = counts.malformed_count;
252
+
253
+ // Fold measured cost + commit count into the session so overhead.* metrics
254
+ // and `optimize stats` carry real dollars, not perpetual nulls.
255
+ const cc = computeSessionCostAndCommits(cwd, meta);
256
+ if (cc.cost_usd != null) meta.cost_usd = cc.cost_usd;
257
+ if (cc.commit_count != null) meta.commit_count = cc.commit_count;
207
258
 
208
259
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
209
260
 
261
+ // Clear the active-session pointer so the next SubagentStop opens a fresh
262
+ // session — but ONLY when we ended the session it points at (ending session
263
+ // A explicitly must not orphan a different active session B).
264
+ try {
265
+ if (getCurrentSessionId(cwd) === sid) {
266
+ fs.unlinkSync(path.join(getOptimizeDir(cwd), CURRENT_SESSION_FILE));
267
+ }
268
+ } catch { /* best-effort */ }
269
+
210
270
  return {
211
271
  session_id: sid,
212
- event_count: eventCount,
213
- agent_count: agentNames.size,
214
- type_counts: typeCounts,
272
+ event_count: counts.event_count,
273
+ agent_count: counts.agent_count,
274
+ type_counts: counts.type_counts,
275
+ malformed_count: counts.malformed_count,
276
+ cost_usd: meta.cost_usd != null ? meta.cost_usd : null,
277
+ commit_count: meta.commit_count != null ? meta.commit_count : null,
215
278
  ended_at: meta.ended_at,
216
279
  };
217
280
  } catch (e) {
@@ -219,6 +282,43 @@ function endTraceSession(cwd, sessionId) {
219
282
  }
220
283
  }
221
284
 
285
+ /**
286
+ * Rewrite session.json from trace.jsonl WITHOUT ending the session (ended_at is
287
+ * left untouched). Powers `optimize trace reconcile`, so hook-driven auto-sessions
288
+ * that never call `end` still get accurate counters.
289
+ */
290
+ function reconcileTraceSession(cwd, sessionId) {
291
+ const sid = sessionId || getCurrentSessionId(cwd);
292
+ if (!sid) return { error: 'No session to reconcile' };
293
+ try {
294
+ const metaPath = path.join(getTracesDir(cwd), sid, OPT_SESSION_FILE);
295
+ let meta = {};
296
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch {}
297
+ const counts = reconcileSessionMeta(cwd, sid);
298
+ meta.event_count = counts.event_count;
299
+ meta.agent_count = counts.agent_count;
300
+ meta.agents = counts.agents;
301
+ meta.type_counts = counts.type_counts;
302
+ if (counts.malformed_count) meta.malformed_count = counts.malformed_count;
303
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
304
+ return { session_id: sid, reconciled: true, event_count: counts.event_count, malformed_count: counts.malformed_count };
305
+ } catch (e) {
306
+ return { error: e.message };
307
+ }
308
+ }
309
+
310
+ /** Reconcile every session dir (used by `optimize trace reconcile --all`). */
311
+ function reconcileAllTraceSessions(cwd) {
312
+ const results = [];
313
+ try {
314
+ const tracesDir = getTracesDir(cwd);
315
+ for (const e of fs.readdirSync(tracesDir, { withFileTypes: true })) {
316
+ if (e.isDirectory() && e.name.startsWith('sess_')) results.push(reconcileTraceSession(cwd, e.name));
317
+ }
318
+ } catch { /* no traces dir */ }
319
+ return { reconciled: results.length, sessions: results };
320
+ }
321
+
222
322
  function readTraceSession(cwd, sessionId) {
223
323
  try {
224
324
  const sessionDir = path.join(getTracesDir(cwd), sessionId);
@@ -229,14 +329,25 @@ function readTraceSession(cwd, sessionId) {
229
329
  } catch {}
230
330
 
231
331
  const events = [];
332
+ let malformed = 0;
232
333
  try {
233
334
  const raw = fs.readFileSync(path.join(sessionDir, TRACE_EVENT_FILE), 'utf-8');
234
335
  raw.trim().split('\n').filter(Boolean).forEach(line => {
235
- try { events.push(JSON.parse(line)); } catch {}
336
+ try { events.push(JSON.parse(line)); } catch { malformed++; }
236
337
  });
237
338
  } catch {}
238
339
 
239
- return { session_id: sessionId, metadata, events, event_count: events.length };
340
+ // Reconcile-on-read: an unfinalized meta (no ended_at) or a stale zero
341
+ // event_count is overlaid with the live counts derived from the events just
342
+ // read, so consumers of metadata (e.g. optimize stats) never see a stale 0.
343
+ if (!metadata.ended_at || !metadata.event_count) {
344
+ const typeCounts = {};
345
+ const agents = new Set();
346
+ for (const e of events) { typeCounts[e.type] = (typeCounts[e.type] || 0) + 1; if (e.agent) agents.add(e.agent); }
347
+ metadata = { ...metadata, event_count: events.length, agent_count: agents.size, agents: Array.from(agents), type_counts: typeCounts };
348
+ }
349
+
350
+ return { session_id: sessionId, metadata, events, event_count: events.length, malformed_count: malformed };
240
351
  } catch (e) {
241
352
  return { error: e.message };
242
353
  }
@@ -256,6 +367,14 @@ function listTraceSessions(cwd) {
256
367
  try {
257
368
  meta = JSON.parse(fs.readFileSync(path.join(sessionDir, OPT_SESSION_FILE), 'utf-8'));
258
369
  } catch {}
370
+ // Reconcile-on-read: unfinalized (no ended_at) or stale-zero sessions —
371
+ // every hook-driven auto-session — get live counts from trace.jsonl so
372
+ // getOptimizeStats doesn't sum perpetual zeros. Finalized sessions with a
373
+ // real count stay cheap (session.json read only).
374
+ if (!meta.ended_at || !meta.event_count) {
375
+ const counts = reconcileSessionMeta(cwd, e.name);
376
+ meta = { ...meta, event_count: counts.event_count, agent_count: counts.agent_count, agents: counts.agents, type_counts: counts.type_counts };
377
+ }
259
378
  return meta;
260
379
  }).sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''));
261
380
 
@@ -291,11 +410,18 @@ function analyzeEvents(events, sessionMeta) {
291
410
  const agentStats = {};
292
411
  events.forEach(e => {
293
412
  if (!e.agent) return;
294
- if (!agentStats[e.agent]) agentStats[e.agent] = { total: 0, errors: 0, gaps: 0, corrections: 0 };
413
+ if (!agentStats[e.agent]) agentStats[e.agent] = { total: 0, errors: 0, gaps: 0, corrections: 0, input_tokens: 0, output_tokens: 0, total_tokens: 0 };
295
414
  agentStats[e.agent].total++;
296
415
  if (e.type === 'error') agentStats[e.agent].errors++;
297
416
  if (e.type === 'gap') agentStats[e.agent].gaps++;
298
417
  if (e.type === 'correction') agentStats[e.agent].corrections++;
418
+ // Sum the per-call tokens the trace logger now writes — ONLY on completion
419
+ // events, so the redundancy event that mirrors output_tokens isn't counted twice.
420
+ if (e.category === 'agent_completion' && e.context) {
421
+ agentStats[e.agent].input_tokens += e.context.input_tokens || 0;
422
+ agentStats[e.agent].output_tokens += e.context.output_tokens || 0;
423
+ agentStats[e.agent].total_tokens += e.context.total_tokens || ((e.context.input_tokens || 0) + (e.context.output_tokens || 0));
424
+ }
299
425
  });
300
426
 
301
427
  Object.keys(agentStats).forEach(a => {
@@ -305,9 +431,20 @@ function analyzeEvents(events, sessionMeta) {
305
431
 
306
432
  const wastedTokens = redundancies.reduce((sum, e) => sum + (e.tokens_wasted || 0), 0);
307
433
 
308
- // ── Timing analysis from wall-clock timestamps ────────────────────────────
309
- // Token data is unavailable (Claude Code SubagentStop doesn't populate usage).
310
- // Use event timestamps + session start/end for meaningful timing analysis.
434
+ // Sum the authoritative per-call tokens (written by the SubagentStop hooks
435
+ // since v3.20.0) across completion events — the redundancy events mirror
436
+ // output_tokens, so restrict to agent_completion to avoid double-counting.
437
+ const completions = events.filter(e => e.category === 'agent_completion' && e.context);
438
+ const tokenTotals = completions.reduce((acc, e) => {
439
+ acc.input += e.context.input_tokens || 0;
440
+ acc.output += e.context.output_tokens || 0;
441
+ acc.cache_read += e.context.cache_read_tokens || 0;
442
+ return acc;
443
+ }, { input: 0, output: 0, cache_read: 0 });
444
+
445
+ // ── Timing analysis ───────────────────────────────────────────────────────
446
+ // Prefer measured per-agent duration_ms (hooks derive it from the transcript
447
+ // slice); fall back to the inter-event wall-clock gap when it's absent.
311
448
  const timing = {};
312
449
 
313
450
  // Session total duration
@@ -381,6 +518,10 @@ function analyzeEvents(events, sessionMeta) {
381
518
  wasted_tokens: wastedTokens,
382
519
  reviewer_corrections: reviewerCorrections.length,
383
520
  memory_primed_count: memoryPrimed.length,
521
+ total_input_tokens: tokenTotals.input,
522
+ total_output_tokens: tokenTotals.output,
523
+ total_cache_read_tokens: tokenTotals.cache_read,
524
+ total_tokens: tokenTotals.input + tokenTotals.output,
384
525
  },
385
526
  timing,
386
527
  overhead,
@@ -405,11 +546,21 @@ function generateLocalReport(cwd, sessionId) {
405
546
  const session = readTraceSession(cwd, sessionId);
406
547
  if (session.error) return session;
407
548
 
549
+ // Fold measured cost + commit count into the metadata before analysis so the
550
+ // autonomous-overhead metrics populate even for hook-driven auto-sessions that
551
+ // never call `optimize trace end`. Only fill fields a producer didn't set.
552
+ const metadata = session.metadata || {};
553
+ if (typeof metadata.cost_usd !== 'number' || typeof metadata.commit_count !== 'number') {
554
+ const cc = computeSessionCostAndCommits(cwd, metadata);
555
+ if (typeof metadata.cost_usd !== 'number' && cc.cost_usd != null) metadata.cost_usd = cc.cost_usd;
556
+ if (typeof metadata.commit_count !== 'number' && cc.commit_count != null) metadata.commit_count = cc.commit_count;
557
+ }
558
+
408
559
  return {
409
560
  session_id: sessionId,
410
561
  generated_at: new Date().toISOString(),
411
- metadata: session.metadata,
412
- ...analyzeEvents(session.events, session.metadata),
562
+ metadata,
563
+ ...analyzeEvents(session.events, metadata),
413
564
  raw_events: session.events,
414
565
  };
415
566
  }
@@ -622,8 +773,12 @@ function cmdOptimizeTrace(cwd, sub, opts, raw) {
622
773
  } else if (sub === 'show') {
623
774
  if (!opts.sessionId) { output({ error: 'Session ID required (--session <id>)' }, raw); return; }
624
775
  output(readTraceSession(cwd, opts.sessionId), raw);
776
+ } else if (sub === 'reconcile') {
777
+ // Rewrite session.json counters from trace.jsonl without ending the session,
778
+ // so hook-driven auto-sessions that never call `end` still report real numbers.
779
+ output(opts.all ? reconcileAllTraceSessions(cwd) : reconcileTraceSession(cwd, opts.sessionId), raw);
625
780
  } else {
626
- output({ error: 'Unknown trace subcommand. Available: init, log, end, current, list, show' }, raw);
781
+ output({ error: 'Unknown trace subcommand. Available: init, log, end, current, list, show, reconcile' }, raw);
627
782
  }
628
783
  }
629
784
 
@@ -1087,6 +1242,10 @@ module.exports = {
1087
1242
  endTraceSession,
1088
1243
  readTraceSession,
1089
1244
  listTraceSessions,
1245
+ reconcileSessionMeta,
1246
+ reconcileTraceSession,
1247
+ reconcileAllTraceSessions,
1248
+ computeSessionCostAndCommits,
1090
1249
  // Analysis
1091
1250
  analyzeEvents,
1092
1251
  generateLocalReport,
@@ -1297,6 +1297,7 @@ async function main() {
1297
1297
  const traceSub = args[2];
1298
1298
  optimize.cmdOptimizeTrace(cwd, traceSub, {
1299
1299
  sessionId: getArgValue(args, '--session'),
1300
+ all: args.includes('--all'),
1300
1301
  description: getArgValue(args, '--description'),
1301
1302
  command: getArgValue(args, '--command'),
1302
1303
  phase: getArgValue(args, '--phase'),