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.
- package/bin/install-lib.cjs +14 -98
- package/hooks/dist/pan-cost-logger.js +134 -30
- package/hooks/dist/pan-trace-logger.js +160 -14
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +119 -0
- package/pan-wizard-core/bin/lib/campaign.cjs +8 -1
- package/pan-wizard-core/bin/lib/config.cjs +3 -0
- package/pan-wizard-core/bin/lib/constants.cjs +8 -0
- package/pan-wizard-core/bin/lib/core.cjs +4 -0
- package/pan-wizard-core/bin/lib/cost.cjs +18 -4
- package/pan-wizard-core/bin/lib/focus.cjs +64 -5
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +253 -0
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +156 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +192 -33
- package/pan-wizard-core/bin/lib/state.cjs +4 -0
- package/pan-wizard-core/bin/pan-tools.cjs +12 -1
package/bin/install-lib.cjs
CHANGED
|
@@ -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
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
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
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
89
|
-
//
|
|
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
|
|
161
|
+
command,
|
|
100
162
|
model,
|
|
101
|
-
tier:
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
let
|
|
192
|
-
|
|
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
|
|
329
|
+
phase,
|
|
214
330
|
type: 'decision',
|
|
215
331
|
category: 'agent_completion',
|
|
216
332
|
description: `${agent} completed`,
|
|
217
333
|
context: {
|
|
218
|
-
model
|
|
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
|
|
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(
|
|
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.
|
|
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"
|