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.
- package/hooks/dist/pan-cost-logger.js +82 -8
- package/hooks/dist/pan-trace-logger.js +116 -9
- package/package.json +1 -1
- 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 +61 -5
- package/pan-wizard-core/bin/lib/optimize.cjs +192 -33
- package/pan-wizard-core/bin/pan-tools.cjs +1 -0
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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;
|
|
@@ -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)
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
329
|
+
phase,
|
|
228
330
|
type: 'decision',
|
|
229
331
|
category: 'agent_completion',
|
|
230
332
|
description: `${agent} completed`,
|
|
231
333
|
context: {
|
|
232
|
-
model
|
|
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
|
|
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.
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
204
|
-
meta.agent_count =
|
|
205
|
-
meta.agents =
|
|
206
|
-
meta.type_counts =
|
|
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:
|
|
213
|
-
agent_count:
|
|
214
|
-
type_counts:
|
|
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
|
-
|
|
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
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
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
|
|
412
|
-
...analyzeEvents(session.events,
|
|
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'),
|