pan-wizard 3.29.0 → 3.31.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 (50) hide show
  1. package/README.md +40 -40
  2. package/bin/install-lib.cjs +186 -83
  3. package/bin/install.js +213 -195
  4. package/commands/pan/army.md +4 -4
  5. package/commands/pan/audit-deployment.md +2 -2
  6. package/commands/pan/cost.md +5 -5
  7. package/commands/pan/exec-phase.md +2 -0
  8. package/commands/pan/focus-auto.md +5 -5
  9. package/commands/pan/verify-phase.md +8 -9
  10. package/hooks/dist/pan-check-update.js +53 -2
  11. package/hooks/dist/pan-context-monitor.js +41 -0
  12. package/hooks/dist/pan-cost-logger.js +71 -2
  13. package/hooks/dist/pan-state-reinject.js +158 -0
  14. package/hooks/dist/pan-stop-guard.js +127 -2
  15. package/hooks/dist/pan-trace-logger.js +42 -1
  16. package/package.json +1 -1
  17. package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
  18. package/pan-wizard-core/bin/lib/commands.cjs +20 -4
  19. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  20. package/pan-wizard-core/bin/lib/constants.cjs +3 -1
  21. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  22. package/pan-wizard-core/bin/lib/core.cjs +249 -27
  23. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  24. package/pan-wizard-core/bin/lib/cost.cjs +234 -88
  25. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  26. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  27. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +12 -5
  28. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  29. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  30. package/pan-wizard-core/bin/lib/state.cjs +86 -22
  31. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  32. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  33. package/pan-wizard-core/bin/lib/verify-deploy.cjs +20 -3
  34. package/pan-wizard-core/bin/pan-tools.cjs +10 -1
  35. package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
  36. package/pan-wizard-core/references/model-profiles.md +4 -4
  37. package/pan-wizard-core/references/planning-config.md +19 -23
  38. package/pan-wizard-core/workflows/exec-phase.md +8 -0
  39. package/pan-wizard-core/workflows/help.md +2 -2
  40. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  41. package/pan-wizard-core/workflows/progress.md +1 -1
  42. package/pan-wizard-core/workflows/settings.md +2 -4
  43. package/pan-wizard-core/workflows/todo-add.md +1 -1
  44. package/pan-wizard-core/workflows/todo-check.md +7 -6
  45. package/scripts/build-agent-plugin.js +4 -0
  46. package/scripts/build-hooks.js +2 -1
  47. package/scripts/build-plugin.js +10 -0
  48. package/scripts/npm-dist-tag.js +47 -0
  49. package/scripts/plugin-path.js +6 -1
  50. package/scripts/test-surface.cjs +11 -9
@@ -39,6 +39,25 @@
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
+ //
55
+ // Codex and Copilot CLI (M14, 2026-09-26): registered on Codex's `Stop` and
56
+ // Copilot's `agentStop`. Both payloads carry `cwd` and `stop_hook_active` (Copilot
57
+ // keeps that one field snake_case inside its camelCase payload) and both honour the
58
+ // same {decision: 'block', reason} output, so the logic above applies unchanged.
59
+ // Copilot also ends the turn itself after eight consecutive blocks.
60
+ //
42
61
  // Escape hatch: set workflow.stop_guard to false in .planning/config.json to
43
62
  // disable the guard entirely without turning off auto_advance.
44
63
  //
@@ -51,7 +70,48 @@
51
70
  // rather than only reachable via stdin (same pattern as the other PAN hooks).
52
71
 
53
72
  const fs = require('fs');
73
+ const os = require('os');
54
74
  const path = require('path');
75
+
76
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
77
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
78
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
79
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
80
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
81
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
82
+ // aside whenever the project's Claude settings register the same script, so the hook
83
+ // runs once, and runs again from here the moment the Claude registration is gone.
84
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
85
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
86
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
87
+ // Copilot copy loads where repository hooks may not. Identical in every hook
88
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
89
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
90
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
91
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
92
+ const claudeDir = ['.', 'claude'].join('');
93
+ try {
94
+ const hooksDir = path.dirname(hookFile);
95
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
96
+ if (typeof projectDir !== 'string' || !projectDir) return false;
97
+ const script = path.basename(hookFile);
98
+ for (const name of ['settings.json', 'settings.local.json']) {
99
+ let settings;
100
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
101
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
102
+ if (!events) continue;
103
+ for (const groups of Object.values(events)) {
104
+ if (!Array.isArray(groups)) continue;
105
+ for (const group of groups) {
106
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
107
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
108
+ }
109
+ }
110
+ }
111
+ } catch { /* fail open: run this copy */ }
112
+ return false;
113
+ }
114
+ const crypto = require('crypto');
55
115
  /**
56
116
  * Which planning tree this hook acts on.
57
117
  *
@@ -155,6 +215,63 @@ function readIfExists(p) {
155
215
  try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
156
216
  }
157
217
 
218
+ // Per-user hook state directory inside tmpdir, created 0700 — the same directory
219
+ // and the same checks as bridgeDir() in pan-context-monitor.js (hooks are
220
+ // standalone files and cannot require one another). Fail CLOSED (null) when the
221
+ // directory is not provably ours: a shared host must not be able to pre-plant a
222
+ // marker that silences the guard, or a symlink it writes through (M60).
223
+ function hookStateDir() {
224
+ const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
225
+ const dir = path.join(os.tmpdir(), `pan-hooks-${uid}`);
226
+ try {
227
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
228
+ const st = fs.lstatSync(dir);
229
+ if (st.isSymbolicLink()) return null;
230
+ // POSIX-only ownership and mode checks: Windows fakes mode bits (N15).
231
+ if (typeof process.getuid === 'function') {
232
+ if (st.uid !== process.getuid()) return null;
233
+ if ((st.mode & 0o077) !== 0) return null;
234
+ }
235
+ return dir;
236
+ } catch { return null; }
237
+ }
238
+
239
+ /**
240
+ * The file name of the one-shot marker for an AfterAgent block (Gemini CLI).
241
+ * Keyed by session, project and the block's reason — the reason names the target
242
+ * phase, so a later drop at a DIFFERENT phase in the same session is still caught.
243
+ * Pure; null when there is no session id to key on.
244
+ */
245
+ function onceMarkerName(sessionId, projectDir, reason) {
246
+ if (typeof sessionId !== 'string' || !sessionId) return null;
247
+ const key = crypto.createHash('sha256').update(`${sessionId}\0${projectDir}\0${reason}`).digest('hex').slice(0, 32);
248
+ return `stop-guard-${key}.json`;
249
+ }
250
+
251
+ const MARKER_MAX_AGE_MS = 7 * 24 * 3600 * 1000;
252
+
253
+ /**
254
+ * Apply the AfterAgent one-shot rule: returns true when this block may be issued
255
+ * (and records it), false when the same session already got it or no safe place
256
+ * to remember it exists. Best-effort pruning keeps the directory from growing.
257
+ */
258
+ function claimOnceMarker(dir, name, now = Date.now()) {
259
+ if (!dir || !name) return false;
260
+ const marker = path.join(dir, name);
261
+ try {
262
+ for (const f of fs.readdirSync(dir)) {
263
+ if (!/^stop-guard-[0-9a-f]{32}\.json$/.test(f)) continue;
264
+ try { if (now - fs.statSync(path.join(dir, f)).mtimeMs > MARKER_MAX_AGE_MS) fs.unlinkSync(path.join(dir, f)); } catch { /* keep */ }
265
+ }
266
+ } catch { /* unreadable dir — the exclusive create below still decides */ }
267
+ try {
268
+ // 'wx' fails when the marker exists: the create IS the check, so two hook
269
+ // processes racing on the same stop cannot both block.
270
+ fs.writeFileSync(marker, JSON.stringify({ at: new Date(now).toISOString() }), { flag: 'wx', mode: 0o600 });
271
+ return true;
272
+ } catch { return false; }
273
+ }
274
+
158
275
  function main() {
159
276
  let input = '';
160
277
  process.stdin.setEncoding('utf8');
@@ -166,18 +283,26 @@ function main() {
166
283
  if (!payload || typeof payload !== 'object') payload = {};
167
284
 
168
285
  const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
286
+ if (deferToClaudeRegistration(projectDir)) process.exit(0);
169
287
  const planningDir = planningPath(projectDir);
170
288
 
171
289
  let config = null;
172
290
  try { config = JSON.parse(fs.readFileSync(path.join(planningDir, 'config.json'), 'utf8')); } catch { /* no project / bad config -> allow */ }
173
291
 
174
- const decision = buildStopDecision({
292
+ let decision = buildStopDecision({
175
293
  stopHookActive: payload.stop_hook_active === true,
176
294
  config,
177
295
  stateContent: readIfExists(path.join(planningDir, 'state.md')),
178
296
  roadmapContent: readIfExists(path.join(planningDir, 'roadmap.md')),
179
297
  });
180
298
 
299
+ // Gemini CLI's end-of-turn event: its stop_hook_active cannot carry the
300
+ // one-shot promise on its own (see the header), so a marker does.
301
+ if (decision && payload.hook_event_name === 'AfterAgent') {
302
+ const name = onceMarkerName(payload.session_id || payload.sessionId, projectDir, decision.reason);
303
+ if (!claimOnceMarker(hookStateDir(), name)) decision = null;
304
+ }
305
+
181
306
  if (decision) process.stdout.write(JSON.stringify(decision));
182
307
  } catch { /* fail open — never break a stop */ }
183
308
  process.exit(0);
@@ -188,4 +313,4 @@ if (require.main === module) {
188
313
  main();
189
314
  }
190
315
 
191
- module.exports = { buildStopDecision, UNTICKED_PHASE_RE };
316
+ module.exports = { deferToClaudeRegistration, buildStopDecision, UNTICKED_PHASE_RE, onceMarkerName, claimOnceMarker };
@@ -17,6 +17,45 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+
21
+ // ─── R39: one run per hook when Claude and Copilot share a project ───────────
22
+ // Copilot CLI also runs the hooks in a repository's .claude/settings.json and
23
+ // .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
24
+ // hooks loaded): in a project with both the Claude and the Copilot install, every
25
+ // PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
26
+ // Claude settings. The Copilot project copy (this file under .github/hooks) steps
27
+ // aside whenever the project's Claude settings register the same script, so the hook
28
+ // runs once, and runs again from here the moment the Claude registration is gone.
29
+ // Both files are repository hooks to Copilot and load under the same trust rule, so
30
+ // deferring never leaves zero. Only this copy defers: Claude Code never reads
31
+ // .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
32
+ // Copilot copy loads where repository hooks may not. Identical in every hook
33
+ // Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
34
+ function deferToClaudeRegistration(projectDir, hookFile = __filename) {
35
+ // Assembled, not written as a literal: the installer rewrites every quoted .claude
36
+ // literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
37
+ const claudeDir = ['.', 'claude'].join('');
38
+ try {
39
+ const hooksDir = path.dirname(hookFile);
40
+ if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
41
+ if (typeof projectDir !== 'string' || !projectDir) return false;
42
+ const script = path.basename(hookFile);
43
+ for (const name of ['settings.json', 'settings.local.json']) {
44
+ let settings;
45
+ try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
46
+ const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
47
+ if (!events) continue;
48
+ for (const groups of Object.values(events)) {
49
+ if (!Array.isArray(groups)) continue;
50
+ for (const group of groups) {
51
+ const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
52
+ if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
53
+ }
54
+ }
55
+ }
56
+ } catch { /* fail open: run this copy */ }
57
+ return false;
58
+ }
20
59
  const crypto = require('crypto');
21
60
  /**
22
61
  * Which planning tree this hook acts on.
@@ -175,7 +214,7 @@ function finalizeSession(cwd, sid) {
175
214
  const COMMAND_TAIL_BYTES = 262144;
176
215
 
177
216
  // PAN's own command namespace, as a runtime writes it: `/pan:exec-phase` (Claude Code,
178
- // Gemini) or `/pan-exec-phase` (Codex, OpenCode, Copilot). Only these spawn PAN agents,
217
+ // Gemini) or `/pan-exec-phase` (Codex, OpenCode, Copilot, and Claude Code after --unified-skills). Only these spawn PAN agents,
179
218
  // so only these are attributed — a host UI command (`/model`, `/compact`) and a plain
180
219
  // typed prompt leave the row honestly unattributed instead of borrowing a name.
181
220
  const PAN_COMMAND_RE = /<command-name>\s*\/?pan[:-]([a-z0-9][a-z0-9-]*)\s*<\/command-name>/i;
@@ -932,6 +971,7 @@ if (require.main === module) {
932
971
  try {
933
972
  const data = JSON.parse(input);
934
973
  const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
974
+ if (deferToClaudeRegistration(cwd)) return;
935
975
  // M62: a global-install hook fires in every repo; skip non-PAN projects so
936
976
  // we don't create .planning/ optimization + trace artifacts in them. The tree
937
977
  // must already exist — see hasPlanningTree (field sweep 2026-09-17).
@@ -948,6 +988,7 @@ if (require.main === module) {
948
988
  }
949
989
 
950
990
  module.exports = {
991
+ deferToClaudeRegistration,
951
992
  buildTraceEvents,
952
993
  appendTraceEvents,
953
994
  resolveAgentTranscript,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.29.0",
3
+ "version": "3.31.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"
@@ -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
  //
@@ -3,7 +3,7 @@
3
3
  */
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { safeReadFile, loadConfig, isGitIgnored, isGitRepo, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, resolveEffortInternal, detectProvider, resolveTierToModel, estimateCostMultiplier, MODEL_PROFILES, output, error, findPhaseInternal, scanPendingTodos, toPosix } = require('./core.cjs');
6
+ const { safeReadFile, loadConfig, isGitIgnored, isGitRepo, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, resolveModelDetailed, resolveEffortInternal, detectProvider, resolveTierToModel, estimateCostMultiplier, MODEL_PROFILES, output, error, findPhaseInternal, scanPendingTodos, toPosix } = require('./core.cjs');
7
7
  const { extractFrontmatter } = require('./frontmatter.cjs');
8
8
  const { PHASES_DIR, MILESTONES_DIR, QUICK_DIR, STATE_FILE, ROADMAP_FILE, PROJECT_FILE, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, CONTEXT_SUFFIX, UAT_SUFFIX, VERIFICATION_SUFFIX, isPlanFile, isSummaryFile, ARCHIVE_DIR_RE, PHASE_DIR_RE, CONTEXT_WINDOW, WARNING_THRESHOLD, CRITICAL_THRESHOLD, VALID_COMMIT_TYPES, DEFAULT_SENSITIVE_PATTERNS } = require('./constants.cjs');
9
9
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
@@ -220,7 +220,7 @@ function cmdHistoryDigest(cwd, raw) {
220
220
  * @param {string} [metadataJson] - Optional JSON string with task metadata for complexity routing
221
221
  * @returns {void}
222
222
  */
223
- function cmdResolveModel(cwd, agentType, raw, metadataJson) {
223
+ function cmdResolveModel(cwd, agentType, raw, metadataJson, attempt) {
224
224
  if (!agentType) {
225
225
  error('agent-type required');
226
226
  }
@@ -230,6 +230,11 @@ function cmdResolveModel(cwd, agentType, raw, metadataJson) {
230
230
  try { taskMetadata = JSON.parse(metadataJson); }
231
231
  catch { /* ignore invalid metadata, use static routing */ }
232
232
  }
233
+ // --attempt N (M8): the retry number, 1 for a first try. It rides in the
234
+ // metadata so every resolution rule sees it; the flag wins over a metadata key.
235
+ if (attempt !== undefined) {
236
+ taskMetadata = { ...(taskMetadata && typeof taskMetadata === 'object' ? taskMetadata : {}), attempt };
237
+ }
233
238
 
234
239
  const config = loadConfig(cwd);
235
240
  const profile = config.model_profile || 'balanced';
@@ -243,9 +248,14 @@ function cmdResolveModel(cwd, agentType, raw, metadataJson) {
243
248
  return;
244
249
  }
245
250
 
246
- const model = resolveModelInternal(cwd, agentType, taskMetadata);
251
+ const resolved = resolveModelDetailed(cwd, agentType, taskMetadata);
252
+ const model = resolved.model;
247
253
  const effort = resolveEffortInternal(cwd, agentType);
248
254
  const result = { model, profile, strategy, effort };
255
+ if (taskMetadata && Number.isInteger(taskMetadata.attempt)) {
256
+ result.attempt = taskMetadata.attempt;
257
+ result.escalated_from = resolved.escalated_from;
258
+ }
249
259
  output(result, raw, model);
250
260
  }
251
261
 
@@ -804,7 +814,13 @@ function cmdTodoComplete(cwd, filename, raw) {
804
814
  }
805
815
 
806
816
  const today = new Date().toISOString().split('T')[0];
807
- content = `completed: ${today}\n` + content;
817
+ // /pan:todo-add writes YAML frontmatter; the date goes inside it. Prepending it
818
+ // put `completed:` above the opening `---` and broke the block. A file with no
819
+ // frontmatter keeps the old first-line form.
820
+ const fm = content.match(/^---\r?\n/);
821
+ content = fm
822
+ ? fm[0] + `completed: ${today}` + fm[0].slice(3) + content.slice(fm[0].length)
823
+ : `completed: ${today}\n` + content;
808
824
 
809
825
  try {
810
826
  fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
@@ -84,6 +84,8 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
84
84
  strategy: 'static',
85
85
  provider: 'auto',
86
86
  cascade_quality_gate: true,
87
+ // Failure-tier escalation (M8): how many tiers a retry may climb.
88
+ max_escalations: 1,
87
89
  complexity_thresholds: {
88
90
  downgrade_max: 2,
89
91
  upgrade_min: 6,
@@ -607,7 +607,9 @@ const SKILL_ALIGN_MAX_TASKS = 50; // larger drafts are a planning
607
607
  const SKILL_ALIGN_CONTENT_CAP = 700; // chars of file head scored (≈ objective paragraph)
608
608
 
609
609
  /** Valid conventional commit types */
610
- const VALID_COMMIT_TYPES = ['feat', 'fix', 'docs', 'test', 'refactor', 'chore'];
610
+ // `perf` is a task commit type in references/git-integration.md; it was missing
611
+ // here, so `pan-tools commit --type perf` refused a type the docs prescribe.
612
+ const VALID_COMMIT_TYPES = ['feat', 'fix', 'docs', 'test', 'refactor', 'perf', 'chore'];
611
613
 
612
614
  /** Default sensitive file patterns for commit safety checks */
613
615
  const DEFAULT_SENSITIVE_PATTERNS = ['\\.env$', '\\.pem$', '\\.key$', 'credentials', 'secret', 'password', 'token'];
@@ -113,9 +113,23 @@ function assessCacheTtl(records, opts = {}) {
113
113
  // last record: its cache writes are not a subagent's re-write after an idle
114
114
  // gap, and the subagent cache lifetime setting does not govern them.
115
115
  .filter(r => !(r && r.token_source === 'session-transcript'))
116
- .map(r => ({ t: r && r.ts ? new Date(r.ts).getTime() : NaN, w: Number(r && r.cache_write_tokens) || 0 }))
116
+ // A row that recorded the cache-write lifetime split (M13) says which writes
117
+ // were five-minute ones; only those can be a re-write after a short idle gap —
118
+ // a one-hour write already outlives it. Rows without the split fall back to the
119
+ // gap heuristic on the whole write.
120
+ .map(r => {
121
+ const measured = r && typeof r.cache_write_5m_tokens === 'number';
122
+ return {
123
+ t: r && r.ts ? new Date(r.ts).getTime() : NaN,
124
+ w: measured ? r.cache_write_5m_tokens : (Number(r && r.cache_write_tokens) || 0),
125
+ w1h: measured ? (Number(r.cache_write_1h_tokens) || 0) : 0,
126
+ measured,
127
+ };
128
+ })
117
129
  .filter(r => Number.isFinite(r.t))
118
130
  .sort((a, b) => a.t - b.t);
131
+ const measuredRows = rows.filter(r => r.measured);
132
+ const basis = measuredRows.length === 0 ? 'inferred' : measuredRows.length === rows.length ? 'measured' : 'mixed';
119
133
  let shortIdle = 0; let shortIdleTokens = 0; let longIdle = 0;
120
134
  for (let i = 1; i < rows.length; i++) {
121
135
  if (rows[i].w < minWrite) continue;
@@ -126,14 +140,25 @@ function assessCacheTtl(records, opts = {}) {
126
140
  const recommend = shortIdle >= recommendAt;
127
141
  const setting = 'subagentPromptCacheTtl';
128
142
  const severity = recommend && shortIdleTokens >= (opts.warnTokens ?? TTL_WARN_TOKENS) ? 'warn' : 'info';
143
+ const basisNote = basis === 'measured'
144
+ ? ' (measured: the ledger records each write\'s cache lifetime)'
145
+ : basis === 'mixed'
146
+ ? ' (partly measured: rows written before PAN recorded the cache lifetime are counted from idle gaps alone)'
147
+ : ' (inferred from idle gaps between rows: no row records the cache lifetime)';
129
148
  const advice = recommend
130
- ? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.`
149
+ ? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.${basisNote}`
131
150
  : null;
132
151
  return {
133
152
  records_considered: rows.length,
134
153
  writes_after_short_idle: shortIdle,
135
154
  tokens_after_short_idle: shortIdleTokens,
136
155
  writes_after_long_idle: longIdle,
156
+ // Where the split came from: 'measured' (every row carries it), 'mixed', or
157
+ // 'inferred' (none does — the gap heuristic alone).
158
+ basis,
159
+ measured_rows: measuredRows.length,
160
+ cache_write_1h_tokens: measuredRows.reduce((n, r) => n + r.w1h, 0),
161
+ cache_write_5m_tokens: measuredRows.reduce((n, r) => n + r.w, 0),
137
162
  recommend,
138
163
  setting,
139
164
  severity,