pan-wizard 3.30.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.
- package/README.md +2 -2
- package/bin/install-lib.cjs +129 -33
- package/bin/install.js +16 -9
- package/commands/pan/army.md +4 -4
- package/commands/pan/verify-phase.md +8 -9
- package/hooks/dist/pan-check-update.js +53 -2
- package/hooks/dist/pan-context-monitor.js +41 -0
- package/hooks/dist/pan-cost-logger.js +70 -1
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +48 -2
- package/hooks/dist/pan-trace-logger.js +41 -0
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +20 -4
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +3 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
- package/pan-wizard-core/bin/lib/core.cjs +232 -23
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +132 -43
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +86 -22
- package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
- package/pan-wizard-core/bin/lib/template.cjs +6 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
- package/pan-wizard-core/bin/pan-tools.cjs +10 -1
- package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
- package/pan-wizard-core/workflows/exec-phase.md +8 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/todo-add.md +1 -1
- package/pan-wizard-core/workflows/todo-check.md +7 -6
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- package/scripts/test-surface.cjs +1 -0
|
@@ -25,6 +25,45 @@ const fs = require('fs');
|
|
|
25
25
|
const os = require('os');
|
|
26
26
|
const path = require('path');
|
|
27
27
|
|
|
28
|
+
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
29
|
+
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
30
|
+
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
31
|
+
// hooks loaded): in a project with both the Claude and the Copilot install, every
|
|
32
|
+
// PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
|
|
33
|
+
// Claude settings. The Copilot project copy (this file under .github/hooks) steps
|
|
34
|
+
// aside whenever the project's Claude settings register the same script, so the hook
|
|
35
|
+
// runs once, and runs again from here the moment the Claude registration is gone.
|
|
36
|
+
// Both files are repository hooks to Copilot and load under the same trust rule, so
|
|
37
|
+
// deferring never leaves zero. Only this copy defers: Claude Code never reads
|
|
38
|
+
// .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
|
|
39
|
+
// Copilot copy loads where repository hooks may not. Identical in every hook
|
|
40
|
+
// Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
|
|
41
|
+
function deferToClaudeRegistration(projectDir, hookFile = __filename) {
|
|
42
|
+
// Assembled, not written as a literal: the installer rewrites every quoted .claude
|
|
43
|
+
// literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
|
|
44
|
+
const claudeDir = ['.', 'claude'].join('');
|
|
45
|
+
try {
|
|
46
|
+
const hooksDir = path.dirname(hookFile);
|
|
47
|
+
if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
|
|
48
|
+
if (typeof projectDir !== 'string' || !projectDir) return false;
|
|
49
|
+
const script = path.basename(hookFile);
|
|
50
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
51
|
+
let settings;
|
|
52
|
+
try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
|
|
53
|
+
const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
|
|
54
|
+
if (!events) continue;
|
|
55
|
+
for (const groups of Object.values(events)) {
|
|
56
|
+
if (!Array.isArray(groups)) continue;
|
|
57
|
+
for (const group of groups) {
|
|
58
|
+
const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
59
|
+
if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch { /* fail open: run this copy */ }
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
// Per-user bridge directory inside tmpdir, created 0700 so another user on a
|
|
29
68
|
// shared host can't pre-plant a symlink at a predictable session path or read
|
|
30
69
|
// the bridge files. Both hooks derive the same dir from the same uid, so the
|
|
@@ -135,6 +174,7 @@ function main() {
|
|
|
135
174
|
process.stdin.on('end', () => {
|
|
136
175
|
try {
|
|
137
176
|
const data = JSON.parse(input);
|
|
177
|
+
if (deferToClaudeRegistration(data.cwd || process.cwd())) process.exit(0);
|
|
138
178
|
const sessionId = data.session_id;
|
|
139
179
|
|
|
140
180
|
if (!sessionId) {
|
|
@@ -199,6 +239,7 @@ if (require.main === module) {
|
|
|
199
239
|
}
|
|
200
240
|
|
|
201
241
|
module.exports = {
|
|
242
|
+
deferToClaudeRegistration,
|
|
202
243
|
buildContextWarning,
|
|
203
244
|
bridgeDir,
|
|
204
245
|
WARNING_THRESHOLD,
|
|
@@ -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
|
// Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
|
|
@@ -564,6 +603,8 @@ function buildCostRecord(data, cwd) {
|
|
|
564
603
|
let outputTokens = 0;
|
|
565
604
|
let cacheRead = 0;
|
|
566
605
|
let cacheWrite = 0;
|
|
606
|
+
// { h1, m5 } when the source carried the cache-write lifetime split (M13).
|
|
607
|
+
let ttlSplit = null;
|
|
567
608
|
let durationMs = null;
|
|
568
609
|
// token_source records WHICH path produced the counts: `agent-transcript` (the
|
|
569
610
|
// subagent's own conversation file — see resolveAgentTranscript), `transcript`
|
|
@@ -621,6 +662,10 @@ function buildCostRecord(data, cwd) {
|
|
|
621
662
|
cacheWrite = clampSlice(rawCw, SLICE_MAX.cache_write);
|
|
622
663
|
clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output
|
|
623
664
|
|| rawCr > SLICE_MAX.cache_read || rawCw > SLICE_MAX.cache_write;
|
|
665
|
+
// A clamped write total no longer matches its split, so the split is dropped with it.
|
|
666
|
+
if (typeof fromTranscript.cache_write_1h_tokens === 'number' && rawCw <= SLICE_MAX.cache_write) {
|
|
667
|
+
ttlSplit = { h1: fromTranscript.cache_write_1h_tokens, m5: fromTranscript.cache_write_5m_tokens || 0 };
|
|
668
|
+
}
|
|
624
669
|
durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
|
|
625
670
|
if (!model) model = fromTranscript.model;
|
|
626
671
|
if (fromTranscript.lineCount > since) {
|
|
@@ -676,6 +721,11 @@ function buildCostRecord(data, cwd) {
|
|
|
676
721
|
cacheRead = clampPlausible(rawCr);
|
|
677
722
|
cacheWrite = clampPlausible(rawCw);
|
|
678
723
|
clamped = [rawIn, rawOut, rawCr, rawCw].some((n) => n > PLAUSIBLE_MAX);
|
|
724
|
+
const split = {};
|
|
725
|
+
addCacheTtlSplit(split, data.usage);
|
|
726
|
+
if (typeof split.cache_write_1h_tokens === 'number' && cacheWrite === rawCw) {
|
|
727
|
+
ttlSplit = { h1: clampPlausible(split.cache_write_1h_tokens), m5: clampPlausible(split.cache_write_5m_tokens) };
|
|
728
|
+
}
|
|
679
729
|
}
|
|
680
730
|
|
|
681
731
|
// Backfill command/phase from the active trace session when the payload omits
|
|
@@ -705,6 +755,8 @@ function buildCostRecord(data, cwd) {
|
|
|
705
755
|
output_tokens: outputTokens,
|
|
706
756
|
cache_read_tokens: cacheRead,
|
|
707
757
|
cache_write_tokens: cacheWrite,
|
|
758
|
+
// The lifetime split of cache_write_tokens, present only when measured (M13).
|
|
759
|
+
...(ttlSplit ? { cache_write_1h_tokens: ttlSplit.h1, cache_write_5m_tokens: ttlSplit.m5 } : {}),
|
|
708
760
|
cost_usd: null,
|
|
709
761
|
duration_ms: durationMs,
|
|
710
762
|
phase,
|
|
@@ -833,11 +885,27 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
833
885
|
totals.output_tokens += extractNumber(usage, 'output_tokens');
|
|
834
886
|
totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
|
|
835
887
|
totals.cache_creation_input_tokens += extractNumber(usage, 'cache_creation_input_tokens');
|
|
888
|
+
addCacheTtlSplit(totals, usage);
|
|
836
889
|
}
|
|
837
890
|
totals.lineCount = seen;
|
|
838
891
|
return totals;
|
|
839
892
|
}
|
|
840
893
|
|
|
894
|
+
/**
|
|
895
|
+
* Add a usage record's cache-write lifetime split to `totals` (M13). Claude Code
|
|
896
|
+
* transcripts carry `usage.cache_creation: { ephemeral_5m_input_tokens,
|
|
897
|
+
* ephemeral_1h_input_tokens }` beside the `cache_creation_input_tokens` total;
|
|
898
|
+
* one-hour writes bill at 2x base input against 1.25x for five-minute ones, so
|
|
899
|
+
* the ledger keeps the split. The totals gain the two fields only when some
|
|
900
|
+
* record carried the block — rows from hosts without it stay unchanged.
|
|
901
|
+
*/
|
|
902
|
+
function addCacheTtlSplit(totals, usage) {
|
|
903
|
+
const cc = usage && usage.cache_creation;
|
|
904
|
+
if (!cc || typeof cc !== 'object') return;
|
|
905
|
+
totals.cache_write_1h_tokens = (totals.cache_write_1h_tokens || 0) + extractNumber(cc, 'ephemeral_1h_input_tokens');
|
|
906
|
+
totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + extractNumber(cc, 'ephemeral_5m_input_tokens');
|
|
907
|
+
}
|
|
908
|
+
|
|
841
909
|
/**
|
|
842
910
|
* Append record to .planning/metrics/tokens.jsonl. Silently succeeds
|
|
843
911
|
* even if the file or directory can't be written — hook must not block.
|
|
@@ -922,6 +990,7 @@ if (require.main === module) {
|
|
|
922
990
|
// fall back to process.cwd() which is the project root when Claude Code
|
|
923
991
|
// invokes the hook.
|
|
924
992
|
const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
|
|
993
|
+
if (deferToClaudeRegistration(cwd)) return;
|
|
925
994
|
// M62: a global-install hook fires in every repo; don't pollute non-PAN
|
|
926
995
|
// projects with .planning/ metrics artifacts. The tree must already exist —
|
|
927
996
|
// see hasPlanningTree (field sweep 2026-09-17).
|
|
@@ -934,4 +1003,4 @@ if (require.main === module) {
|
|
|
934
1003
|
});
|
|
935
1004
|
}
|
|
936
1005
|
|
|
937
|
-
module.exports = { buildCostRecord, appendRecord, readUsageFromTranscript, resolveAgentTranscript, readCursor, writeCursor, isPanProject, hasPlanningTree, readCommandFromTranscript, isSessionStale, PAN_RUNTIME_DIRS, METRICS_DIR, TOKENS_FILE, CURSOR_FILE, SLICE_MAX, MAX_CURSOR_KEYS };
|
|
1006
|
+
module.exports = { deferToClaudeRegistration, buildCostRecord, appendRecord, readUsageFromTranscript, addCacheTtlSplit, resolveAgentTranscript, readCursor, writeCursor, isPanProject, hasPlanningTree, readCommandFromTranscript, isSessionStale, PAN_RUNTIME_DIRS, METRICS_DIR, TOKENS_FILE, CURSOR_FILE, SLICE_MAX, MAX_CURSOR_KEYS };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// State Re-injection - SessionStart hook, `compact` matcher (market-ideas queue M10)
|
|
3
|
+
//
|
|
4
|
+
// Compaction replaces the conversation with a summary, and a summary can drop the
|
|
5
|
+
// one line that mattered: which phase is in flight, which plan, where it stopped.
|
|
6
|
+
// The PanLoop boundary-drop class (finding 0) is that failure — an agent that no
|
|
7
|
+
// longer knows it is mid-chain ends the turn or re-plans finished work. This hook
|
|
8
|
+
// runs when the session restarts after a compaction and hands the model PAN's
|
|
9
|
+
// current position, read from the planning tree on disk, as `additionalContext`.
|
|
10
|
+
//
|
|
11
|
+
// Why SessionStart and not PostCompact: Claude Code's PostCompact has no decision
|
|
12
|
+
// control and discards its output ("Used for side effects like logging"), while
|
|
13
|
+
// SessionStart fires again after a compaction with `source: "compact"` and honours
|
|
14
|
+
// `hookSpecificOutput.additionalContext` (code.claude.com/docs/en/hooks, read
|
|
15
|
+
// 2026-09-26). Codex is the same: its PostCompact output schema has no
|
|
16
|
+
// additionalContext, and SessionStart hooks matching `source: "compact"` deliver it
|
|
17
|
+
// to the continuation (codex hooks docs, read 2026-09-26). Registered with the
|
|
18
|
+
// `compact` matcher on both; the source is re-checked here so a registration
|
|
19
|
+
// without the matcher stays inert on every other start.
|
|
20
|
+
//
|
|
21
|
+
// Inert unless the project is a PAN project with work in flight: `.planning/`
|
|
22
|
+
// holds a state.md that names a current phase AND a roadmap.md with an unticked
|
|
23
|
+
// phase. Everywhere else — no planning tree, a focus-only project, a finished
|
|
24
|
+
// milestone — it prints nothing. It never writes a file (field finding: hooks
|
|
25
|
+
// that scaffolded `.planning/` in projects that never ran PAN).
|
|
26
|
+
//
|
|
27
|
+
// Fail-open everywhere: bad stdin, unreadable files — exit 0 silently. A missing
|
|
28
|
+
// reminder costs a re-read; a crashed hook is a failing hook at every start.
|
|
29
|
+
//
|
|
30
|
+
// The block is capped well under the host's 10,000-character limit for
|
|
31
|
+
// additionalContext; each field is truncated on its own.
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which planning tree this hook acts on.
|
|
38
|
+
*
|
|
39
|
+
* Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which hooks cannot require
|
|
40
|
+
* (they are standalone and run inside the host runtime). All PAN hooks carry an
|
|
41
|
+
* identical copy — if the CLI is pointed at a track while a hook still writes to
|
|
42
|
+
* `.planning/`, that tree's telemetry lands in the wrong place.
|
|
43
|
+
*
|
|
44
|
+
* Env only — a hook gets no argv. A value that escapes the project root is
|
|
45
|
+
* ignored rather than honoured: a bad value degrades to the default, never
|
|
46
|
+
* writes outside the project.
|
|
47
|
+
*/
|
|
48
|
+
function planningDirName() {
|
|
49
|
+
const raw = process.env.PAN_PLANNING_DIR || '';
|
|
50
|
+
if (raw.trim()) {
|
|
51
|
+
const rel = raw.trim().replace(/\\/g, '/');
|
|
52
|
+
const bad = rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:/.test(rel)
|
|
53
|
+
|| rel.split('/').includes('..');
|
|
54
|
+
if (!bad) return rel;
|
|
55
|
+
}
|
|
56
|
+
const track = (process.env.PAN_TRACK || '').trim();
|
|
57
|
+
if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) {
|
|
58
|
+
return `.planning/tracks/${track}`;
|
|
59
|
+
}
|
|
60
|
+
return '.planning';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Absolute path inside the active planning tree. */
|
|
64
|
+
function planningPath(cwd, ...segments) {
|
|
65
|
+
return path.join(cwd, ...planningDirName().split('/'), ...segments);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Same unticked-phase shape the stop guard and the PanLoop harness use: the
|
|
69
|
+
// checklist line templates/roadmap.md and pan-roadmapper.md emit.
|
|
70
|
+
const UNTICKED_PHASE_RE = /^- \[ \] \*\*Phase (\d+(?:\.\d+)?):/m;
|
|
71
|
+
|
|
72
|
+
const FIELD_MAX = 240;
|
|
73
|
+
const BLOCK_MAX = 2000;
|
|
74
|
+
|
|
75
|
+
/** A `**Name:** value` field from state.md — the shape state.cjs reads and writes. */
|
|
76
|
+
function field(content, name) {
|
|
77
|
+
const m = content.match(new RegExp(`\\*\\*${name}:\\*\\*[ \\t]*(.+)`, 'i'));
|
|
78
|
+
if (!m) return null;
|
|
79
|
+
const v = m[1].trim();
|
|
80
|
+
// Template placeholders ("[X]", "[Phase name]") are not a position.
|
|
81
|
+
if (!v || /^\[.*\]$/.test(v) || /^none$/i.test(v)) return null;
|
|
82
|
+
return v.length > FIELD_MAX ? v.slice(0, FIELD_MAX - 1) + '…' : v;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure: the additionalContext block for this planning tree, or null when there is
|
|
87
|
+
* nothing in flight to restore.
|
|
88
|
+
* @param {object} args
|
|
89
|
+
* @param {string|null} args.stateContent .planning/state.md, or null
|
|
90
|
+
* @param {string|null} args.roadmapContent .planning/roadmap.md, or null
|
|
91
|
+
* @param {string} [args.planningRel] the planning directory, for the pointer line
|
|
92
|
+
* @returns {string|null}
|
|
93
|
+
*/
|
|
94
|
+
function buildReinjectContext({ stateContent, roadmapContent, planningRel = '.planning' }) {
|
|
95
|
+
if (typeof stateContent !== 'string' || typeof roadmapContent !== 'string') return null;
|
|
96
|
+
const phase = field(stateContent, 'Current Phase');
|
|
97
|
+
if (!phase) return null;
|
|
98
|
+
const unticked = roadmapContent.match(UNTICKED_PHASE_RE);
|
|
99
|
+
if (!unticked) return null; // every roadmap phase is built — nothing in flight
|
|
100
|
+
|
|
101
|
+
const name = field(stateContent, 'Current Phase Name');
|
|
102
|
+
const plan = field(stateContent, 'Current Plan');
|
|
103
|
+
const plans = field(stateContent, 'Total Plans in Phase');
|
|
104
|
+
const status = field(stateContent, 'Status');
|
|
105
|
+
const stoppedAt = field(stateContent, 'Stopped At');
|
|
106
|
+
const lastDesc = field(stateContent, 'Last Activity Description');
|
|
107
|
+
const resume = field(stateContent, 'Resume File');
|
|
108
|
+
|
|
109
|
+
const lines = ['PAN project state, re-read from disk after context compaction:'];
|
|
110
|
+
let position = `- Current phase: ${phase}${name ? ` (${name})` : ''}`;
|
|
111
|
+
if (plan) position += `, plan ${plan}${plans ? ` of ${plans}` : ''}`;
|
|
112
|
+
lines.push(position);
|
|
113
|
+
if (status) lines.push(`- Status: ${status}`);
|
|
114
|
+
if (stoppedAt) lines.push(`- Stopped at: ${stoppedAt}`);
|
|
115
|
+
else if (lastDesc) lines.push(`- Last activity: ${lastDesc}`);
|
|
116
|
+
if (resume) lines.push(`- Resume file: ${resume}`);
|
|
117
|
+
lines.push(`- First unbuilt roadmap phase: Phase ${unticked[1]}`);
|
|
118
|
+
lines.push(`Re-read ${planningRel}/state.md and the current phase's plan before the next step: continue the work in flight rather than re-planning finished work.`);
|
|
119
|
+
const block = lines.join('\n');
|
|
120
|
+
return block.length > BLOCK_MAX ? block.slice(0, BLOCK_MAX - 1) + '…' : block;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readIfExists(p) {
|
|
124
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function main() {
|
|
128
|
+
let input = '';
|
|
129
|
+
process.stdin.setEncoding('utf8');
|
|
130
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
131
|
+
process.stdin.on('end', () => {
|
|
132
|
+
try {
|
|
133
|
+
let payload = {};
|
|
134
|
+
try { payload = JSON.parse(input); } catch { /* fail open on bad stdin */ }
|
|
135
|
+
if (!payload || typeof payload !== 'object') payload = {};
|
|
136
|
+
if (payload.source !== 'compact') { process.exit(0); return; }
|
|
137
|
+
|
|
138
|
+
const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
139
|
+
const context = buildReinjectContext({
|
|
140
|
+
stateContent: readIfExists(planningPath(projectDir, 'state.md')),
|
|
141
|
+
roadmapContent: readIfExists(planningPath(projectDir, 'roadmap.md')),
|
|
142
|
+
planningRel: planningDirName(),
|
|
143
|
+
});
|
|
144
|
+
if (context) {
|
|
145
|
+
process.stdout.write(JSON.stringify({
|
|
146
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context },
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
} catch { /* fail open — never break a session start */ }
|
|
150
|
+
process.exit(0);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (require.main === module) {
|
|
155
|
+
main();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { buildReinjectContext, UNTICKED_PHASE_RE };
|
|
@@ -52,6 +52,12 @@
|
|
|
52
52
|
// directory: a second stop aimed at the same phase is allowed. No safe marker
|
|
53
53
|
// directory, or no session id, means no block (fail open, as everywhere else).
|
|
54
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
|
+
//
|
|
55
61
|
// Escape hatch: set workflow.stop_guard to false in .planning/config.json to
|
|
56
62
|
// disable the guard entirely without turning off auto_advance.
|
|
57
63
|
//
|
|
@@ -66,6 +72,45 @@
|
|
|
66
72
|
const fs = require('fs');
|
|
67
73
|
const os = require('os');
|
|
68
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
|
+
}
|
|
69
114
|
const crypto = require('crypto');
|
|
70
115
|
/**
|
|
71
116
|
* Which planning tree this hook acts on.
|
|
@@ -238,6 +283,7 @@ function main() {
|
|
|
238
283
|
if (!payload || typeof payload !== 'object') payload = {};
|
|
239
284
|
|
|
240
285
|
const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
286
|
+
if (deferToClaudeRegistration(projectDir)) process.exit(0);
|
|
241
287
|
const planningDir = planningPath(projectDir);
|
|
242
288
|
|
|
243
289
|
let config = null;
|
|
@@ -253,7 +299,7 @@ function main() {
|
|
|
253
299
|
// Gemini CLI's end-of-turn event: its stop_hook_active cannot carry the
|
|
254
300
|
// one-shot promise on its own (see the header), so a marker does.
|
|
255
301
|
if (decision && payload.hook_event_name === 'AfterAgent') {
|
|
256
|
-
const name = onceMarkerName(payload.session_id, projectDir, decision.reason);
|
|
302
|
+
const name = onceMarkerName(payload.session_id || payload.sessionId, projectDir, decision.reason);
|
|
257
303
|
if (!claimOnceMarker(hookStateDir(), name)) decision = null;
|
|
258
304
|
}
|
|
259
305
|
|
|
@@ -267,4 +313,4 @@ if (require.main === module) {
|
|
|
267
313
|
main();
|
|
268
314
|
}
|
|
269
315
|
|
|
270
|
-
module.exports = { buildStopDecision, UNTICKED_PHASE_RE, onceMarkerName, claimOnceMarker };
|
|
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.
|
|
@@ -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.
|
|
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"
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|