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.
- package/README.md +48 -48
- package/agents/pan-previewer.md +1 -1
- package/bin/install-lib.cjs +580 -18
- package/bin/install.js +25 -44
- package/commands/pan/army.md +1 -1
- package/commands/pan/hygiene.md +14 -8
- package/commands/pan/milestone-audit.md +10 -4
- package/commands/pan/preview.md +2 -2
- package/hooks/dist/pan-cost-logger.js +69 -5
- package/hooks/dist/pan-stop-guard.js +32 -1
- package/hooks/dist/pan-trace-logger.js +35 -2
- package/package.json +5 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
- package/pan-wizard-core/bin/lib/bus.cjs +0 -1
- package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
- package/pan-wizard-core/bin/lib/commands.cjs +15 -14
- package/pan-wizard-core/bin/lib/config.cjs +5 -5
- package/pan-wizard-core/bin/lib/constants.cjs +49 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
- package/pan-wizard-core/bin/lib/core.cjs +190 -26
- package/pan-wizard-core/bin/lib/cost.cjs +113 -11
- package/pan-wizard-core/bin/lib/distill.cjs +3 -3
- package/pan-wizard-core/bin/lib/focus.cjs +16 -16
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/hud.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
- package/pan-wizard-core/bin/lib/init.cjs +98 -13
- package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
- package/pan-wizard-core/bin/lib/memory.cjs +1 -1
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
- package/pan-wizard-core/bin/lib/phase.cjs +4 -4
- package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
- package/pan-wizard-core/bin/lib/preview.cjs +0 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
- package/pan-wizard-core/bin/lib/state.cjs +0 -1
- package/pan-wizard-core/bin/lib/template.cjs +1 -1
- package/pan-wizard-core/bin/lib/utils.cjs +39 -11
- package/pan-wizard-core/bin/lib/verify.cjs +26 -5
- package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
- package/pan-wizard-core/bin/pan-tools.cjs +58 -4
- package/pan-wizard-core/mcp/server.cjs +92 -8
- package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
- package/pan-wizard-core/references/model-profiles.md +2 -2
- package/pan-wizard-core/workflows/health.md +1 -0
- package/pan-wizard-core/workflows/milestone-audit.md +35 -6
- package/pan-zcode/README.md +1 -1
- package/scripts/build-agent-plugin.js +220 -0
- package/scripts/build-plugin.js +48 -3
- package/scripts/generate-skills-docs.py +1 -1
- 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 {
|
|
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,
|
|
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 : [
|
|
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 =
|
|
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(
|
|
697
|
-
const roadmapContent = safeReadFile(
|
|
698
|
-
const projectContent = safeReadFile(
|
|
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(
|
|
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 =
|
|
786
|
-
const completedDir =
|
|
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 =
|
|
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:
|
|
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',
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
};
|