pan-wizard 3.26.0 → 3.28.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 (54) hide show
  1. package/README.md +48 -48
  2. package/agents/pan-previewer.md +1 -1
  3. package/bin/install-lib.cjs +580 -18
  4. package/bin/install.js +25 -44
  5. package/commands/pan/army.md +1 -1
  6. package/commands/pan/hygiene.md +14 -8
  7. package/commands/pan/milestone-audit.md +10 -4
  8. package/commands/pan/preview.md +2 -2
  9. package/hooks/dist/pan-cost-logger.js +69 -5
  10. package/hooks/dist/pan-stop-guard.js +32 -1
  11. package/hooks/dist/pan-trace-logger.js +35 -2
  12. package/package.json +5 -2
  13. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  14. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  15. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  16. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  17. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  18. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  19. package/pan-wizard-core/bin/lib/constants.cjs +49 -0
  20. package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
  21. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  22. package/pan-wizard-core/bin/lib/cost.cjs +113 -11
  23. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  24. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  25. package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
  26. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  27. package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
  28. package/pan-wizard-core/bin/lib/init.cjs +98 -13
  29. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  30. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  31. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  32. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  33. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  34. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  35. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  36. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  37. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  38. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  39. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  40. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  41. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  42. package/pan-wizard-core/bin/lib/verify.cjs +26 -5
  43. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  44. package/pan-wizard-core/bin/pan-tools.cjs +58 -4
  45. package/pan-wizard-core/mcp/server.cjs +92 -8
  46. package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
  47. package/pan-wizard-core/references/model-profiles.md +2 -2
  48. package/pan-wizard-core/workflows/health.md +1 -0
  49. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
  50. package/pan-zcode/README.md +1 -1
  51. package/scripts/build-agent-plugin.js +220 -0
  52. package/scripts/build-plugin.js +48 -3
  53. package/scripts/generate-skills-docs.py +1 -1
  54. package/scripts/release-check.js +58 -12
@@ -5,10 +5,11 @@ const fs = require('fs');
5
5
  const path = require('path');
6
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');
7
7
  const { extractFrontmatter } = require('./frontmatter.cjs');
8
- const { PLANNING_DIR, 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
- const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles } = require('./utils.cjs');
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
+ const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
10
10
  const { estimateTokens } = require('./context-budget.cjs');
11
11
  const { collectPhaseSummaries, readErrorPatterns, appendErrorPattern, appendSessionSummary, parseLearnings, formatLearningEntry, cmdLearningsExtract, cmdLearningsList, cmdLearningsPrune } = require('./commands-learnings.cjs');
12
+ const { planningRootRel } = require('./planning-root.cjs');
12
13
 
13
14
  /**
14
15
  * Generate a URL-safe slug from text by lowercasing and replacing non-alphanumeric chars.
@@ -381,7 +382,7 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
381
382
  output({ committed: false, hash: null, reason: 'skipped_commit_docs_false' }, raw, 'skipped');
382
383
  return;
383
384
  }
384
- if (isGitIgnored(cwd, PLANNING_DIR)) {
385
+ if (isGitIgnored(cwd, planningRootRel())) {
385
386
  // No error key, exit 0: .planning/ is gitignored - also the user's choice.
386
387
  output({ committed: false, hash: null, reason: 'skipped_gitignored' }, raw, 'skipped');
387
388
  return;
@@ -395,7 +396,7 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
395
396
  const preStaged = new Set(stagedFiles(cwd));
396
397
 
397
398
  // Stage files
398
- const filesToStage = files && files.length > 0 ? files : [PLANNING_DIR + '/'];
399
+ const filesToStage = files && files.length > 0 ? files : [planningRel() + '/'];
399
400
  for (const file of filesToStage) execGit(cwd, ['add', file]);
400
401
 
401
402
  // Safety checks
@@ -614,7 +615,7 @@ function renderProgressBar(percent, width) {
614
615
  */
615
616
  function cmdProgressRender(cwd, format, raw) {
616
617
  const phasesDir = phasesPath(cwd);
617
- const roadmapPath = path.join(cwd, PLANNING_DIR, ROADMAP_FILE);
618
+ const roadmapPath = planningPath(cwd, ROADMAP_FILE);
618
619
  const milestone = getMilestoneInfo(cwd);
619
620
 
620
621
  const phases = [];
@@ -693,9 +694,9 @@ function cmdProgressRender(cwd, format, raw) {
693
694
  * Compute and output a composite health score from progress, context budget, and staleness.
694
695
  */
695
696
  function renderHealthReport(cwd, { phasesDir, phases, totalPlans, totalSummaries, percent }, raw) {
696
- const stateContent = safeReadFile(path.join(cwd, PLANNING_DIR, STATE_FILE));
697
- const roadmapContent = safeReadFile(path.join(cwd, PLANNING_DIR, ROADMAP_FILE));
698
- const projectContent = safeReadFile(path.join(cwd, PLANNING_DIR, PROJECT_FILE));
697
+ const stateContent = safeReadFile(planningPath(cwd, STATE_FILE));
698
+ const roadmapContent = safeReadFile(planningPath(cwd, ROADMAP_FILE));
699
+ const projectContent = safeReadFile(planningPath(cwd, PROJECT_FILE));
699
700
 
700
701
  const stateTokens = estimateTokens(stateContent);
701
702
  const roadmapTokens = estimateTokens(roadmapContent);
@@ -742,7 +743,7 @@ function renderHealthReport(cwd, { phasesDir, phases, totalPlans, totalSummaries
742
743
  // Read session history count
743
744
  let sessionCount = 0;
744
745
  try {
745
- const sessionContent = fs.readFileSync(path.join(cwd, PLANNING_DIR, SESSION_HISTORY_FILE), 'utf-8');
746
+ const sessionContent = fs.readFileSync(planningPath(cwd, SESSION_HISTORY_FILE), 'utf-8');
746
747
  sessionCount = (sessionContent.match(/^### Session — /gm) || []).length;
747
748
  } catch { /* file doesn't exist */ }
748
749
 
@@ -782,8 +783,8 @@ function cmdTodoComplete(cwd, filename, raw) {
782
783
  error('filename required for todo complete');
783
784
  }
784
785
 
785
- const pendingDir = path.join(cwd, PLANNING_DIR, 'todos', 'pending');
786
- const completedDir = path.join(cwd, PLANNING_DIR, 'todos', 'completed');
786
+ const pendingDir = planningPath(cwd, 'todos', 'pending');
787
+ const completedDir = planningPath(cwd, 'todos', 'completed');
787
788
  const sourcePath = path.join(pendingDir, filename);
788
789
 
789
790
  let content;
@@ -870,14 +871,14 @@ function cmdScaffold(cwd, type, options, raw) {
870
871
  }
871
872
  const slug = generateSlugInternal(name);
872
873
  const dirName = `${padded}-${slug}`;
873
- const phasesParent = path.join(cwd, PLANNING_DIR, PHASES_DIR);
874
+ const phasesParent = planningPath(cwd, PHASES_DIR);
874
875
  try {
875
876
  fs.mkdirSync(phasesParent, { recursive: true });
876
877
  fs.mkdirSync(path.join(phasesParent, dirName), { recursive: true });
877
878
  } catch (e) {
878
879
  error(`Failed to create phase directory: ${e.message}`);
879
880
  }
880
- output({ created: true, directory: `${PLANNING_DIR}/${PHASES_DIR}/${dirName}` }, raw, `${PLANNING_DIR}/${PHASES_DIR}/${dirName}`);
881
+ output({ created: true, directory: planningRel(PHASES_DIR, dirName) }, raw, planningRel(PHASES_DIR, dirName));
881
882
  return;
882
883
  }
883
884
  default:
@@ -972,7 +973,7 @@ function cmdBatchCommit(cwd, items, raw) {
972
973
  }
973
974
 
974
975
  // Stage .planning/ only
975
- execGit(cwd, ['add', PLANNING_DIR + '/']);
976
+ execGit(cwd, ['add', planningRel() + '/']);
976
977
 
977
978
  // Check if there's anything to commit
978
979
  const statusResult = execGit(cwd, ['diff', '--cached', '--name-only']);
@@ -7,11 +7,11 @@ const os = require('os');
7
7
  const path = require('path');
8
8
  const { output, error, safeReadFile, toPosix, findPhaseInternal } = require('./core.cjs');
9
9
  const {
10
- PLANNING_DIR, CONFIG_FILE, PROJECT_FILE, STANDARDS_FILE,
10
+ CONFIG_FILE, PROJECT_FILE, STANDARDS_FILE,
11
11
  STANDARDS_CATALOG, STANDARDS_CATEGORIES, STANDARDS_RECOMMENDATIONS,
12
12
  PHASE_KEYWORDS_TO_STANDARDS, STANDARDS_EXTERNAL_TOOLS,
13
13
  } = require('./constants.cjs');
14
- const { readJsonFile, planningPath, fileAccessible, hasBraveSearchKey } = require('./utils.cjs');
14
+ const { readJsonFile, planningPath, fileAccessible, hasBraveSearchKey, planningRel } = require('./utils.cjs');
15
15
 
16
16
  /**
17
17
  * Count checked checklist items in a standards section.
@@ -119,7 +119,7 @@ function cmdConfigEnsureSection(cwd, raw) {
119
119
 
120
120
  try {
121
121
  fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
122
- output({ created: true, path: PLANNING_DIR + '/' + CONFIG_FILE }, raw, 'created');
122
+ output({ created: true, path: planningRel(CONFIG_FILE) }, raw, 'created');
123
123
  } catch (err) {
124
124
  error('Failed to create config.json: ' + err.message);
125
125
  }
@@ -354,7 +354,7 @@ function cmdStandardsSelect(cwd, standardId, raw) {
354
354
  output({
355
355
  added: standardId,
356
356
  project_standards: currentIds,
357
- standards_file: toPosix(PLANNING_DIR + '/' + STANDARDS_FILE),
357
+ standards_file: toPosix(planningRel(STANDARDS_FILE)),
358
358
  }, raw, 'Added ' + STANDARDS_CATALOG[standardId].name);
359
359
  }
360
360
 
@@ -398,7 +398,7 @@ function cmdStandardsRemove(cwd, standardId, raw) {
398
398
  output({
399
399
  removed: standardId,
400
400
  project_standards: newIds,
401
- standards_file: toPosix(PLANNING_DIR + '/' + STANDARDS_FILE),
401
+ standards_file: toPosix(planningRel(STANDARDS_FILE)),
402
402
  }, raw, 'Removed ' + standardId);
403
403
  }
404
404
 
@@ -572,11 +572,31 @@ const MEMORY_LOAD_WARN_TOKENS = 4000; // memory-budget telemetry: warn thres
572
572
  const MEMORY_LOAD_CRIT_TOKENS = 8000; // memory-budget telemetry: critical threshold (absolute tokens)
573
573
  const MEMORY_LOAD_MAX_FRACTION = 0.15; // memory-budget telemetry: max fraction of median agent input
574
574
 
575
+ // Cached prompt context (ADR-0044). The files in CACHEABLE_CONTEXT_FILES are
576
+ // re-read into EVERY agent call, so their combined size is the single largest
577
+ // recurring cost in a PAN project — cache reads dominate token traffic by roughly
578
+ // two orders of magnitude over generation. These thresholds mirror the
579
+ // MEMORY_LOAD_* pattern above: measured, classified, and surfaced.
580
+ const CACHE_BLOCK_WARN_TOKENS = 15000; // cached context block: warn threshold (absolute tokens)
581
+ const CACHE_BLOCK_CRIT_TOKENS = 25000; // cached context block: critical threshold
582
+ const CACHE_FILE_WARN_TOKENS = 6000; // any SINGLE cacheable file past this is the one to fix
583
+
584
+ // state.md compaction (ADR-0044). state.md is the largest cacheable file in
585
+ // practice because its section writers only ever append; closed history keeps
586
+ // being re-read months after it stopped being actionable.
587
+ const STATE_HISTORY_FILE = 'state-history.md';
588
+ const STATE_COMPACT_KEEP_DAYS = 30; // dated sections newer than this stay in state.md
589
+
575
590
  // Hygiene — project cleanup + version alignment (docs/FIELD-HARVEST-2026-07.md follow-ups).
576
591
  const HYGIENE_TRACE_RETENTION_DAYS = 30; // trace sessions older than this are prunable…
577
592
  const HYGIENE_TRACE_KEEP_MIN = 5; // …but always keep this many newest sessions
593
+ const HYGIENE_REPORT_KEEP_MIN = 5; // …same shape for optimization/reports/
578
594
  const HYGIENE_LEDGER_SUSPECT_RATIO = 0.5; // ledger "poisoned" when suspect fraction ≥ this…
579
595
  const HYGIENE_LEDGER_MIN_RECORDS = 20; // …and it has at least this many records
596
+ // …OR when the suspect rows, however few, carry this share of the token MASS.
597
+ // A count-only gate passes a ledger whose 24% bad rows hold 90% of the tokens —
598
+ // statistically fine, arithmetically useless. Mass is what aggregates read.
599
+ const HYGIENE_LEDGER_SUSPECT_MASS_RATIO = 0.5;
580
600
  const HYGIENE_TMP_AGE_MS = 60 * 60 * 1000; // .tmp orphans older than 1h are deletable
581
601
 
582
602
  // Skill-Aligned Decomposition pass (ADR-0038): planner draft ↔ skill-surface alignment.
@@ -667,6 +687,27 @@ const REFLECTION_THRESHOLD = {
667
687
  enable_on_tiers: ['reasoning'],
668
688
  };
669
689
 
690
+ /**
691
+ * Markers of a `.planning/` tree written by ANOTHER tool. gsd-core (open-gsd/gsd-core,
692
+ * the continuation of Get Shit Done) uses the same directory name and the same uppercase
693
+ * core files PAN's pre-v2.2 layout used, so hygiene's legacy-filename rename would rename
694
+ * another tool's state. These are POSITIVE markers PAN never writes — files, directories,
695
+ * and gsd-core's flat dotted config keys (PAN nests `workflow: {}`). Source: gsd-core
696
+ * docs/USER-GUIDE.md, read 2026-09-10. Consumed by foreign-planning.cjs (reality check R15).
697
+ */
698
+ const FOREIGN_PLANNING_MARKERS = Object.freeze({
699
+ gsd: Object.freeze({
700
+ tool: 'gsd-core',
701
+ files: Object.freeze(['HANDOFF.json', '.gsd-allow-shrink']),
702
+ dirs: Object.freeze(['forensics', 'threads', 'seeds', 'ui-reviews', 'sketches', 'spikes', 'onboarding']),
703
+ configKeys: Object.freeze([
704
+ 'workflow.discuss_mode', 'workflow.context_coverage_gate', 'workflow.ui_phase', 'workflow.ui_safety_gate',
705
+ 'workflow.skip_discuss', 'workflow.drift_action', 'workflow.drift_threshold', 'plan_review.source_grounding',
706
+ 'graphify.enabled', 'intel.enabled', 'hooks.workflow_guard', 'dynamic_routing', 'resolve_model_ids',
707
+ ]),
708
+ }),
709
+ });
710
+
670
711
  module.exports = {
671
712
  // Directories
672
713
  PLANNING_DIR,
@@ -745,6 +786,7 @@ module.exports = {
745
786
  COMPLEX_FILE_THRESHOLD,
746
787
  CHARS_PER_TOKEN,
747
788
  HEALTH_STATUS,
789
+ FOREIGN_PLANNING_MARKERS,
748
790
  MAX_JSON_SIZE,
749
791
  PROGRESS_BAR_WIDTH,
750
792
  MAX_SLUG_LENGTH,
@@ -772,8 +814,15 @@ module.exports = {
772
814
  MEMORY_LOAD_CRIT_TOKENS,
773
815
  MEMORY_LOAD_MAX_FRACTION,
774
816
  // Hygiene
817
+ CACHE_BLOCK_WARN_TOKENS,
818
+ CACHE_BLOCK_CRIT_TOKENS,
819
+ CACHE_FILE_WARN_TOKENS,
820
+ STATE_HISTORY_FILE,
821
+ STATE_COMPACT_KEEP_DAYS,
775
822
  HYGIENE_TRACE_RETENTION_DAYS,
776
823
  HYGIENE_TRACE_KEEP_MIN,
824
+ HYGIENE_REPORT_KEEP_MIN,
825
+ HYGIENE_LEDGER_SUSPECT_MASS_RATIO,
777
826
  HYGIENE_LEDGER_SUSPECT_RATIO,
778
827
  HYGIENE_LEDGER_MIN_RECORDS,
779
828
  HYGIENE_TMP_AGE_MS,
@@ -71,6 +71,66 @@ function estimateRelevanceRatio(text) {
71
71
  * @param {string} cwd - Project root directory
72
72
  * @param {boolean} raw - If true, output human-readable string
73
73
  */
74
+ // ─── Prompt-cache lifetime signal (2026-09, ADR-0044 follow-up) ─────────────
75
+ //
76
+ // Claude Code decides the prompt-cache lifetime per request bucket: the main
77
+ // conversation can get one hour on a subscription, but EVERYTHING ELSE —
78
+ // subagents, workflows, forks — gets five minutes unless `subagentPromptCacheTtl`
79
+ // (≥2.1.242) says otherwise. Every PAN agent is a subagent. So a phase whose
80
+ // agents are spaced more than five minutes apart re-writes the same cached
81
+ // context block each time, and ADR-0044 measured that block as the bulk of PAN's
82
+ // token traffic. This assessor reads the cost ledger for exactly that signature:
83
+ // a cache WRITE that follows an idle gap of five to sixty minutes — a miss the
84
+ // one-hour lifetime would have turned into a hit. It recommends the setting only
85
+ // when the pattern recurs, because one-hour writes bill at 2× base input against
86
+ // 1.25× for five-minute writes: the longer lifetime pays off once a block is read
87
+ // twice inside the hour, and costs more on bursts that never idle.
88
+
89
+ const TTL_SHORT_MIN = 5; // the default subagent lifetime, in minutes
90
+ const TTL_LONG_MIN = 60; // the lifetime the setting buys
91
+ const TTL_MIN_WRITE_TOKENS = 1000; // ignore trivial writes (a few tokens of tool results)
92
+ const TTL_RECOMMEND_AT = 2; // recurrence, not a single event, earns the recommendation
93
+
94
+ /**
95
+ * Pure. Scan ledger records (oldest first by `ts`) for cache writes that follow
96
+ * an idle gap in (TTL_SHORT_MIN, TTL_LONG_MIN] — writes the one-hour lifetime
97
+ * would have avoided. Records without a parseable `ts`, and records flagged
98
+ * suspect by the caller (pass them pre-filtered), are ignored.
99
+ *
100
+ * @param {Array<object>} records - cost ledger rows ({ts, cache_write_tokens, …})
101
+ * @param {{minWriteTokens?:number, recommendAt?:number}} [opts]
102
+ * @returns {{records_considered:number, writes_after_short_idle:number, tokens_after_short_idle:number, writes_after_long_idle:number, recommend:boolean, setting:string, advice:string|null}}
103
+ */
104
+ function assessCacheTtl(records, opts = {}) {
105
+ const minWrite = opts.minWriteTokens ?? TTL_MIN_WRITE_TOKENS;
106
+ const recommendAt = opts.recommendAt ?? TTL_RECOMMEND_AT;
107
+ const rows = (Array.isArray(records) ? records : [])
108
+ .map(r => ({ t: r && r.ts ? new Date(r.ts).getTime() : NaN, w: Number(r && r.cache_write_tokens) || 0 }))
109
+ .filter(r => Number.isFinite(r.t))
110
+ .sort((a, b) => a.t - b.t);
111
+ let shortIdle = 0; let shortIdleTokens = 0; let longIdle = 0;
112
+ for (let i = 1; i < rows.length; i++) {
113
+ if (rows[i].w < minWrite) continue;
114
+ const gapMin = (rows[i].t - rows[i - 1].t) / 60000;
115
+ if (gapMin > TTL_SHORT_MIN && gapMin <= TTL_LONG_MIN) { shortIdle++; shortIdleTokens += rows[i].w; }
116
+ else if (gapMin > TTL_LONG_MIN) longIdle++;
117
+ }
118
+ const recommend = shortIdle >= recommendAt;
119
+ const setting = 'subagentPromptCacheTtl';
120
+ const advice = recommend
121
+ ? `${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.`
122
+ : null;
123
+ return {
124
+ records_considered: rows.length,
125
+ writes_after_short_idle: shortIdle,
126
+ tokens_after_short_idle: shortIdleTokens,
127
+ writes_after_long_idle: longIdle,
128
+ recommend,
129
+ setting,
130
+ advice,
131
+ };
132
+ }
133
+
74
134
  function cmdContextBudget(cwd, raw) {
75
135
  const planDir = planningPath(cwd);
76
136
  if (!fileAccessible(planDir)) {
@@ -182,12 +242,49 @@ function cmdContextBudget(cwd, raw) {
182
242
  const eligiblePct = totalTokens > 0
183
243
  ? Math.round((cacheTokens / totalTokens) * 1000) / 10
184
244
  : 0;
245
+ // The cached block is re-read into EVERY agent call, so its size is the
246
+ // project's largest recurring cost. This used to be measured and reported
247
+ // with no threshold attached, which meant a block that had grown to ~28k
248
+ // tokens of mostly closed history looked exactly like a healthy one.
249
+ // Classifying it is what turns the measurement into a signal.
250
+ const { CACHE_BLOCK_WARN_TOKENS, CACHE_BLOCK_CRIT_TOKENS, CACHE_FILE_WARN_TOKENS } = require('./constants.cjs');
251
+ const largest = cached.blocks
252
+ .map(b => ({ path: b.path, tokens: Math.ceil((b.content || '').length / 4) }))
253
+ .sort((a, b) => b.tokens - a.tokens);
254
+
255
+ let cacheStatus = 'ok';
256
+ if (cached.blocks.length === 0) cacheStatus = 'absent';
257
+ else if (cacheTokens >= CACHE_BLOCK_CRIT_TOKENS) cacheStatus = 'critical';
258
+ else if (cacheTokens >= CACHE_BLOCK_WARN_TOKENS) cacheStatus = 'warn';
259
+
260
+ const advice = cacheStatus === 'absent'
261
+ ? 'no cacheable context files — every agent call re-sends its context uncached'
262
+ : cacheStatus === 'ok'
263
+ ? null
264
+ : `cached context is re-read on every agent call; largest file ${largest[0].path} (~${largest[0].tokens} tokens)`
265
+ + (largest[0].path.endsWith('state.md') ? ' — run `pan-tools state compact`' : '');
266
+
267
+ // Lifetime signal from the ledger (suspect rows excluded — they carry
268
+ // poisoned counters, not real writes). Absent ledger → zero rows, no advice.
269
+ let ttl = null;
270
+ try {
271
+ const { readRecords, isSuspectRecord } = require('./cost.cjs');
272
+ ttl = assessCacheTtl(readRecords(cwd).filter(r => !isSuspectRecord(r)));
273
+ } catch { ttl = null; }
274
+
185
275
  cache = {
186
276
  block_count: cached.blocks.length,
187
277
  block_paths: cached.blocks.map(b => b.path),
278
+ block_tokens: largest,
188
279
  total_bytes: cached.total_bytes,
189
280
  total_tokens: cacheTokens,
190
281
  eligible_pct: eligiblePct,
282
+ status: cacheStatus,
283
+ warn_tokens: CACHE_BLOCK_WARN_TOKENS,
284
+ crit_tokens: CACHE_BLOCK_CRIT_TOKENS,
285
+ file_warn_tokens: CACHE_FILE_WARN_TOKENS,
286
+ advice,
287
+ ttl,
191
288
  sha: cached.sha,
192
289
  };
193
290
  } catch {
@@ -247,4 +344,5 @@ module.exports = {
247
344
  cmdContextBudget,
248
345
  estimateTokens,
249
346
  estimateRelevanceRatio,
347
+ assessCacheTtl,
250
348
  };