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.
Files changed (40) hide show
  1. package/README.md +39 -39
  2. package/bin/install-lib.cjs +65 -58
  3. package/bin/install.js +199 -188
  4. package/commands/pan/army.md +2 -2
  5. package/commands/pan/audit-deployment.md +2 -2
  6. package/commands/pan/cost.md +19 -7
  7. package/commands/pan/exec-phase.md +2 -0
  8. package/commands/pan/focus-auto.md +5 -5
  9. package/hooks/dist/pan-check-update.js +4 -0
  10. package/hooks/dist/pan-cost-logger.js +322 -43
  11. package/hooks/dist/pan-stop-guard.js +81 -2
  12. package/hooks/dist/pan-trace-logger.js +275 -32
  13. package/package.json +4 -1
  14. package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
  15. package/pan-wizard-core/bin/lib/commands.cjs +3 -1
  16. package/pan-wizard-core/bin/lib/constants.cjs +17 -0
  17. package/pan-wizard-core/bin/lib/context-budget.cjs +10 -0
  18. package/pan-wizard-core/bin/lib/core.cjs +17 -4
  19. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
  20. package/pan-wizard-core/bin/lib/cost.cjs +165 -55
  21. package/pan-wizard-core/bin/lib/git.cjs +5 -1
  22. package/pan-wizard-core/bin/lib/hud.cjs +5 -3
  23. package/pan-wizard-core/bin/lib/hygiene.cjs +22 -25
  24. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +3 -3
  25. package/pan-wizard-core/bin/lib/memory.cjs +14 -8
  26. package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
  27. package/pan-wizard-core/bin/lib/utils.cjs +22 -0
  28. package/pan-wizard-core/bin/lib/verify-deploy.cjs +1 -1
  29. package/pan-wizard-core/bin/lib/verify.cjs +24 -10
  30. package/pan-wizard-core/bin/pan-tools.cjs +8 -1
  31. package/pan-wizard-core/references/model-profiles.md +4 -4
  32. package/pan-wizard-core/references/planning-config.md +19 -23
  33. package/pan-wizard-core/workflows/health.md +1 -0
  34. package/pan-wizard-core/workflows/settings.md +2 -4
  35. package/scripts/coverage-gate.cjs +257 -0
  36. package/scripts/install-git-hooks.js +5 -0
  37. package/scripts/mutation-probe.cjs +272 -0
  38. package/scripts/release-check.js +33 -12
  39. package/scripts/test-quality-lint.cjs +240 -0
  40. 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
- const decision = buildStopDecision({
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
- const PAN_RUNTIME_DIRS = ['.claude', '.codex', '.gemini', '.opencode', '.github'];
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. See that file.
88
- const SCHEMA_V = 3;
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 so the cursor map stays bounded (L40, ADR audit 2026-08).
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
- for (const [tp, v] of Object.entries(cursor)) {
298
- if (tp === SEEN_EVENTS || tp === LEGACY_CONSUME_KEYS) continue; // reserved markers — not paths
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
- // keep accumulating today's rows. Finalize it and mint a fresh one. Explicit
332
- // (non-auto) sessions stay sticky — only auto-sessions roll over.
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
- if (m && m[1] !== stamp) {
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
- if (sessionId && entry.session_id && entry.session_id !== sessionId) continue;
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
- let tokenSource = data.transcript_path ? 'transcript' : 'usage-fallback';
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 (data.transcript_path) {
713
+ if (sliceSource || agentFileMissing) {
714
+ const keyPath = sliceSource || parentPath;
493
715
  const cursor = readTraceCursor(cwd);
494
- const since = cursor[data.transcript_path] || 0;
495
- const fromTranscript = readUsageFromTranscript(data.transcript_path, data.session_id, since);
496
- inputTokens = fromTranscript.input_tokens;
497
- outputTokens = fromTranscript.output_tokens;
498
- cacheRead = fromTranscript.cache_read_input_tokens;
499
- durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts);
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
- cursor[data.transcript_path] = fromTranscript.lineCount;
506
- addSeenSig(cursor, data.transcript_path, eventSig);
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, data.transcript_path).includes(eventSig)) {
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: remember this event's signature so its own re-fire
529
- // is subsequently dropped, then fall through to emit the completion.
530
- addSeenSig(cursor, data.transcript_path, eventSig);
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
- command: data.command || sessionMeta.command || null,
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
- if (outputTokens > 3000 && cacheRead === 0) {
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
- if (!isPanProject(cwd)) return;
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.28.0",
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; every PAN
6
- // target runtime (and Antigravity CLI) reads it natively. PAN contributes one
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
- if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
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,