pan-wizard 3.28.0 → 3.30.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/README.md +39 -39
- package/bin/install-lib.cjs +65 -58
- package/bin/install.js +199 -188
- package/commands/pan/army.md +2 -2
- package/commands/pan/audit-deployment.md +2 -2
- package/commands/pan/cost.md +19 -7
- package/commands/pan/exec-phase.md +2 -0
- package/commands/pan/focus-auto.md +5 -5
- package/hooks/dist/pan-check-update.js +4 -0
- package/hooks/dist/pan-cost-logger.js +322 -43
- package/hooks/dist/pan-stop-guard.js +81 -2
- package/hooks/dist/pan-trace-logger.js +275 -32
- package/package.json +4 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands.cjs +3 -1
- package/pan-wizard-core/bin/lib/constants.cjs +17 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +10 -0
- package/pan-wizard-core/bin/lib/core.cjs +17 -4
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
- package/pan-wizard-core/bin/lib/cost.cjs +165 -55
- package/pan-wizard-core/bin/lib/git.cjs +5 -1
- package/pan-wizard-core/bin/lib/hud.cjs +5 -3
- package/pan-wizard-core/bin/lib/hygiene.cjs +22 -25
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +3 -3
- package/pan-wizard-core/bin/lib/memory.cjs +14 -8
- package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
- package/pan-wizard-core/bin/lib/utils.cjs +22 -0
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +1 -1
- package/pan-wizard-core/bin/lib/verify.cjs +24 -10
- package/pan-wizard-core/bin/pan-tools.cjs +8 -1
- package/pan-wizard-core/references/model-profiles.md +4 -4
- package/pan-wizard-core/references/planning-config.md +19 -23
- package/pan-wizard-core/workflows/health.md +1 -0
- package/pan-wizard-core/workflows/settings.md +2 -4
- package/scripts/coverage-gate.cjs +257 -0
- package/scripts/install-git-hooks.js +5 -0
- package/scripts/mutation-probe.cjs +272 -0
- package/scripts/release-check.js +33 -12
- package/scripts/test-quality-lint.cjs +240 -0
- package/scripts/test-surface.cjs +336 -0
|
@@ -39,6 +39,19 @@
|
|
|
39
39
|
// per stop chain, so a user who genuinely wants to stop is delayed by exactly
|
|
40
40
|
// one continuation, never trapped.
|
|
41
41
|
//
|
|
42
|
+
// Gemini CLI (R29, 2026-09-23): the guard is registered there on AfterAgent,
|
|
43
|
+
// Gemini's end-of-turn event — until this date PAN registered it under Claude's
|
|
44
|
+
// `Stop` key, which Gemini skips with an "Invalid hook event name" warning, so it
|
|
45
|
+
// had never run. AfterAgent honours the same {decision: 'block', reason} output
|
|
46
|
+
// (a block re-prompts the agent with the reason), but its stop_hook_active is only
|
|
47
|
+
// true for the AfterAgent that evaluates a continuation the block started
|
|
48
|
+
// directly: a continuation that used tools reports false again (gemini-cli
|
|
49
|
+
// client.ts / useGeminiStream.ts, read 2026-09-23). The flag alone would let the
|
|
50
|
+
// guard block every such turn. So on AfterAgent the one-shot promise is kept with
|
|
51
|
+
// a marker per session, project and target phase in the per-user 0700 hook
|
|
52
|
+
// directory: a second stop aimed at the same phase is allowed. No safe marker
|
|
53
|
+
// directory, or no session id, means no block (fail open, as everywhere else).
|
|
54
|
+
//
|
|
42
55
|
// Escape hatch: set workflow.stop_guard to false in .planning/config.json to
|
|
43
56
|
// disable the guard entirely without turning off auto_advance.
|
|
44
57
|
//
|
|
@@ -51,7 +64,9 @@
|
|
|
51
64
|
// rather than only reachable via stdin (same pattern as the other PAN hooks).
|
|
52
65
|
|
|
53
66
|
const fs = require('fs');
|
|
67
|
+
const os = require('os');
|
|
54
68
|
const path = require('path');
|
|
69
|
+
const crypto = require('crypto');
|
|
55
70
|
/**
|
|
56
71
|
* Which planning tree this hook acts on.
|
|
57
72
|
*
|
|
@@ -155,6 +170,63 @@ function readIfExists(p) {
|
|
|
155
170
|
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
156
171
|
}
|
|
157
172
|
|
|
173
|
+
// Per-user hook state directory inside tmpdir, created 0700 — the same directory
|
|
174
|
+
// and the same checks as bridgeDir() in pan-context-monitor.js (hooks are
|
|
175
|
+
// standalone files and cannot require one another). Fail CLOSED (null) when the
|
|
176
|
+
// directory is not provably ours: a shared host must not be able to pre-plant a
|
|
177
|
+
// marker that silences the guard, or a symlink it writes through (M60).
|
|
178
|
+
function hookStateDir() {
|
|
179
|
+
const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
|
|
180
|
+
const dir = path.join(os.tmpdir(), `pan-hooks-${uid}`);
|
|
181
|
+
try {
|
|
182
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
183
|
+
const st = fs.lstatSync(dir);
|
|
184
|
+
if (st.isSymbolicLink()) return null;
|
|
185
|
+
// POSIX-only ownership and mode checks: Windows fakes mode bits (N15).
|
|
186
|
+
if (typeof process.getuid === 'function') {
|
|
187
|
+
if (st.uid !== process.getuid()) return null;
|
|
188
|
+
if ((st.mode & 0o077) !== 0) return null;
|
|
189
|
+
}
|
|
190
|
+
return dir;
|
|
191
|
+
} catch { return null; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The file name of the one-shot marker for an AfterAgent block (Gemini CLI).
|
|
196
|
+
* Keyed by session, project and the block's reason — the reason names the target
|
|
197
|
+
* phase, so a later drop at a DIFFERENT phase in the same session is still caught.
|
|
198
|
+
* Pure; null when there is no session id to key on.
|
|
199
|
+
*/
|
|
200
|
+
function onceMarkerName(sessionId, projectDir, reason) {
|
|
201
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
202
|
+
const key = crypto.createHash('sha256').update(`${sessionId}\0${projectDir}\0${reason}`).digest('hex').slice(0, 32);
|
|
203
|
+
return `stop-guard-${key}.json`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const MARKER_MAX_AGE_MS = 7 * 24 * 3600 * 1000;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Apply the AfterAgent one-shot rule: returns true when this block may be issued
|
|
210
|
+
* (and records it), false when the same session already got it or no safe place
|
|
211
|
+
* to remember it exists. Best-effort pruning keeps the directory from growing.
|
|
212
|
+
*/
|
|
213
|
+
function claimOnceMarker(dir, name, now = Date.now()) {
|
|
214
|
+
if (!dir || !name) return false;
|
|
215
|
+
const marker = path.join(dir, name);
|
|
216
|
+
try {
|
|
217
|
+
for (const f of fs.readdirSync(dir)) {
|
|
218
|
+
if (!/^stop-guard-[0-9a-f]{32}\.json$/.test(f)) continue;
|
|
219
|
+
try { if (now - fs.statSync(path.join(dir, f)).mtimeMs > MARKER_MAX_AGE_MS) fs.unlinkSync(path.join(dir, f)); } catch { /* keep */ }
|
|
220
|
+
}
|
|
221
|
+
} catch { /* unreadable dir — the exclusive create below still decides */ }
|
|
222
|
+
try {
|
|
223
|
+
// 'wx' fails when the marker exists: the create IS the check, so two hook
|
|
224
|
+
// processes racing on the same stop cannot both block.
|
|
225
|
+
fs.writeFileSync(marker, JSON.stringify({ at: new Date(now).toISOString() }), { flag: 'wx', mode: 0o600 });
|
|
226
|
+
return true;
|
|
227
|
+
} catch { return false; }
|
|
228
|
+
}
|
|
229
|
+
|
|
158
230
|
function main() {
|
|
159
231
|
let input = '';
|
|
160
232
|
process.stdin.setEncoding('utf8');
|
|
@@ -171,13 +243,20 @@ function main() {
|
|
|
171
243
|
let config = null;
|
|
172
244
|
try { config = JSON.parse(fs.readFileSync(path.join(planningDir, 'config.json'), 'utf8')); } catch { /* no project / bad config -> allow */ }
|
|
173
245
|
|
|
174
|
-
|
|
246
|
+
let decision = buildStopDecision({
|
|
175
247
|
stopHookActive: payload.stop_hook_active === true,
|
|
176
248
|
config,
|
|
177
249
|
stateContent: readIfExists(path.join(planningDir, 'state.md')),
|
|
178
250
|
roadmapContent: readIfExists(path.join(planningDir, 'roadmap.md')),
|
|
179
251
|
});
|
|
180
252
|
|
|
253
|
+
// Gemini CLI's end-of-turn event: its stop_hook_active cannot carry the
|
|
254
|
+
// one-shot promise on its own (see the header), so a marker does.
|
|
255
|
+
if (decision && payload.hook_event_name === 'AfterAgent') {
|
|
256
|
+
const name = onceMarkerName(payload.session_id, projectDir, decision.reason);
|
|
257
|
+
if (!claimOnceMarker(hookStateDir(), name)) decision = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
181
260
|
if (decision) process.stdout.write(JSON.stringify(decision));
|
|
182
261
|
} catch { /* fail open — never break a stop */ }
|
|
183
262
|
process.exit(0);
|
|
@@ -188,4 +267,4 @@ if (require.main === module) {
|
|
|
188
267
|
main();
|
|
189
268
|
}
|
|
190
269
|
|
|
191
|
-
module.exports = { buildStopDecision, UNTICKED_PHASE_RE };
|
|
270
|
+
module.exports = { buildStopDecision, UNTICKED_PHASE_RE, onceMarkerName, claimOnceMarker };
|
|
@@ -51,7 +51,13 @@ function planningPath(cwd, ...segments) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
|
|
54
|
-
|
|
54
|
+
// Every runtime PAN installs into, by config-directory name. Built from the runtime
|
|
55
|
+
// names rather than written as literals on purpose: the installer templates a hook by
|
|
56
|
+
// rewriting the string `'.claude'`, including a catch-all for unanchored occurrences,
|
|
57
|
+
// which rewrote this list too — a Codex install shipped `.codex` twice and no `.claude`
|
|
58
|
+
// at all, so this predicate stopped recognising a Claude-only project (2026-09-17).
|
|
59
|
+
// This list is runtime-AGNOSTIC and must survive the install byte for byte.
|
|
60
|
+
const PAN_RUNTIME_DIRS = ['claude', 'codex', 'gemini', 'opencode', 'github'].map((r) => `.${r}`);
|
|
55
61
|
|
|
56
62
|
// M62: only instrument actual PAN projects. A global-install hook fires in EVERY
|
|
57
63
|
// repo the user opens; without this gate it silently creates .planning/
|
|
@@ -77,6 +83,23 @@ function isPanProject(cwd) {
|
|
|
77
83
|
// Resolved per call via planningDirName() so a track-scoped run traces into
|
|
78
84
|
// its own tree; kept as a name for the code paths that only need the label.
|
|
79
85
|
const PLANNING_DIR = planningDirName();
|
|
86
|
+
|
|
87
|
+
// Telemetry FILLS a planning tree; it never brings one into existence. isPanProject
|
|
88
|
+
// above also accepts a bare install marker, which is right for "is PAN here" but wrong
|
|
89
|
+
// as a licence to write: a global-install hook fires in every repo the user opens, and
|
|
90
|
+
// scaffolding `.planning/` on the marker alone made repos that had merely installed PAN
|
|
91
|
+
// look like half-built projects to `validate health` and `hygiene scan` — five of the
|
|
92
|
+
// fourteen field projects swept on 2026-09-17 had a planning tree no /pan command ever
|
|
93
|
+
// created. Before the first /pan command there is no project to attribute a run to, so
|
|
94
|
+
// the honest record is no record. Best-effort — never throws.
|
|
95
|
+
function hasPlanningTree(cwd) {
|
|
96
|
+
try {
|
|
97
|
+
return !!cwd && fs.existsSync(planningPath(cwd));
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
const OPTIMIZE_DIR = 'optimization';
|
|
81
104
|
const TRACES_DIR = 'traces';
|
|
82
105
|
const CURRENT_SESSION_FILE = 'current-session';
|
|
@@ -84,8 +107,9 @@ const TRACE_EVENT_FILE = 'trace.jsonl';
|
|
|
84
107
|
|
|
85
108
|
// Trace event schema version — kept in sync by hand with pan-cost-logger.js
|
|
86
109
|
// (standalone zero-dep hooks can't share a module). v3 added the per-invocation
|
|
87
|
-
// `event_sig` discriminator to the completion event's context
|
|
88
|
-
|
|
110
|
+
// `event_sig` discriminator to the completion event's context; v4 added
|
|
111
|
+
// `agent_id` and the `agent-transcript` token source. See that file.
|
|
112
|
+
const SCHEMA_V = 4;
|
|
89
113
|
|
|
90
114
|
// YYYYMMDD stamp for a Date (the day-scope of an auto-session id).
|
|
91
115
|
function dayStamp(d) {
|
|
@@ -145,6 +169,113 @@ function finalizeSession(cwd, sid) {
|
|
|
145
169
|
} catch { /* best-effort */ }
|
|
146
170
|
}
|
|
147
171
|
|
|
172
|
+
// How much of a session transcript's tail to read when looking for the command that
|
|
173
|
+
// spawned this agent. A long session file runs to hundreds of megabytes, and the answer
|
|
174
|
+
// is always in the most recent turns, so the read is bounded.
|
|
175
|
+
const COMMAND_TAIL_BYTES = 262144;
|
|
176
|
+
|
|
177
|
+
// PAN's own command namespace, as a runtime writes it: `/pan:exec-phase` (Claude Code,
|
|
178
|
+
// Gemini) or `/pan-exec-phase` (Codex, OpenCode, Copilot, and Claude Code after --unified-skills). Only these spawn PAN agents,
|
|
179
|
+
// so only these are attributed — a host UI command (`/model`, `/compact`) and a plain
|
|
180
|
+
// typed prompt leave the row honestly unattributed instead of borrowing a name.
|
|
181
|
+
const PAN_COMMAND_RE = /<command-name>\s*\/?pan[:-]([a-z0-9][a-z0-9-]*)\s*<\/command-name>/i;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The PAN command in whose turn this agent ran, read from the PARENT session transcript.
|
|
185
|
+
*
|
|
186
|
+
* `command` used to come only from the optimizer's trace session, and tracing is off by
|
|
187
|
+
* default, so outside focus mode every field row carried `command: null` and "which
|
|
188
|
+
* command got expensive" was unanswerable from PAN's own telemetry (field sweep
|
|
189
|
+
* 2026-09-17, 976 rows). A runtime records a slash-command invocation as a TYPED user
|
|
190
|
+
* turn carrying `<command-name>`, so the tail's most recent such turn names this work.
|
|
191
|
+
*
|
|
192
|
+
* Only typed user turns count. A tool_result record is the host replying to a tool call,
|
|
193
|
+
* and its payload may quote a command tag verbatim — a transcript that had grepped another
|
|
194
|
+
* project's history reported that project's command as its own until this was record-scoped
|
|
195
|
+
* rather than text-scoped. Turns without a command (plain prose, an injected reminder) are
|
|
196
|
+
* skipped rather than treated as clearing the attribution, so a mid-run "continue" does not
|
|
197
|
+
* erase it.
|
|
198
|
+
*
|
|
199
|
+
* This is attribution by recency, not by proof: an agent spawned from a plain prompt long
|
|
200
|
+
* after a PAN command still reads as that command while it remains in the window. Scoping
|
|
201
|
+
* the match to PAN's namespace is what keeps that bounded — a session that has run no PAN
|
|
202
|
+
* command reports null rather than naming whatever the user last typed.
|
|
203
|
+
*
|
|
204
|
+
* Returns the bare command name (`/pan:exec-phase` → `exec-phase`) or null. Never throws.
|
|
205
|
+
*/
|
|
206
|
+
function readCommandFromTranscript(transcriptPath) {
|
|
207
|
+
try {
|
|
208
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
|
|
209
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
210
|
+
let text;
|
|
211
|
+
let partial = false;
|
|
212
|
+
try {
|
|
213
|
+
const size = fs.fstatSync(fd).size;
|
|
214
|
+
const start = Math.max(0, size - COMMAND_TAIL_BYTES);
|
|
215
|
+
partial = start > 0;
|
|
216
|
+
const len = size - start;
|
|
217
|
+
if (len <= 0) return null;
|
|
218
|
+
const buf = Buffer.allocUnsafe(len);
|
|
219
|
+
const read = fs.readSync(fd, buf, 0, len, start);
|
|
220
|
+
text = buf.toString('utf-8', 0, read);
|
|
221
|
+
} finally {
|
|
222
|
+
fs.closeSync(fd);
|
|
223
|
+
}
|
|
224
|
+
const lines = text.split(/\r?\n/);
|
|
225
|
+
if (partial) lines.shift(); // a mid-record first line
|
|
226
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
227
|
+
if (!lines[i]) continue;
|
|
228
|
+
let rec;
|
|
229
|
+
try { rec = JSON.parse(lines[i]); } catch { continue; }
|
|
230
|
+
if (!rec || rec.type !== 'user' || !rec.message) continue;
|
|
231
|
+
const content = rec.message.content;
|
|
232
|
+
let typed = null;
|
|
233
|
+
if (typeof content === 'string') typed = content;
|
|
234
|
+
else if (Array.isArray(content)) {
|
|
235
|
+
if (content.some((b) => b && b.type === 'tool_result')) continue; // host reply, not a typed turn
|
|
236
|
+
typed = content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
|
|
237
|
+
}
|
|
238
|
+
if (!typed) continue;
|
|
239
|
+
const m = typed.match(PAN_COMMAND_RE);
|
|
240
|
+
if (m) return m[1].toLowerCase();
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// How long a `current-session` pointer is evidence of a live session. An explicit
|
|
249
|
+
// (non-auto) session used to stay "current" indefinitely: a field project still pointed
|
|
250
|
+
// at a session started on 17 July when it was swept on 17 September, so every ledger row
|
|
251
|
+
// since had inherited that session's command and phase. Mirrors SESSION_STALE_MS in
|
|
252
|
+
// optimize.cjs — the hooks cannot import it.
|
|
253
|
+
const SESSION_STALE_MS = 24 * 60 * 60 * 1000;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Last write to a session — its event log, else the moment it started, else null.
|
|
257
|
+
* Best-effort; never throws.
|
|
258
|
+
*/
|
|
259
|
+
function sessionLastActivityMs(dir) {
|
|
260
|
+
try {
|
|
261
|
+
return fs.statSync(path.join(dir, 'trace.jsonl')).mtimeMs;
|
|
262
|
+
} catch { /* no events yet */ }
|
|
263
|
+
try {
|
|
264
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'session.json'), 'utf-8'));
|
|
265
|
+
const t = new Date(meta.started_at).getTime();
|
|
266
|
+
return Number.isFinite(t) ? t : null;
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Finished, or too old to be the session running now. */
|
|
273
|
+
function isSessionStale(dir, meta, now = Date.now()) {
|
|
274
|
+
if (meta && meta.ended_at) return true;
|
|
275
|
+
const last = sessionLastActivityMs(dir);
|
|
276
|
+
return last === null || now - last > SESSION_STALE_MS;
|
|
277
|
+
}
|
|
278
|
+
|
|
148
279
|
function getOptimizeDir(cwd) {
|
|
149
280
|
return path.join(cwd, PLANNING_DIR, OPTIMIZE_DIR);
|
|
150
281
|
}
|
|
@@ -290,14 +421,19 @@ function addSeenSig(cursor, transcriptPath, sig) {
|
|
|
290
421
|
for (let i = 0; i < keys.length - MAX_SEEN_TRANSCRIPTS; i++) delete se[keys[i]];
|
|
291
422
|
}
|
|
292
423
|
|
|
424
|
+
// Cursor path keys: with per-agent transcripts every spawn adds a key that lives as
|
|
425
|
+
// long as Claude Code keeps the file, so existence-pruning alone no longer bounds
|
|
426
|
+
// the map (L40). Keep the most recently written keys (mirrors pan-cost-logger).
|
|
427
|
+
const MAX_CURSOR_KEYS = 512;
|
|
428
|
+
|
|
293
429
|
function writeTraceCursor(cwd, cursor) {
|
|
294
430
|
try {
|
|
295
|
-
// Prune dead-transcript keys
|
|
431
|
+
// Prune dead-transcript keys, then keep only the newest MAX_CURSOR_KEYS so the
|
|
432
|
+
// cursor map stays bounded (L40, ADR audit 2026-08). Insertion order is write
|
|
433
|
+
// order — a key is re-inserted when advanced.
|
|
296
434
|
const pruned = {};
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (tp && fs.existsSync(tp)) pruned[tp] = v;
|
|
300
|
-
}
|
|
435
|
+
const live = Object.entries(cursor).filter(([tp]) => tp !== SEEN_EVENTS && tp !== LEGACY_CONSUME_KEYS && tp && fs.existsSync(tp));
|
|
436
|
+
for (const [tp, v] of live.slice(-MAX_CURSOR_KEYS)) pruned[tp] = v;
|
|
301
437
|
// Preserve the seen-event marker (N17/N25-N27). Deliberately NOT pruned by
|
|
302
438
|
// transcript existence — a missing-transcript first fire's marker must
|
|
303
439
|
// survive this very write (N27); bounded by count instead (L40).
|
|
@@ -327,11 +463,15 @@ function ensureSessionId(cwd) {
|
|
|
327
463
|
const stamp = dayStamp(now); // YYYYMMDD
|
|
328
464
|
const existing = getCurrentSessionId(cwd);
|
|
329
465
|
if (existing) {
|
|
330
|
-
// Day-rollover: a stale day-scoped auto-session from a previous day must not
|
|
331
|
-
//
|
|
332
|
-
//
|
|
466
|
+
// Day-rollover: a stale day-scoped auto-session from a previous day must not keep
|
|
467
|
+
// accumulating today's rows. Finalize it and mint a fresh one. An EXPLICIT session
|
|
468
|
+
// used to stay sticky with no bound at all, which is how a field project was still
|
|
469
|
+
// pointing at a 17 July session on 17 September; it now rolls over once it has been
|
|
470
|
+
// quiet for SESSION_STALE_MS, so "sticky while in use" no longer means "forever".
|
|
333
471
|
const m = /^sess_auto_(\d{8})$/.exec(existing);
|
|
334
|
-
|
|
472
|
+
const dir = path.join(getTracesDir(cwd), existing);
|
|
473
|
+
const stale = isSessionStale(dir, readSessionMetaById(cwd, existing));
|
|
474
|
+
if ((m && m[1] !== stamp) || stale) {
|
|
335
475
|
finalizeSession(cwd, existing);
|
|
336
476
|
// fall through to mint a new day-scoped session below
|
|
337
477
|
} else {
|
|
@@ -375,6 +515,54 @@ function clampPlausible(n) {
|
|
|
375
515
|
return typeof n === 'number' && n >= 0 && n <= PLAUSIBLE_MAX ? n : 0;
|
|
376
516
|
}
|
|
377
517
|
|
|
518
|
+
// Per-agent transcript resolution — mirrors pan-cost-logger.js (the hooks are
|
|
519
|
+
// standalone and cannot share a module; keep the two in sync by hand). On
|
|
520
|
+
// SubagentStop the host hands over the PARENT session transcript plus the
|
|
521
|
+
// subagent's `agent_id`; the subagent's own conversation lives beside it as
|
|
522
|
+
// `<parent dir>/<session_id>/subagents/agent-<agent_id>.jsonl`. Slicing the
|
|
523
|
+
// parent per event attributed the session's usage to whichever subagent stopped
|
|
524
|
+
// next, so the agent file is preferred whenever it exists. An explicit
|
|
525
|
+
// `agent_transcript_path` in the payload wins over the derivation.
|
|
526
|
+
const AGENT_ID_SAFE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
527
|
+
function resolveAgentTranscript(data) {
|
|
528
|
+
try {
|
|
529
|
+
const explicit = data.agent_transcript_path;
|
|
530
|
+
if (typeof explicit === 'string' && explicit && fs.existsSync(explicit)) return explicit;
|
|
531
|
+
const agentId = data.agent_id;
|
|
532
|
+
if (typeof agentId !== 'string' || !AGENT_ID_SAFE.test(agentId)) return null;
|
|
533
|
+
if (typeof data.transcript_path !== 'string' || !data.transcript_path) return null;
|
|
534
|
+
if (typeof data.session_id !== 'string' || !AGENT_ID_SAFE.test(data.session_id)) return null;
|
|
535
|
+
const base = path.dirname(data.transcript_path);
|
|
536
|
+
const subagentsDir = path.join(base, data.session_id, 'subagents');
|
|
537
|
+
const resolvedBase = path.resolve(base);
|
|
538
|
+
const contained = (p) => path.resolve(p).startsWith(resolvedBase + path.sep);
|
|
539
|
+
const direct = path.join(subagentsDir, `agent-${agentId}.jsonl`);
|
|
540
|
+
if (contained(direct) && fs.existsSync(direct)) return direct;
|
|
541
|
+
// Workflow-tool subagents live one level down: subagents/workflows/<wf_id>/.
|
|
542
|
+
const workflowsDir = path.join(subagentsDir, 'workflows');
|
|
543
|
+
let runs = [];
|
|
544
|
+
try { runs = fs.readdirSync(workflowsDir); } catch { return null; }
|
|
545
|
+
for (const run of runs) {
|
|
546
|
+
const nested = path.join(workflowsDir, run, `agent-${agentId}.jsonl`);
|
|
547
|
+
if (contained(nested) && fs.existsSync(nested)) return nested;
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
} catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Slice ceilings — a sum over one subagent's conversation, so above a single
|
|
556
|
+
// call's PLAUSIBLE_MAX; cache_read and output mirror cost.cjs isSuspectRecord's
|
|
557
|
+
// absolute limits, input is a hook-only sanity ceiling (same values as
|
|
558
|
+
// pan-cost-logger's SLICE_MAX). A value past its ceiling is a session's cumulative
|
|
559
|
+
// usage that leaked into the slice — drop it to 0 and flag the event. The span
|
|
560
|
+
// is recorded as measured; the reader's six-hour rule judges it.
|
|
561
|
+
const SLICE_MAX = { input: 2e7, output: 1e7, cache_read: 5e8 };
|
|
562
|
+
function clampSlice(n, max) {
|
|
563
|
+
return typeof n === 'number' && n >= 0 && n <= max ? n : 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
378
566
|
/**
|
|
379
567
|
* P-1805 (v3.7.8): extract usage totals by reading the SubagentStop transcript.
|
|
380
568
|
* The hook payload from Claude Code in headless mode does NOT include
|
|
@@ -413,6 +601,12 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
413
601
|
return totals;
|
|
414
602
|
}
|
|
415
603
|
let seen = 0; // count of non-empty JSONL records (the cursor unit)
|
|
604
|
+
// One API turn, one usage: Claude Code writes an assistant turn as one record
|
|
605
|
+
// per content block, each repeating the turn's `message.id` with a usage
|
|
606
|
+
// snapshot (the last block's holds the final counts). Key by id, last wins;
|
|
607
|
+
// records without an id are summed as they come. Mirrors pan-cost-logger.
|
|
608
|
+
const byMessage = new Map();
|
|
609
|
+
let unkeyed = 0;
|
|
416
610
|
for (const line of raw.split('\n')) {
|
|
417
611
|
if (!line) continue;
|
|
418
612
|
seen++;
|
|
@@ -425,7 +619,14 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
425
619
|
}
|
|
426
620
|
// Filter to entries from this subagent if a session_id is provided.
|
|
427
621
|
// The transcript may include parent + child traffic; session_id discriminates.
|
|
428
|
-
|
|
622
|
+
// Claude Code names this field `sessionId`; the guard read `session_id` only, so it
|
|
623
|
+
// was inert — 199 of 200 records in a real local transcript carry the camelCase
|
|
624
|
+
// spelling and none carry the snake_case one (measured 2026-09-17). Harmless on the
|
|
625
|
+
// per-agent path, where every record in the file belongs to the one agent, but the
|
|
626
|
+
// scoping it claims to do never happened on the parent-slice fallback. Both
|
|
627
|
+
// spellings are accepted rather than one guessed at.
|
|
628
|
+
const entrySession = entry.sessionId || entry.session_id;
|
|
629
|
+
if (sessionId && entrySession && entrySession !== sessionId) continue;
|
|
429
630
|
// Span of this subagent's slice (after the session filter) for duration_ms,
|
|
430
631
|
// and the model id (mirrors pan-cost-logger — keep the last model seen).
|
|
431
632
|
const entryTs = typeof entry.timestamp === 'string' ? entry.timestamp : null;
|
|
@@ -442,6 +643,12 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
442
643
|
|| (entry.type === 'assistant' && entry.message?.usage)
|
|
443
644
|
|| null;
|
|
444
645
|
if (!usage || typeof usage !== 'object') continue;
|
|
646
|
+
const messageId = entry.message && typeof entry.message.id === 'string' && entry.message.id
|
|
647
|
+
? entry.message.id
|
|
648
|
+
: `__unkeyed_${unkeyed++}`;
|
|
649
|
+
byMessage.set(messageId, usage);
|
|
650
|
+
}
|
|
651
|
+
for (const usage of byMessage.values()) {
|
|
445
652
|
totals.input_tokens += extractNumber(usage, 'input_tokens');
|
|
446
653
|
totals.output_tokens += extractNumber(usage, 'output_tokens');
|
|
447
654
|
totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
|
|
@@ -487,23 +694,47 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
487
694
|
let outputTokens = 0;
|
|
488
695
|
let cacheRead = 0;
|
|
489
696
|
let durationMs = null;
|
|
490
|
-
|
|
697
|
+
// `agent-transcript` when the subagent's own conversation file was sliced,
|
|
698
|
+
// `transcript` for a slice of the shared parent transcript (the fallback when
|
|
699
|
+
// the host names no agent), `usage-fallback` for the payload's own counters.
|
|
700
|
+
const agentTranscript = resolveAgentTranscript(data);
|
|
701
|
+
const parentPath = typeof data.transcript_path === 'string' && data.transcript_path ? data.transcript_path : null;
|
|
702
|
+
// A named agent whose file is not there consumes nothing (see pan-cost-logger:
|
|
703
|
+
// the parent slice would be the whole session booked to one spawn).
|
|
704
|
+
const agentNamed = (typeof data.agent_id === 'string' && AGENT_ID_SAFE.test(data.agent_id))
|
|
705
|
+
|| (typeof data.agent_transcript_path === 'string' && data.agent_transcript_path !== '');
|
|
706
|
+
const agentFileMissing = !agentTranscript && agentNamed && parentPath !== null;
|
|
707
|
+
const sliceSource = agentTranscript || (agentFileMissing ? null : parentPath);
|
|
708
|
+
const tokenSource = agentTranscript ? 'agent-transcript'
|
|
709
|
+
: agentFileMissing ? 'agent-transcript-missing'
|
|
710
|
+
: sliceSource ? 'transcript' : 'usage-fallback';
|
|
711
|
+
const agentId = typeof data.agent_id === 'string' && data.agent_id ? data.agent_id : null;
|
|
491
712
|
let clamped = false;
|
|
492
|
-
if (
|
|
713
|
+
if (sliceSource || agentFileMissing) {
|
|
714
|
+
const keyPath = sliceSource || parentPath;
|
|
493
715
|
const cursor = readTraceCursor(cwd);
|
|
494
|
-
const since = cursor[
|
|
495
|
-
const fromTranscript =
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
716
|
+
const since = cursor[keyPath] || 0;
|
|
717
|
+
const fromTranscript = sliceSource
|
|
718
|
+
? readUsageFromTranscript(sliceSource, data.session_id, since)
|
|
719
|
+
: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, model: null, first_ts: null, last_ts: null, lineCount: since };
|
|
720
|
+
const rawIn = fromTranscript.input_tokens;
|
|
721
|
+
const rawOut = fromTranscript.output_tokens;
|
|
722
|
+
const rawCr = fromTranscript.cache_read_input_tokens;
|
|
723
|
+
inputTokens = clampSlice(rawIn, SLICE_MAX.input);
|
|
724
|
+
outputTokens = clampSlice(rawOut, SLICE_MAX.output);
|
|
725
|
+
cacheRead = clampSlice(rawCr, SLICE_MAX.cache_read);
|
|
726
|
+
clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output || rawCr > SLICE_MAX.cache_read;
|
|
727
|
+
durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured
|
|
500
728
|
if (!model) model = fromTranscript.model;
|
|
501
729
|
if (cwd && fromTranscript.lineCount > since) {
|
|
502
730
|
// A real slice. Advance the cursor and remember this event's signature
|
|
503
731
|
// (N17/N25) so a later empty-slice event can tell its re-fire from a
|
|
504
732
|
// parallel sibling — even when other siblings are recorded in between (N25).
|
|
505
|
-
|
|
506
|
-
|
|
733
|
+
// Re-insert so insertion order tracks write order (writeTraceCursor keeps
|
|
734
|
+
// the newest keys).
|
|
735
|
+
delete cursor[keyPath];
|
|
736
|
+
cursor[keyPath] = fromTranscript.lineCount;
|
|
737
|
+
addSeenSig(cursor, keyPath, eventSig);
|
|
507
738
|
writeTraceCursor(cwd, cursor);
|
|
508
739
|
} else if (cwd && fromTranscript.lineCount <= since) {
|
|
509
740
|
// No transcript records past the cursor: this event consumed NO slice of its
|
|
@@ -522,12 +753,13 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
522
753
|
// The full-payload signature distinguishes them (N25/N26): emit nothing
|
|
523
754
|
// ONLY when this exact payload was already seen for this transcript;
|
|
524
755
|
// otherwise fall through and emit the completion.
|
|
525
|
-
if (eventSig && getSeenSigs(cursor,
|
|
756
|
+
if (eventSig && getSeenSigs(cursor, keyPath).includes(eventSig)) {
|
|
526
757
|
return []; // already-seen event → re-fire; emit nothing (M61)
|
|
527
758
|
}
|
|
528
|
-
// Sibling / first-fire
|
|
529
|
-
// is subsequently dropped, then fall
|
|
530
|
-
|
|
759
|
+
// Sibling / first-fire / named-but-missing agent file: remember this
|
|
760
|
+
// event's signature so its own re-fire is subsequently dropped, then fall
|
|
761
|
+
// through to emit the completion.
|
|
762
|
+
addSeenSig(cursor, keyPath, eventSig);
|
|
531
763
|
writeTraceCursor(cwd, cursor);
|
|
532
764
|
}
|
|
533
765
|
} else {
|
|
@@ -561,7 +793,10 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
561
793
|
description: `${agent} completed`,
|
|
562
794
|
context: {
|
|
563
795
|
model,
|
|
564
|
-
|
|
796
|
+
// Outside focus mode the trace session carries no command; the parent transcript
|
|
797
|
+
// names it instead (see readCommandFromTranscript).
|
|
798
|
+
command: data.command || sessionMeta.command || readCommandFromTranscript(data.transcript_path) || null,
|
|
799
|
+
agent_id: agentId,
|
|
565
800
|
input_tokens: inputTokens,
|
|
566
801
|
output_tokens: outputTokens,
|
|
567
802
|
cache_read_tokens: cacheRead,
|
|
@@ -593,8 +828,10 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
593
828
|
});
|
|
594
829
|
|
|
595
830
|
// Heuristic: if output tokens > 3000 and no cache hits, flag as potential redundancy
|
|
596
|
-
// (expensive agent run that wasn't cached — may be repeated research)
|
|
597
|
-
|
|
831
|
+
// (expensive agent run that wasn't cached — may be repeated research). A zero
|
|
832
|
+
// the plausibility guard produced is not a cache miss, so a clamped event never
|
|
833
|
+
// trips it.
|
|
834
|
+
if (outputTokens > 3000 && cacheRead === 0 && !clamped) {
|
|
598
835
|
events.push({
|
|
599
836
|
v: SCHEMA_V,
|
|
600
837
|
ts,
|
|
@@ -696,8 +933,9 @@ if (require.main === module) {
|
|
|
696
933
|
const data = JSON.parse(input);
|
|
697
934
|
const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
|
|
698
935
|
// M62: a global-install hook fires in every repo; skip non-PAN projects so
|
|
699
|
-
// we don't create .planning/ optimization + trace artifacts in them.
|
|
700
|
-
|
|
936
|
+
// we don't create .planning/ optimization + trace artifacts in them. The tree
|
|
937
|
+
// must already exist — see hasPlanningTree (field sweep 2026-09-17).
|
|
938
|
+
if (!hasPlanningTree(cwd)) return;
|
|
701
939
|
// In a PAN project, ensure a session exists — creates a day-scoped
|
|
702
940
|
// auto-session if needed.
|
|
703
941
|
const sessionId = ensureSessionId(cwd);
|
|
@@ -712,9 +950,14 @@ if (require.main === module) {
|
|
|
712
950
|
module.exports = {
|
|
713
951
|
buildTraceEvents,
|
|
714
952
|
appendTraceEvents,
|
|
953
|
+
resolveAgentTranscript,
|
|
715
954
|
getCurrentSessionId,
|
|
716
955
|
ensureSessionId,
|
|
717
956
|
isPanProject,
|
|
957
|
+
hasPlanningTree,
|
|
958
|
+
PAN_RUNTIME_DIRS,
|
|
959
|
+
readCommandFromTranscript,
|
|
960
|
+
isSessionStale,
|
|
718
961
|
PLANNING_DIR,
|
|
719
962
|
OPTIMIZE_DIR,
|
|
720
963
|
TRACES_DIR,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.30.0",
|
|
4
4
|
"description": "Command a bot army for your codebase: a reasoning-tier 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"
|
|
@@ -70,6 +70,9 @@
|
|
|
70
70
|
"test:e2e": "node scripts/run-tests.cjs tests/scenarios",
|
|
71
71
|
"test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
|
|
72
72
|
"test:watch": "node scripts/run-tests.cjs --watch tests tests/scenarios",
|
|
73
|
+
"test:coverage": "node scripts/coverage-gate.cjs",
|
|
74
|
+
"test:surface": "node scripts/test-surface.cjs --check",
|
|
75
|
+
"test:mutate": "node scripts/mutation-probe.cjs",
|
|
73
76
|
"build:plugin": "node scripts/build-plugin.js",
|
|
74
77
|
"build:agent-plugin": "node scripts/build-agent-plugin.js",
|
|
75
78
|
"harness": "node harness/src/run.cjs --tier 0",
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// ─── AGENTS.md universal rules layer (ADR-0028 Phase 3) ─────────────────────
|
|
4
4
|
//
|
|
5
|
-
// AGENTS.md is the cross-runtime project-instructions standard;
|
|
6
|
-
//
|
|
5
|
+
// AGENTS.md is the cross-runtime project-instructions standard; Codex,
|
|
6
|
+
// OpenCode and Copilot CLI read it natively, Claude Code only through the @AGENTS.md
|
|
7
|
+
// import in CLAUDE.md; Gemini CLI reads GEMINI.md by default, not this file. PAN contributes one
|
|
7
8
|
// marker-fenced section so agents in any runtime understand the PAN context
|
|
8
9
|
// when reading the repo. User content outside the markers is never touched.
|
|
9
10
|
//
|
|
@@ -428,7 +428,9 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
|
|
|
428
428
|
const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', finalMessage];
|
|
429
429
|
const commitResult = execGit(cwd, commitArgs);
|
|
430
430
|
if (commitResult.exitCode !== 0) {
|
|
431
|
-
|
|
431
|
+
// Both of git's phrasings — see the matching guard in git.cjs cmdGitCommit.
|
|
432
|
+
if ((commitResult.stdout + commitResult.stderr).includes('nothing to commit')
|
|
433
|
+
|| (commitResult.stdout + commitResult.stderr).includes('nothing added to commit')) {
|
|
432
434
|
output({ committed: false, hash: null, reason: 'nothing_to_commit' }, raw, 'nothing');
|
|
433
435
|
return;
|
|
434
436
|
}
|
|
@@ -670,6 +670,22 @@ const CACHEABLE_CONTEXT_FILES = [
|
|
|
670
670
|
'state.md',
|
|
671
671
|
'standards.md',
|
|
672
672
|
];
|
|
673
|
+
/**
|
|
674
|
+
* Which workflow model a `.planning/` tree is running, by the entries only that model
|
|
675
|
+
* creates. PAN has three: the PHASE model (`/pan:new-project` → project/roadmap/state
|
|
676
|
+
* → phases), the FOCUS model (`/pan:focus` → .planning/focus/, no phase spine), and an
|
|
677
|
+
* orchestration CAMPAIGN. A tree with none of these holds only generated artifacts
|
|
678
|
+
* (metrics, traces, codebase maps) — a fragment, typically scaffolded by a hook.
|
|
679
|
+
* Checks that belong to one model must not be run against another: a focus-model
|
|
680
|
+
* project has no project.md by design, and reporting that as an error called eight of
|
|
681
|
+
* fourteen field projects broken (field sweep 2026-09-17).
|
|
682
|
+
*/
|
|
683
|
+
const PLANNING_MODEL_MARKERS = {
|
|
684
|
+
phase: ['project.md', 'roadmap.md', 'state.md', 'requirements.md', 'phases', 'milestones'],
|
|
685
|
+
focus: ['focus', 'quick'],
|
|
686
|
+
campaign: ['orchestration'],
|
|
687
|
+
};
|
|
688
|
+
|
|
673
689
|
/** Default thinking budget (tokens) for verification-heavy agents */
|
|
674
690
|
const THINKING_BUDGETS = {
|
|
675
691
|
'pan-plan-checker': 8000,
|
|
@@ -796,6 +812,7 @@ module.exports = {
|
|
|
796
812
|
LARGE_CONTEXT_TOKEN_THRESHOLD,
|
|
797
813
|
SMALL_CONTEXT_TOKEN_THRESHOLD,
|
|
798
814
|
CACHEABLE_CONTEXT_FILES,
|
|
815
|
+
PLANNING_MODEL_MARKERS,
|
|
799
816
|
THINKING_BUDGETS,
|
|
800
817
|
REFLECTION_THRESHOLD,
|
|
801
818
|
CONTEXT_WINDOW,
|