pan-wizard 3.19.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.
@@ -1324,105 +1324,21 @@ return { areas_mapped: maps.filter(Boolean).length, synthesis }
1324
1324
 
1325
1325
  // ─── AGENTS.md universal rules layer (ADR-0028 Phase 3) ─────────────────────
1326
1326
  //
1327
- // AGENTS.md is the cross-runtime project-instructions standard; every PAN
1328
- // target runtime (and Antigravity CLI) reads it natively. PAN contributes one
1329
- // marker-fenced section so agents in any runtime understand the PAN context
1330
- // when reading the repo. User content outside the markers is never touched.
1327
+ // The builders + markers live under pan-wizard-core/ (the single source of
1328
+ // truth, shipped into every install) so the installer and the installed
1329
+ // `pan-tools memory rebuild` regenerate byte-identical content. They are
1330
+ // re-exported here so all existing installer callers and tests keep importing
1331
+ // them from install-lib unchanged.
1331
1332
 
1332
- const PAN_AGENTS_BEGIN = '<!-- BEGIN PAN WIZARD -->';
1333
- const PAN_AGENTS_END = '<!-- END PAN WIZARD -->';
1334
-
1335
- /**
1336
- * Build the PAN section for AGENTS.md (marker-fenced, runtime-neutral).
1337
- * @returns {string} The fenced section, no leading/trailing blank lines.
1338
- */
1339
- function buildAgentsMdSection() {
1340
- return [
1341
- PAN_AGENTS_BEGIN,
1342
- '## PAN Wizard',
1343
- '',
1344
- 'This project uses PAN Wizard for structured, phase-based planning and execution.',
1345
- '',
1346
- '- `.planning/` is PAN\'s state directory (state.md, roadmap.md, phase directories). Treat it as the source of truth for planning state and modify it through PAN commands, not by hand.',
1347
- '- PAN commands install as `pan-*` skills/commands (for example `/pan-help`, `/pan-new-project`, `/pan-exec-phase`). Start with `/pan-help`.',
1348
- '- The `pan-tools` dispatcher backs every command; it lives under `pan-wizard-core/` inside the runtime\'s config directory (or `.agents/` for unified installs).',
1349
- PAN_AGENTS_END,
1350
- ].join('\n');
1351
- }
1352
-
1353
- /**
1354
- * Insert or replace the PAN section in AGENTS.md content.
1355
- * - No existing content (null/empty) → just the section.
1356
- * - Markers present → replace exactly the fenced block, preserving everything
1357
- * around it.
1358
- * - Markers absent → append with a separating blank line.
1359
- * @param {string|null} existing - Current AGENTS.md content, or null if absent
1360
- * @param {string} section - Output of buildAgentsMdSection()
1361
- * @returns {string} New file content (always newline-terminated)
1362
- */
1363
- function upsertAgentsMdSection(existing, section) {
1364
- if (!existing || !existing.trim()) {
1365
- return section + '\n';
1366
- }
1367
- const beginIdx = existing.indexOf(PAN_AGENTS_BEGIN);
1368
- const endIdx = existing.indexOf(PAN_AGENTS_END);
1369
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
1370
- const before = existing.slice(0, beginIdx);
1371
- const after = existing.slice(endIdx + PAN_AGENTS_END.length);
1372
- return before + section + after;
1373
- }
1374
- return existing.trimEnd() + '\n\n' + section + '\n';
1375
- }
1376
-
1377
- /**
1378
- * Remove the PAN section from AGENTS.md content.
1379
- * @param {string} existing - Current AGENTS.md content
1380
- * @returns {string|null} Content without the PAN block, or null when nothing
1381
- * meaningful remains (caller should delete the file).
1382
- */
1383
- function removeAgentsMdSection(existing) {
1384
- if (!existing) return null;
1385
- const beginIdx = existing.indexOf(PAN_AGENTS_BEGIN);
1386
- const endIdx = existing.indexOf(PAN_AGENTS_END);
1387
- if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
1388
- return existing; // no PAN block — leave untouched
1389
- }
1390
- const before = existing.slice(0, beginIdx);
1391
- const after = existing.slice(endIdx + PAN_AGENTS_END.length);
1392
- const remaining = (before.trimEnd() + '\n\n' + after.trimStart()).trim();
1393
- return remaining ? remaining + '\n' : null;
1394
- }
1395
-
1396
- /**
1397
- * Ensure CLAUDE.md bridges to AGENTS.md via a marker-fenced @AGENTS.md import
1398
- * (Claude Code's documented pattern for adopting the universal rules file).
1399
- * Idempotent; preserves all user content.
1400
- * @param {string|null} existing - Current CLAUDE.md content, or null if absent
1401
- * @returns {string} New file content
1402
- */
1403
- function ensureClaudeMdImport(existing) {
1404
- const block = `${PAN_AGENTS_BEGIN}\n@AGENTS.md\n${PAN_AGENTS_END}`;
1405
- if (!existing || !existing.trim()) {
1406
- return block + '\n';
1407
- }
1408
- if (existing.includes(PAN_AGENTS_BEGIN)) {
1409
- return existing; // bridge (or another PAN block) already present
1410
- }
1411
- if (/^@AGENTS\.md\s*$/m.test(existing)) {
1412
- return existing; // user already imports AGENTS.md themselves
1413
- }
1414
- return existing.trimEnd() + '\n\n' + block + '\n';
1415
- }
1416
-
1417
- /**
1418
- * Remove the PAN bridge block from CLAUDE.md content.
1419
- * @param {string} existing - Current CLAUDE.md content
1420
- * @returns {string|null} Content without the bridge, or null when nothing
1421
- * meaningful remains (caller should delete the file).
1422
- */
1423
- function removeClaudeMdImport(existing) {
1424
- return removeAgentsMdSection(existing);
1425
- }
1333
+ const {
1334
+ PAN_AGENTS_BEGIN,
1335
+ PAN_AGENTS_END,
1336
+ buildAgentsMdSection,
1337
+ upsertAgentsMdSection,
1338
+ removeAgentsMdSection,
1339
+ ensureClaudeMdImport,
1340
+ removeClaudeMdImport,
1341
+ } = require('../pan-wizard-core/bin/lib/agents-md.cjs');
1426
1342
 
1427
1343
  // ─── Exports ────────────────────────────────────────────────────────────────
1428
1344
 
@@ -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
@@ -56,57 +98,80 @@ function buildCostRecord(data, cwd) {
56
98
  // Only log actual subagent stops; ignore other Stop variants.
57
99
  if (data.hook_event_name && data.hook_event_name !== 'SubagentStop') return null;
58
100
 
59
- // P-1805 (v3.7.8): if data.usage is missing/empty (Claude Code headless mode
60
- // doesn't include it in the SubagentStop payload), fall back to reading the
61
- // transcript_path JSONL and summing usage across the subagent's messages.
62
- // Same approach as pan-trace-logger.js for consistency.
63
- //
64
- // 2026-06: the SubagentStop payload carries no model id either, which left
65
- // every hook record with model:null and /pan:cost unable to price it. The
66
- // transcript's assistant messages carry message.model right next to the
67
- // usage we already read — capture it whenever data.model is absent.
68
- let inputTokens = extractNumber(data.usage, 'input_tokens');
69
- let outputTokens = extractNumber(data.usage, 'output_tokens');
70
- let cacheRead = extractNumber(data.usage, 'cache_read_input_tokens');
71
- let cacheWrite = extractNumber(data.usage, 'cache_creation_input_tokens');
101
+ // Per-call token counts come from the transcript SLICE — the records since
102
+ // this transcript's previous SubagentStop cursor. The SubagentStop `data.usage`,
103
+ // when Claude Code supplies it, is a CUMULATIVE session counter, NOT this
104
+ // subagent's delta, so logging it verbatim stamped impossible per-row magnitudes
105
+ // (tens of millions of output tokens, billions of cache-read) onto every record
106
+ // and made /pan:cost and the optimizer unusable (field reports 2026-06 / 2026-07).
107
+ // The transcript slice is the authoritative per-invocation delta; `data.usage`
108
+ // is a guarded fallback used only when no transcript is available.
72
109
  let model = typeof data.model === 'string' && data.model ? data.model : null;
73
- const needUsage = (inputTokens + outputTokens + cacheRead + cacheWrite) === 0;
74
- if ((needUsage || !model) && data.transcript_path) {
75
- // Attribute only the transcript slice since the previous SubagentStop for
76
- // this transcript, so a shared-session transcript is never re-summed on
77
- // every event (field report 2026-06 — the billion-token cache-read bug).
110
+ let inputTokens = 0;
111
+ let outputTokens = 0;
112
+ let cacheRead = 0;
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;
120
+ if (data.transcript_path) {
78
121
  const cursor = readCursor(cwd);
79
122
  const since = cursor[data.transcript_path] || 0;
80
123
  const fromTranscript = readUsageFromTranscript(data.transcript_path, data.session_id, since);
81
- if (needUsage) {
82
- inputTokens = fromTranscript.input_tokens;
83
- outputTokens = fromTranscript.output_tokens;
84
- cacheRead = fromTranscript.cache_read_input_tokens;
85
- cacheWrite = fromTranscript.cache_creation_input_tokens;
86
- }
124
+ inputTokens = fromTranscript.input_tokens;
125
+ outputTokens = fromTranscript.output_tokens;
126
+ cacheRead = fromTranscript.cache_read_input_tokens;
127
+ cacheWrite = fromTranscript.cache_creation_input_tokens;
128
+ durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts);
87
129
  if (!model) model = fromTranscript.model;
88
- // Advance the cursor to the end of the transcript so the next subagent's
89
- // record starts fresh (these slices partition the transcript — no overlap).
130
+ // Advance the cursor so the next subagent's record starts fresh — the slices
131
+ // partition the transcript, so it is never re-summed on every event.
90
132
  if (fromTranscript.lineCount > since) {
91
133
  cursor[data.transcript_path] = fromTranscript.lineCount;
92
134
  writeCursor(cwd, cursor);
93
135
  }
136
+ } else {
137
+ // No transcript to slice — best-effort from data.usage, plausibility-guarded
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;
@@ -118,6 +183,15 @@ function extractNumber(obj, key) {
118
183
  return typeof v === 'number' ? v : 0;
119
184
  }
120
185
 
186
+ // A single subagent call's token counts never realistically exceed this; a value
187
+ // above it is a cumulative session counter that leaked in, so we drop it to 0
188
+ // rather than poison the ledger. Generous vs. any real call, tiny vs. the
189
+ // billions/tens-of-millions the cumulative bug produced.
190
+ const PLAUSIBLE_MAX = 20000000;
191
+ function clampPlausible(n) {
192
+ return typeof n === 'number' && n >= 0 && n <= PLAUSIBLE_MAX ? n : 0;
193
+ }
194
+
121
195
  /**
122
196
  * P-1805 (v3.7.8): read transcript JSONL and sum usage across assistant messages.
123
197
  *
@@ -139,6 +213,8 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
139
213
  cache_read_input_tokens: 0,
140
214
  cache_creation_input_tokens: 0,
141
215
  model: null,
216
+ first_ts: null,
217
+ last_ts: null,
142
218
  lineCount: 0,
143
219
  };
144
220
  if (!transcriptPath || typeof transcriptPath !== 'string') return totals;
@@ -152,6 +228,13 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
152
228
  let entry;
153
229
  try { entry = JSON.parse(line); } catch { continue; }
154
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
+ }
155
238
  // Assistant messages carry the model id alongside their usage — keep the
156
239
  // last one seen (mid-session model switches resolve to the final model).
157
240
  const entryModel = entry.message?.model || entry.model || null;
@@ -184,13 +267,34 @@ function appendRecord(cwd, record) {
184
267
  try {
185
268
  const dir = path.join(cwd, '.planning', METRICS_DIR);
186
269
  fs.mkdirSync(dir, { recursive: true });
187
- fs.appendFileSync(path.join(dir, TOKENS_FILE), JSON.stringify(record) + '\n', 'utf-8');
270
+ const file = path.join(dir, TOKENS_FILE);
271
+ // Idempotency guard: a re-fired SubagentStop must not double-log. Skip the
272
+ // append when this record is identical (every field but the timestamp) to
273
+ // the immediately-preceding row — the source of ~57% duplicate rows in the
274
+ // field (2026-07). Best-effort: any read error just proceeds with the append.
275
+ if (isDuplicateOfLastRecord(file, record)) return false;
276
+ fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf-8');
188
277
  return true;
189
278
  } catch {
190
279
  return false;
191
280
  }
192
281
  }
193
282
 
283
+ /** True when `record` equals the last JSONL row of `file`, ignoring `ts`. */
284
+ function isDuplicateOfLastRecord(file, record) {
285
+ let prev;
286
+ try {
287
+ const raw = fs.readFileSync(file, 'utf-8');
288
+ const lines = raw.split('\n').filter(Boolean);
289
+ if (!lines.length) return false;
290
+ prev = JSON.parse(lines[lines.length - 1]);
291
+ } catch {
292
+ return false; // no file / unreadable / bad JSON → not a duplicate
293
+ }
294
+ const strip = (r) => { const { ts, ...rest } = r; return JSON.stringify(rest); };
295
+ return strip(prev) === strip(record);
296
+ }
297
+
194
298
  // ─── Stdin driver ───────────────────────────────────────────────────────────
195
299
 
196
300
  if (require.main === module) {
@@ -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);
@@ -98,6 +171,14 @@ function extractNumber(obj, key) {
98
171
  return typeof v === 'number' ? v : 0;
99
172
  }
100
173
 
174
+ // Drop implausibly large per-call token counts (a cumulative counter that leaked
175
+ // through the no-transcript fallback) to 0 rather than record them. Mirrors
176
+ // pan-cost-logger's guard.
177
+ const PLAUSIBLE_MAX = 20000000;
178
+ function clampPlausible(n) {
179
+ return typeof n === 'number' && n >= 0 && n <= PLAUSIBLE_MAX ? n : 0;
180
+ }
181
+
101
182
  /**
102
183
  * P-1805 (v3.7.8): extract usage totals by reading the SubagentStop transcript.
103
184
  * The hook payload from Claude Code in headless mode does NOT include
@@ -123,6 +204,9 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
123
204
  output_tokens: 0,
124
205
  cache_read_input_tokens: 0,
125
206
  cache_creation_input_tokens: 0,
207
+ model: null,
208
+ first_ts: null,
209
+ last_ts: null,
126
210
  lineCount: 0,
127
211
  };
128
212
  if (!transcriptPath || typeof transcriptPath !== 'string') return totals;
@@ -146,6 +230,15 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
146
230
  // Filter to entries from this subagent if a session_id is provided.
147
231
  // The transcript may include parent + child traffic; session_id discriminates.
148
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;
149
242
  // Usage typically lives on assistant message records.
150
243
  const usage = entry.usage
151
244
  || entry.message?.usage
@@ -183,44 +276,71 @@ function buildTraceEvents(data, sessionId, cwd) {
183
276
  const ts = new Date().toISOString();
184
277
  const agent = data.agent_type || data.subagent_type || 'unknown';
185
278
 
186
- // P-1805: prefer usage from data.usage when present (interactive Claude Code path).
187
- // Fall back to reading the transcript file (headless `claude -p` path — usage
188
- // not in payload but discoverable via transcript_path).
189
- let inputTokens = extractNumber(data.usage, 'input_tokens');
190
- let outputTokens = extractNumber(data.usage, 'output_tokens');
191
- let cacheRead = extractNumber(data.usage, 'cache_read_input_tokens');
192
- if ((inputTokens + outputTokens + cacheRead) === 0 && data.transcript_path) {
279
+ // Per-call tokens come from the transcript SLICE. The SubagentStop `data.usage`,
280
+ // when present, is a CUMULATIVE session counter — not this subagent's delta — so
281
+ // logging it verbatim produced impossible per-row magnitudes (see pan-cost-logger
282
+ // for the full rationale). The slice is authoritative; data.usage is only a
283
+ // plausibility-guarded fallback when no transcript is available.
284
+ let model = typeof data.model === 'string' && data.model ? data.model : null;
285
+ let inputTokens = 0;
286
+ let outputTokens = 0;
287
+ let cacheRead = 0;
288
+ let durationMs = null;
289
+ let tokenSource = data.transcript_path ? 'transcript' : 'usage-fallback';
290
+ let clamped = false;
291
+ if (data.transcript_path) {
193
292
  const cursor = readTraceCursor(cwd);
194
293
  const since = cursor[data.transcript_path] || 0;
195
294
  const fromTranscript = readUsageFromTranscript(data.transcript_path, data.session_id, since);
196
295
  inputTokens = fromTranscript.input_tokens;
197
296
  outputTokens = fromTranscript.output_tokens;
198
297
  cacheRead = fromTranscript.cache_read_input_tokens;
298
+ durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts);
299
+ if (!model) model = fromTranscript.model;
199
300
  if (cwd && fromTranscript.lineCount > since) {
200
301
  cursor[data.transcript_path] = fromTranscript.lineCount;
201
302
  writeTraceCursor(cwd, cursor);
202
303
  }
304
+ } else {
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);
203
312
  }
204
313
  const totalTokens = inputTokens + outputTokens;
205
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
+
206
321
  const events = [];
207
322
 
208
323
  // Core completion event
209
324
  events.push({
325
+ v: SCHEMA_V,
210
326
  ts,
211
327
  session: sessionId,
212
328
  agent,
213
- phase: data.phase || null,
329
+ phase,
214
330
  type: 'decision',
215
331
  category: 'agent_completion',
216
332
  description: `${agent} completed`,
217
333
  context: {
218
- model: data.model || null,
334
+ model,
335
+ command: data.command || sessionMeta.command || null,
219
336
  input_tokens: inputTokens,
220
337
  output_tokens: outputTokens,
221
338
  cache_read_tokens: cacheRead,
222
339
  total_tokens: totalTokens,
340
+ duration_ms: durationMs,
223
341
  exit_code: data.exit_code || 0,
342
+ token_source: tokenSource,
343
+ clamped,
224
344
  },
225
345
  impact: 'trivial',
226
346
  correction: null,
@@ -231,10 +351,11 @@ function buildTraceEvents(data, sessionId, cwd) {
231
351
  // (expensive agent run that wasn't cached — may be repeated research)
232
352
  if (outputTokens > 3000 && cacheRead === 0) {
233
353
  events.push({
354
+ v: SCHEMA_V,
234
355
  ts,
235
356
  session: sessionId,
236
357
  agent,
237
- phase: data.phase || null,
358
+ phase,
238
359
  type: 'redundancy',
239
360
  category: 'uncached_heavy_run',
240
361
  description: `${agent} produced ${outputTokens} output tokens with zero cache hits — possible repeated research`,
@@ -261,14 +382,39 @@ function appendTraceEvents(cwd, events, sessionId) {
261
382
  try {
262
383
  const sessionDir = path.join(getTracesDir(cwd), sessionId);
263
384
  fs.mkdirSync(sessionDir, { recursive: true });
385
+ const file = path.join(sessionDir, TRACE_EVENT_FILE);
386
+ // Idempotency guard: a re-fired SubagentStop must not double-log. If this
387
+ // batch's completion event duplicates the last agent_completion already in
388
+ // the file (every field but ts), skip the whole batch — the source of the
389
+ // ~57% duplicate completion rows in the field (2026-07).
390
+ const completion = events.find(e => e && e.category === 'agent_completion');
391
+ if (completion && isDuplicateCompletion(file, completion)) return false;
264
392
  const lines = events.map(e => JSON.stringify(e)).join('\n') + '\n';
265
- fs.appendFileSync(path.join(sessionDir, TRACE_EVENT_FILE), lines, 'utf-8');
393
+ fs.appendFileSync(file, lines, 'utf-8');
266
394
  return true;
267
395
  } catch {
268
396
  return false;
269
397
  }
270
398
  }
271
399
 
400
+ /** True when `completion` matches the file's last agent_completion row, ignoring ts. */
401
+ function isDuplicateCompletion(file, completion) {
402
+ let last;
403
+ try {
404
+ const raw = fs.readFileSync(file, 'utf-8');
405
+ for (const line of raw.split('\n')) {
406
+ if (!line) continue;
407
+ let e; try { e = JSON.parse(line); } catch { continue; }
408
+ if (e && e.category === 'agent_completion') last = e;
409
+ }
410
+ } catch {
411
+ return false;
412
+ }
413
+ if (!last) return false;
414
+ const strip = (e) => { const { ts, ...rest } = e; return JSON.stringify(rest); };
415
+ return strip(last) === strip(completion);
416
+ }
417
+
272
418
  // ─── Stdin driver ────────────────────────────────────────────────────────────
273
419
 
274
420
  if (require.main === module) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.19.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"