pan-wizard 3.19.0 → 3.21.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/bin/install-lib.cjs +14 -98
- package/hooks/dist/pan-cost-logger.js +134 -30
- package/hooks/dist/pan-trace-logger.js +160 -14
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +119 -0
- package/pan-wizard-core/bin/lib/campaign.cjs +8 -1
- package/pan-wizard-core/bin/lib/config.cjs +3 -0
- package/pan-wizard-core/bin/lib/constants.cjs +8 -0
- package/pan-wizard-core/bin/lib/core.cjs +4 -0
- package/pan-wizard-core/bin/lib/cost.cjs +18 -4
- package/pan-wizard-core/bin/lib/focus.cjs +64 -5
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +253 -0
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +156 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +192 -33
- package/pan-wizard-core/bin/lib/state.cjs +4 -0
- package/pan-wizard-core/bin/pan-tools.cjs +12 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─── AGENTS.md universal rules layer (ADR-0028 Phase 3) ─────────────────────
|
|
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
|
|
7
|
+
// marker-fenced section so agents in any runtime understand the PAN context
|
|
8
|
+
// when reading the repo. User content outside the markers is never touched.
|
|
9
|
+
//
|
|
10
|
+
// SSOT NOTE: these builders are the single source of truth for the AGENTS.md
|
|
11
|
+
// PAN section and the CLAUDE.md @AGENTS.md bridge. They live under
|
|
12
|
+
// pan-wizard-core/ (shipped into every install) so the installer AND the
|
|
13
|
+
// installed `pan-tools memory rebuild` regenerate byte-identical content.
|
|
14
|
+
// bin/install-lib.cjs re-exports these names for backward compatibility.
|
|
15
|
+
|
|
16
|
+
const PAN_AGENTS_BEGIN = '<!-- BEGIN PAN WIZARD -->';
|
|
17
|
+
const PAN_AGENTS_END = '<!-- END PAN WIZARD -->';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the PAN section for AGENTS.md (marker-fenced, runtime-neutral).
|
|
21
|
+
* @returns {string} The fenced section, no leading/trailing blank lines.
|
|
22
|
+
*/
|
|
23
|
+
function buildAgentsMdSection() {
|
|
24
|
+
return [
|
|
25
|
+
PAN_AGENTS_BEGIN,
|
|
26
|
+
'## PAN Wizard',
|
|
27
|
+
'',
|
|
28
|
+
'This project uses PAN Wizard for structured, phase-based planning and execution.',
|
|
29
|
+
'',
|
|
30
|
+
'- `.planning/` is PAN\'s state directory (state.md, roadmap.md, phase directories). Treat it as the source of truth for planning state and modify it through PAN commands, not by hand.',
|
|
31
|
+
'- PAN commands install as `pan-*` skills/commands (for example `/pan-help`, `/pan-new-project`, `/pan-exec-phase`). Start with `/pan-help`.',
|
|
32
|
+
'- The `pan-tools` dispatcher backs every command; it lives under `pan-wizard-core/` inside the runtime\'s config directory (or `.agents/` for unified installs).',
|
|
33
|
+
PAN_AGENTS_END,
|
|
34
|
+
].join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Insert or replace the PAN section in AGENTS.md content.
|
|
39
|
+
* - No existing content (null/empty) → just the section.
|
|
40
|
+
* - Markers present → replace exactly the fenced block, preserving everything
|
|
41
|
+
* around it.
|
|
42
|
+
* - Markers absent → append with a separating blank line.
|
|
43
|
+
* @param {string|null} existing - Current AGENTS.md content, or null if absent
|
|
44
|
+
* @param {string} section - Output of buildAgentsMdSection()
|
|
45
|
+
* @returns {string} New file content (always newline-terminated)
|
|
46
|
+
*/
|
|
47
|
+
function upsertAgentsMdSection(existing, section) {
|
|
48
|
+
if (!existing || !existing.trim()) {
|
|
49
|
+
return section + '\n';
|
|
50
|
+
}
|
|
51
|
+
const beginIdx = existing.indexOf(PAN_AGENTS_BEGIN);
|
|
52
|
+
const endIdx = existing.indexOf(PAN_AGENTS_END);
|
|
53
|
+
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
|
54
|
+
const before = existing.slice(0, beginIdx);
|
|
55
|
+
const after = existing.slice(endIdx + PAN_AGENTS_END.length);
|
|
56
|
+
return before + section + after;
|
|
57
|
+
}
|
|
58
|
+
return existing.trimEnd() + '\n\n' + section + '\n';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Remove the PAN section from AGENTS.md content.
|
|
63
|
+
* @param {string} existing - Current AGENTS.md content
|
|
64
|
+
* @returns {string|null} Content without the PAN block, or null when nothing
|
|
65
|
+
* meaningful remains (caller should delete the file).
|
|
66
|
+
*/
|
|
67
|
+
function removeAgentsMdSection(existing) {
|
|
68
|
+
if (!existing) return null;
|
|
69
|
+
const beginIdx = existing.indexOf(PAN_AGENTS_BEGIN);
|
|
70
|
+
const endIdx = existing.indexOf(PAN_AGENTS_END);
|
|
71
|
+
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
|
72
|
+
return existing; // no PAN block — leave untouched
|
|
73
|
+
}
|
|
74
|
+
const before = existing.slice(0, beginIdx);
|
|
75
|
+
const after = existing.slice(endIdx + PAN_AGENTS_END.length);
|
|
76
|
+
const remaining = (before.trimEnd() + '\n\n' + after.trimStart()).trim();
|
|
77
|
+
return remaining ? remaining + '\n' : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Ensure CLAUDE.md bridges to AGENTS.md via a marker-fenced @AGENTS.md import
|
|
82
|
+
* (Claude Code's documented pattern for adopting the universal rules file).
|
|
83
|
+
* Idempotent; preserves all user content.
|
|
84
|
+
* @param {string|null} existing - Current CLAUDE.md content, or null if absent
|
|
85
|
+
* @returns {string} New file content
|
|
86
|
+
*/
|
|
87
|
+
function ensureClaudeMdImport(existing) {
|
|
88
|
+
const block = `${PAN_AGENTS_BEGIN}\n@AGENTS.md\n${PAN_AGENTS_END}`;
|
|
89
|
+
if (!existing || !existing.trim()) {
|
|
90
|
+
return block + '\n';
|
|
91
|
+
}
|
|
92
|
+
if (existing.includes(PAN_AGENTS_BEGIN)) {
|
|
93
|
+
return existing; // bridge (or another PAN block) already present
|
|
94
|
+
}
|
|
95
|
+
if (/^@AGENTS\.md\s*$/m.test(existing)) {
|
|
96
|
+
return existing; // user already imports AGENTS.md themselves
|
|
97
|
+
}
|
|
98
|
+
return existing.trimEnd() + '\n\n' + block + '\n';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remove the PAN bridge block from CLAUDE.md content.
|
|
103
|
+
* @param {string} existing - Current CLAUDE.md content
|
|
104
|
+
* @returns {string|null} Content without the bridge, or null when nothing
|
|
105
|
+
* meaningful remains (caller should delete the file).
|
|
106
|
+
*/
|
|
107
|
+
function removeClaudeMdImport(existing) {
|
|
108
|
+
return removeAgentsMdSection(existing);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
PAN_AGENTS_BEGIN,
|
|
113
|
+
PAN_AGENTS_END,
|
|
114
|
+
buildAgentsMdSection,
|
|
115
|
+
upsertAgentsMdSection,
|
|
116
|
+
removeAgentsMdSection,
|
|
117
|
+
ensureClaudeMdImport,
|
|
118
|
+
removeClaudeMdImport,
|
|
119
|
+
};
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const { output, error } = require('./core.cjs');
|
|
16
|
-
const { PLANNING_DIR } = require('./constants.cjs');
|
|
16
|
+
const { PLANNING_DIR, VERIFY_RESERVE_FRACTION } = require('./constants.cjs');
|
|
17
17
|
|
|
18
18
|
const ORCH_DIR = 'orchestration';
|
|
19
19
|
const SCHEDULE_FILE = 'schedule.json';
|
|
@@ -169,12 +169,19 @@ function cmdCampaignStatus(cwd, raw) {
|
|
|
169
169
|
if (!schedule) return output({ scheduled: false }, raw, 'No campaign scheduled');
|
|
170
170
|
const at = new Date();
|
|
171
171
|
const d = isRunDue(schedule, at);
|
|
172
|
+
// Advisory verify-reserve indicators (status only — does not move the isRunDue
|
|
173
|
+
// trip threshold): how much of the daily budget is held back for re-verification
|
|
174
|
+
// and whether today's spend has crossed into it.
|
|
175
|
+
const fraction = typeof schedule.verify_reserve === 'number' ? schedule.verify_reserve : VERIFY_RESERVE_FRACTION;
|
|
176
|
+
const verifyReserve = schedule.daily_budget != null && fraction > 0 ? Math.ceil(schedule.daily_budget * fraction) : 0;
|
|
177
|
+
const intoReserve = verifyReserve > 0 && schedule.daily_budget != null && d.spent_today >= schedule.daily_budget - verifyReserve;
|
|
172
178
|
const result = {
|
|
173
179
|
scheduled: true, enabled: schedule.enabled, paused: schedule.paused,
|
|
174
180
|
cadence: schedule.cadence, daily_budget: schedule.daily_budget,
|
|
175
181
|
next_due: schedule.next_due, last_run: schedule.last_run,
|
|
176
182
|
spent_today: d.spent_today, runs: (schedule.history || []).length,
|
|
177
183
|
due: d.due, reason: d.reason,
|
|
184
|
+
verify_reserve: verifyReserve, into_verify_reserve: intoReserve,
|
|
178
185
|
};
|
|
179
186
|
const human = `Campaign ${schedule.enabled ? (schedule.paused ? 'paused' : 'active') : 'disabled'} · ${schedule.cadence} · spent ${d.spent_today}/${schedule.daily_budget} today · next ${schedule.next_due} · ${d.due ? 'DUE NOW' : d.reason}`;
|
|
180
187
|
output(result, raw, human);
|
|
@@ -66,6 +66,9 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
|
|
|
66
66
|
// (HUD, telemetry) but never STOP a run. Set enforce:true (or pass
|
|
67
67
|
// --enforce-budget) to make the cap a hard stop again.
|
|
68
68
|
enforce: false,
|
|
69
|
+
// Fraction of the spawn budget held back for re-verification (0..0.5).
|
|
70
|
+
// Surfaced as an indicator always; a hard early stop only under enforce.
|
|
71
|
+
verify_reserve: 0.15,
|
|
69
72
|
},
|
|
70
73
|
commit: {
|
|
71
74
|
safety_checks: true,
|
|
@@ -183,6 +183,12 @@ const DEFAULT_MAX_CYCLES = 10;
|
|
|
183
183
|
|
|
184
184
|
/** Default cumulative budget cap for auto-runner */
|
|
185
185
|
const DEFAULT_TOTAL_BUDGET = 500;
|
|
186
|
+
// Fraction of the spawn budget held back for re-verification so a run can't
|
|
187
|
+
// exhaust its points on Build/Execute before the quality gate re-checks the
|
|
188
|
+
// work. Advisory by default (an indicator); a hard early stop only under
|
|
189
|
+
// --enforce-budget. FLOOR guards tiny budgets from a zero reserve.
|
|
190
|
+
const VERIFY_RESERVE_FRACTION = 0.15;
|
|
191
|
+
const VERIFY_RESERVE_FLOOR = 1;
|
|
186
192
|
|
|
187
193
|
// ─── Standards ──────────────────────────────────────────────────────────────
|
|
188
194
|
|
|
@@ -717,6 +723,8 @@ module.exports = {
|
|
|
717
723
|
CATEGORY_DEFAULTS,
|
|
718
724
|
DEFAULT_MAX_CYCLES,
|
|
719
725
|
DEFAULT_TOTAL_BUDGET,
|
|
726
|
+
VERIFY_RESERVE_FRACTION,
|
|
727
|
+
VERIFY_RESERVE_FLOOR,
|
|
720
728
|
// Standards
|
|
721
729
|
STANDARDS_FILE,
|
|
722
730
|
STANDARDS_CATEGORIES,
|
|
@@ -296,6 +296,9 @@ function loadConfig(cwd) {
|
|
|
296
296
|
model_overrides: parsed.model_overrides || {},
|
|
297
297
|
effort_overrides: parsed.effort_overrides || {},
|
|
298
298
|
routing: parsed.routing || { strategy: 'static', provider: 'auto' },
|
|
299
|
+
// Cost dashboard config: `cost.rates` per-model overrides (surfaced so the
|
|
300
|
+
// documented override actually reaches cost.cjs — it was dropped before).
|
|
301
|
+
cost: parsed.cost || {},
|
|
299
302
|
// ADR-0031: project build/verification commands. null = not configured
|
|
300
303
|
// (focus-auto --clean-seal then asks or skips rather than guessing).
|
|
301
304
|
build: parsed.build || null,
|
|
@@ -312,6 +315,7 @@ function loadConfig(cwd) {
|
|
|
312
315
|
model_overrides: {},
|
|
313
316
|
effort_overrides: {},
|
|
314
317
|
routing: { strategy: 'static', provider: 'auto' },
|
|
318
|
+
cost: {},
|
|
315
319
|
build: null,
|
|
316
320
|
verification: null,
|
|
317
321
|
concurrency: { serial_build: false },
|
|
@@ -148,10 +148,13 @@ function appendRecord(cwd, rec) {
|
|
|
148
148
|
phase: rec.phase || null,
|
|
149
149
|
session: rec.session || null,
|
|
150
150
|
};
|
|
151
|
-
// Allow caller-supplied cost override; otherwise compute
|
|
151
|
+
// Allow caller-supplied cost override; otherwise compute at the user's
|
|
152
|
+
// configured rates (config.cost.rates) — without this, CLI-appended rows froze
|
|
153
|
+
// at DEFAULT_RATES while hook rows (cost_usd:null) got config rates at read
|
|
154
|
+
// time, so the two producers priced identical tokens differently.
|
|
152
155
|
normalized.cost_usd = typeof rec.cost_usd === 'number'
|
|
153
156
|
? rec.cost_usd
|
|
154
|
-
: computeCost(normalized);
|
|
157
|
+
: computeCost(normalized, loadConfig(cwd)?.cost?.rates);
|
|
155
158
|
|
|
156
159
|
try {
|
|
157
160
|
fs.mkdirSync(metricsDir(cwd), { recursive: true });
|
|
@@ -167,7 +170,16 @@ function appendRecord(cwd, rec) {
|
|
|
167
170
|
* @param {string} cwd
|
|
168
171
|
* @returns {Array<Object>}
|
|
169
172
|
*/
|
|
173
|
+
// Count of malformed (unparseable) rows dropped by the most recent readRecords
|
|
174
|
+
// call. A crash mid-append or interleaved concurrent appends can leave a torn
|
|
175
|
+
// row; surfacing the count (rather than swallowing it) mirrors the existing
|
|
176
|
+
// suspect_excluded contract. Module-level so aggregate() can read it without a
|
|
177
|
+
// breaking change to readRecords' bare-array return (consumed as an array by
|
|
178
|
+
// aggregate, memory.cjs, and hygiene.cjs).
|
|
179
|
+
let _lastReadMalformed = 0;
|
|
180
|
+
|
|
170
181
|
function readRecords(cwd) {
|
|
182
|
+
_lastReadMalformed = 0;
|
|
171
183
|
const raw = safeReadFile(tokensFile(cwd));
|
|
172
184
|
if (!raw) return [];
|
|
173
185
|
const records = [];
|
|
@@ -175,7 +187,7 @@ function readRecords(cwd) {
|
|
|
175
187
|
if (!line.trim()) continue;
|
|
176
188
|
try {
|
|
177
189
|
records.push(JSON.parse(line));
|
|
178
|
-
} catch {
|
|
190
|
+
} catch { _lastReadMalformed += 1; }
|
|
179
191
|
}
|
|
180
192
|
return records;
|
|
181
193
|
}
|
|
@@ -207,6 +219,7 @@ function isSuspectRecord(r) {
|
|
|
207
219
|
|
|
208
220
|
function aggregate(cwd, opts) {
|
|
209
221
|
const records = readRecords(cwd);
|
|
222
|
+
const malformedSkipped = _lastReadMalformed; // captured before any later read
|
|
210
223
|
const since = opts?.since ? new Date(opts.since).getTime() : null;
|
|
211
224
|
const until = opts?.until ? new Date(opts.until).getTime() : null;
|
|
212
225
|
const config = loadConfig(cwd);
|
|
@@ -229,6 +242,7 @@ function aggregate(cwd, opts) {
|
|
|
229
242
|
cost_usd: 0,
|
|
230
243
|
cost_unknown: 0,
|
|
231
244
|
suspect_excluded: 0,
|
|
245
|
+
malformed_skipped: malformedSkipped,
|
|
232
246
|
};
|
|
233
247
|
|
|
234
248
|
const byAgent = {};
|
|
@@ -305,7 +319,7 @@ function renderTable(agg) {
|
|
|
305
319
|
lines.push(window);
|
|
306
320
|
lines.push('');
|
|
307
321
|
lines.push('Totals');
|
|
308
|
-
lines.push(` Calls : ${agg.totals.calls}`);
|
|
322
|
+
lines.push(` Calls : ${agg.totals.calls}${agg.totals.malformed_skipped > 0 ? ` (+${agg.totals.malformed_skipped} malformed)` : ''}`);
|
|
309
323
|
lines.push(` Input tokens : ${agg.totals.input_tokens.toLocaleString()}`);
|
|
310
324
|
lines.push(` Output tokens : ${agg.totals.output_tokens.toLocaleString()}`);
|
|
311
325
|
lines.push(` Cache read : ${agg.totals.cache_read_tokens.toLocaleString()}`);
|
|
@@ -15,7 +15,7 @@ const {
|
|
|
15
15
|
BUDGET_LIMIT_BUGFIX, BUDGET_LIMIT_FULL, STABILITY_RATIO, FEATURE_RATIO,
|
|
16
16
|
DIMINISHING_RETURNS_THRESHOLD,
|
|
17
17
|
AUTO_RUN_FILE, FOCUS_CATEGORIES, FOCUS_SOURCES, CATEGORY_PRIORITY_RANGE, CATEGORY_DEFAULTS,
|
|
18
|
-
DEFAULT_MAX_CYCLES, DEFAULT_TOTAL_BUDGET,
|
|
18
|
+
DEFAULT_MAX_CYCLES, DEFAULT_TOTAL_BUDGET, VERIFY_RESERVE_FRACTION, VERIFY_RESERVE_FLOOR,
|
|
19
19
|
BUDGET_MIN, BUDGET_MAX, MAX_CYCLES_MIN, MAX_CYCLES_MAX, TOTAL_BUDGET_MIN, TOTAL_BUDGET_MAX,
|
|
20
20
|
AUTORUN_STATUSES, DOC_SYNC_FILES, COMMAND_RENAME_MAP,
|
|
21
21
|
} = require('./constants.cjs');
|
|
@@ -693,12 +693,28 @@ function generateRunId(cwd) {
|
|
|
693
693
|
* @param {boolean} raw - Raw output mode
|
|
694
694
|
* @param {...string} args - CLI arguments
|
|
695
695
|
*/
|
|
696
|
+
/**
|
|
697
|
+
* Advisory budget indicators for a run: raw remaining, the verify reserve, the
|
|
698
|
+
* remaining budget for NEW work (excludes the reserve), and whether spend has
|
|
699
|
+
* crossed into the reserved headroom. Pure — no behavior change; surfaced so the
|
|
700
|
+
* human/HUD can see the reserve even when it isn't being enforced.
|
|
701
|
+
*/
|
|
702
|
+
function budgetIndicators(run) {
|
|
703
|
+
const used = run.totals ? run.totals.points_used : 0;
|
|
704
|
+
const reserve = run.verify_reserve || 0;
|
|
705
|
+
return {
|
|
706
|
+
budget_remaining: run.total_budget - used,
|
|
707
|
+
verify_reserve: reserve,
|
|
708
|
+
new_work_budget_remaining: run.total_budget - reserve - used,
|
|
709
|
+
into_verify_reserve: reserve > 0 && used >= run.total_budget - reserve,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
696
713
|
function focusAutoStatus(cwd, raw) {
|
|
697
714
|
const run = readAutoRun(cwd);
|
|
698
715
|
if (!run) return error('No auto-run found. Start with: focus auto --category <name>');
|
|
699
|
-
const budgetRemaining = run.total_budget - (run.totals ? run.totals.points_used : 0);
|
|
700
716
|
const cyclesRemaining = run.max_cycles - (run.totals ? run.totals.cycles_completed : 0);
|
|
701
|
-
return output({ ...run,
|
|
717
|
+
return output({ ...run, ...budgetIndicators(run), cycles_remaining: cyclesRemaining }, raw);
|
|
702
718
|
}
|
|
703
719
|
|
|
704
720
|
function focusAutoStop(cwd, raw) {
|
|
@@ -781,6 +797,30 @@ function focusAutoUpdate(cwd, raw, getVal) {
|
|
|
781
797
|
cycle.tests_verified = tv.verified;
|
|
782
798
|
|
|
783
799
|
if (!run.cycles) run.cycles = [];
|
|
800
|
+
|
|
801
|
+
// Attribution: anchor this cycle to a start, measure its wall-clock duration,
|
|
802
|
+
// name the driving command, and JOIN to the authoritative cost ledger so the
|
|
803
|
+
// cycle carries the agents + dollars behind its item counts (not just a
|
|
804
|
+
// self-reported points estimate). windowStart = the prior cycle's end, or the
|
|
805
|
+
// run start for cycle 1. Server-side join — no fakeable CLI arg.
|
|
806
|
+
const windowStart = run.cycles.length ? run.cycles[run.cycles.length - 1].timestamp : run.started_at;
|
|
807
|
+
cycle.started_at = windowStart || null;
|
|
808
|
+
const durMs = windowStart ? (new Date(cycle.timestamp) - new Date(windowStart)) : NaN;
|
|
809
|
+
cycle.duration_ms = Number.isFinite(durMs) ? durMs : null;
|
|
810
|
+
cycle.command = getVal('--command', run.category || run.source || null);
|
|
811
|
+
cycle.cost_usd = null;
|
|
812
|
+
cycle.tokens = null;
|
|
813
|
+
cycle.agents = [];
|
|
814
|
+
if (windowStart) {
|
|
815
|
+
try {
|
|
816
|
+
const agg = require('./cost.cjs').aggregate(cwd, { since: windowStart, until: cycle.timestamp });
|
|
817
|
+
if (agg && agg.totals) {
|
|
818
|
+
cycle.cost_usd = agg.totals.calls > 0 ? agg.totals.cost_usd : null;
|
|
819
|
+
cycle.tokens = { input: agg.totals.input_tokens, output: agg.totals.output_tokens, cache_read: agg.totals.cache_read_tokens };
|
|
820
|
+
cycle.agents = agg.by_agent ? Object.keys(agg.by_agent) : [];
|
|
821
|
+
}
|
|
822
|
+
} catch { /* cost is observability, never the critical path */ }
|
|
823
|
+
}
|
|
784
824
|
run.cycles.push(cycle);
|
|
785
825
|
|
|
786
826
|
if (!run.totals) {
|
|
@@ -848,6 +888,9 @@ function focusAutoCheckpointCommit(cwd, cycle, run) {
|
|
|
848
888
|
// is a planning-doc committer, so honor it. commit_docs=false → hands the .planning
|
|
849
889
|
// commit (and any report regeneration) back to the user.
|
|
850
890
|
if (config.commit_docs === false) return null;
|
|
891
|
+
// Reconcile the always-loaded project memory before staging so the committed
|
|
892
|
+
// .planning/ snapshot carries the trimmed state.md (no-op when already lean).
|
|
893
|
+
try { require('./memory-optimize.cjs').maybeAutoOptimizeMemory(cwd); } catch { /* never block the checkpoint */ }
|
|
851
894
|
// Enabled projects: refresh the HTML reports before staging so the committed
|
|
852
895
|
// .planning/ snapshot reflects this cycle.
|
|
853
896
|
maybeRenderPhaseReports(cwd);
|
|
@@ -865,7 +908,13 @@ function determineStopReason(cycle, run) {
|
|
|
865
908
|
if (cycle.tests_after < cycle.tests_before) return 'regression';
|
|
866
909
|
// Budget is advisory by default — it only STOPS the run when explicitly enforced.
|
|
867
910
|
// Otherwise the overage is tracked/surfaced (indication) and the loop continues.
|
|
911
|
+
// Hard cap (full budget) is evaluated FIRST so it remains the absolute boundary;
|
|
912
|
+
// the softer verify-reserve stop only fires in the band below it.
|
|
868
913
|
if (run.budget_enforce && run.totals.points_used >= run.total_budget) return 'budget_cap';
|
|
914
|
+
// Verify-reserve: under enforcement, stop once new-work spend crosses into the
|
|
915
|
+
// reserved headroom so re-verification still has points. Advisory mode never trips.
|
|
916
|
+
const reserve = run.verify_reserve || 0;
|
|
917
|
+
if (run.budget_enforce && reserve > 0 && run.totals.points_used >= run.total_budget - reserve) return 'budget_reserve_reached';
|
|
869
918
|
if (run.totals.cycles_completed >= run.max_cycles) return 'max_cycles';
|
|
870
919
|
if (cycle.items_completed === 0) {
|
|
871
920
|
// Security category gets a descriptive stop reason rather than generic zero_completed
|
|
@@ -902,9 +951,8 @@ function focusAutoContinue(cwd, raw) {
|
|
|
902
951
|
run.status = run.totals && run.totals.cycles_completed > 0 ? AUTORUN_STATUSES.IN_PROGRESS : AUTORUN_STATUSES.INITIALIZED;
|
|
903
952
|
run.stop_reason = null;
|
|
904
953
|
writeAutoRun(cwd, run);
|
|
905
|
-
const budgetRemaining = run.total_budget - (run.totals ? run.totals.points_used : 0);
|
|
906
954
|
const cyclesRemaining = run.max_cycles - (run.totals ? run.totals.cycles_completed : 0);
|
|
907
|
-
return output({ ...run,
|
|
955
|
+
return output({ ...run, ...budgetIndicators(run), cycles_remaining: cyclesRemaining }, raw);
|
|
908
956
|
}
|
|
909
957
|
|
|
910
958
|
function focusAutoInit(cwd, raw, getVal, hasFlag) {
|
|
@@ -935,6 +983,15 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
|
|
|
935
983
|
// only when the user opts in via config `budget.enforce` or `--enforce-budget`.
|
|
936
984
|
const budgetConfig = loadConfig(cwd).budget || {};
|
|
937
985
|
const budgetEnforce = hasFlag('--enforce-budget') || budgetConfig.enforce === true;
|
|
986
|
+
// Verify-reserve: hold back a fraction of the spawn budget so re-verification
|
|
987
|
+
// isn't starved of points. Advisory by default (surfaced as an indicator);
|
|
988
|
+
// only a hard early stop under --enforce-budget. Clamp the fraction to 0..0.5.
|
|
989
|
+
const reserveRaw = getVal('--verify-reserve', null);
|
|
990
|
+
let reserveFraction = reserveRaw != null ? Number(reserveRaw)
|
|
991
|
+
: (typeof budgetConfig.verify_reserve === 'number' ? budgetConfig.verify_reserve : VERIFY_RESERVE_FRACTION);
|
|
992
|
+
if (!Number.isFinite(reserveFraction) || reserveFraction < 0) reserveFraction = 0;
|
|
993
|
+
if (reserveFraction > 0.5) reserveFraction = 0.5;
|
|
994
|
+
const verifyReserve = reserveFraction > 0 ? Math.max(VERIFY_RESERVE_FLOOR, Math.ceil(totalBudget * reserveFraction)) : 0;
|
|
938
995
|
|
|
939
996
|
if (!FOCUS_MODES.includes(mode)) return error(`Mode must be one of: ${FOCUS_MODES.join(', ')}`);
|
|
940
997
|
if (budget < BUDGET_MIN || budget > BUDGET_MAX) return error(`Budget must be between ${BUDGET_MIN} and ${BUDGET_MAX}`);
|
|
@@ -944,6 +1001,7 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
|
|
|
944
1001
|
const runData = {
|
|
945
1002
|
run_id: generateRunId(cwd),
|
|
946
1003
|
status: AUTORUN_STATUSES.INITIALIZED,
|
|
1004
|
+
started_at: new Date().toISOString(),
|
|
947
1005
|
source: source,
|
|
948
1006
|
category: category,
|
|
949
1007
|
mode: mode,
|
|
@@ -954,6 +1012,7 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
|
|
|
954
1012
|
max_cycles: maxCycles,
|
|
955
1013
|
total_budget: totalBudget,
|
|
956
1014
|
budget_enforce: budgetEnforce,
|
|
1015
|
+
verify_reserve: verifyReserve,
|
|
957
1016
|
priority_range: category ? CATEGORY_PRIORITY_RANGE[category] : { min: 0, max: 6 },
|
|
958
1017
|
deep_review_enabled: hasFlag('--deep-review'),
|
|
959
1018
|
tests_baseline: null,
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PAN memory optimize (A1) — trim the append-heavy tiers so the always-loaded
|
|
5
|
+
* project memory stays small, per the memory-management research:
|
|
6
|
+
* - reconcile on write (dedupe / invalidate / consolidate), NEVER blind-append
|
|
7
|
+
* - retain by importance, not FIFO (never drop the tail blindly)
|
|
8
|
+
* - reversible: overflow is ARCHIVED (dated), never hard-deleted; git keeps the trace
|
|
9
|
+
* - idempotent: re-running an already-lean file is a no-op (zero git churn)
|
|
10
|
+
*
|
|
11
|
+
* SAFETY: this only touches TOP-LEVEL BULLET LISTS inside recognized append-heavy
|
|
12
|
+
* sections (Decisions / Blockers / Concerns / Todos / Session Continuity). Tables,
|
|
13
|
+
* prose, sub-bullets, frontmatter, and every other section are preserved byte-for-byte.
|
|
14
|
+
* The command is dry-run by default (`--apply` to write). `optimizeStateContent` is a
|
|
15
|
+
* pure function of the content, so the whole reconcile is unit-testable in isolation.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { output, safeReadFile } = require('./core.cjs');
|
|
21
|
+
const { planningPath } = require('./utils.cjs');
|
|
22
|
+
const { writeStateMd } = require('./state.cjs');
|
|
23
|
+
const { readMemory, parseEntries, listMemoryAgents, compactMemory, DEFAULT_MAX_ENTRIES, MEMORY_DIR } = require('./memory.cjs');
|
|
24
|
+
|
|
25
|
+
const DEFAULT_KEEP = 12; // recent bullets kept inline per section
|
|
26
|
+
const STATE_ARCHIVE_FILE = 'state-archive.md';
|
|
27
|
+
|
|
28
|
+
// Sections whose bullet lists grow unbounded and are safe to reconcile.
|
|
29
|
+
const APPEND_HEAVY = /\b(decisions|blockers|concerns|pending todos|todos|session continuity|accumulated context|recent activity)\b/i;
|
|
30
|
+
// A bullet that is just a placeholder — dropped once real entries exist.
|
|
31
|
+
const PLACEHOLDER = /^-\s*(none(\s+yet)?|n\/a|tbd|todo|—|-)\.?\s*$/i;
|
|
32
|
+
|
|
33
|
+
const isHeading = (l) => /^#{1,6}\s+\S/.test(l);
|
|
34
|
+
const headingText = (l) => (l.match(/^#{1,6}\s+(.*)$/) || [, ''])[1];
|
|
35
|
+
const isBullet = (l) => /^-\s+\S/.test(l);
|
|
36
|
+
const isIndented = (l) => /^\s+\S/.test(l);
|
|
37
|
+
|
|
38
|
+
/** Split content into ordered blocks: an optional heading + the lines under it. */
|
|
39
|
+
function parseSections(content) {
|
|
40
|
+
const sections = [];
|
|
41
|
+
let cur = { heading: null, lines: [] };
|
|
42
|
+
for (const line of content.split('\n')) {
|
|
43
|
+
if (isHeading(line)) {
|
|
44
|
+
sections.push(cur);
|
|
45
|
+
cur = { heading: line, lines: [] };
|
|
46
|
+
} else {
|
|
47
|
+
cur.lines.push(line);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
sections.push(cur);
|
|
51
|
+
return sections;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reassemble sections into content byte-for-byte when nothing changed. */
|
|
55
|
+
function joinSections(sections) {
|
|
56
|
+
return sections
|
|
57
|
+
.flatMap((s) => (s.heading !== null ? [s.heading, ...s.lines] : s.lines))
|
|
58
|
+
.join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reconcile the bullet list inside one section body: dedupe, strip placeholders,
|
|
63
|
+
* and cap to the last `keepN` entries. An "entry" is a top-level `- ` bullet plus
|
|
64
|
+
* its indented continuation lines, so a bullet is never orphaned from its detail.
|
|
65
|
+
* Overflow entries are pushed to `archived`. Returns { lines, changed }.
|
|
66
|
+
*/
|
|
67
|
+
function reconcileBullets(lines, keepN, archived) {
|
|
68
|
+
const firstB = lines.findIndex(isBullet);
|
|
69
|
+
if (firstB === -1) return { lines, changed: false };
|
|
70
|
+
|
|
71
|
+
const pre = lines.slice(0, firstB);
|
|
72
|
+
const rest = lines.slice(firstB);
|
|
73
|
+
const entries = [];
|
|
74
|
+
let i = 0;
|
|
75
|
+
for (; i < rest.length; ) {
|
|
76
|
+
const l = rest[i];
|
|
77
|
+
if (isBullet(l)) {
|
|
78
|
+
const eLines = [l];
|
|
79
|
+
i++;
|
|
80
|
+
while (i < rest.length && isIndented(rest[i])) { eLines.push(rest[i]); i++; }
|
|
81
|
+
entries.push({ key: eLines.join('\n').trim(), lines: eLines });
|
|
82
|
+
} else if (l.trim() === '') {
|
|
83
|
+
i++; // blank between bullets — normalized away
|
|
84
|
+
} else {
|
|
85
|
+
break; // trailer prose begins — stop grouping
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const trailer = rest.slice(i);
|
|
89
|
+
|
|
90
|
+
// 1. dedupe (keep first occurrence)
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const deduped = entries.filter((e) => (seen.has(e.key) ? false : (seen.add(e.key), true)));
|
|
93
|
+
// 2. strip placeholders once real entries exist
|
|
94
|
+
const real = deduped.filter((e) => !PLACEHOLDER.test(e.key));
|
|
95
|
+
const kept0 = real.length ? real : deduped;
|
|
96
|
+
// 3. cap to the most-recent keepN (bullets are appended, so the tail is newest)
|
|
97
|
+
let kept = kept0;
|
|
98
|
+
const dropped = [];
|
|
99
|
+
if (kept0.length > keepN) {
|
|
100
|
+
dropped.push(...kept0.slice(0, kept0.length - keepN));
|
|
101
|
+
kept = kept0.slice(-keepN);
|
|
102
|
+
}
|
|
103
|
+
for (const d of dropped) archived.push(d.lines.join('\n'));
|
|
104
|
+
|
|
105
|
+
const changed = deduped.length !== entries.length || kept0.length !== deduped.length || dropped.length > 0;
|
|
106
|
+
const newLines = [...pre, ...kept.flatMap((e) => e.lines), ...trailer];
|
|
107
|
+
// Preserve the section's trailing blank line (the blank that separates it from
|
|
108
|
+
// the next heading) so reconciling never collapses two sections together.
|
|
109
|
+
const endsBlank = lines.length > 0 && lines[lines.length - 1].trim() === '';
|
|
110
|
+
if (endsBlank && (newLines.length === 0 || newLines[newLines.length - 1].trim() !== '')) newLines.push('');
|
|
111
|
+
return { lines: newLines, changed };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pure reconcile of state.md content.
|
|
116
|
+
* @returns {{content:string, changed:boolean, archived:string[], sectionsTouched:string[]}}
|
|
117
|
+
*/
|
|
118
|
+
function optimizeStateContent(content, opts = {}) {
|
|
119
|
+
const keepN = Number.isFinite(opts.keep) && opts.keep > 0 ? opts.keep : DEFAULT_KEEP;
|
|
120
|
+
const sections = parseSections(content);
|
|
121
|
+
const archived = [];
|
|
122
|
+
const sectionsTouched = [];
|
|
123
|
+
let changed = false;
|
|
124
|
+
|
|
125
|
+
for (const s of sections) {
|
|
126
|
+
if (s.heading === null) continue;
|
|
127
|
+
if (!APPEND_HEAVY.test(headingText(s.heading))) continue;
|
|
128
|
+
const before = archived.length;
|
|
129
|
+
const r = reconcileBullets(s.lines, keepN, archived);
|
|
130
|
+
if (r.changed) {
|
|
131
|
+
s.lines = r.lines;
|
|
132
|
+
changed = true;
|
|
133
|
+
sectionsTouched.push(headingText(s.heading).trim());
|
|
134
|
+
}
|
|
135
|
+
void before;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { content: changed ? joinSections(sections) : content, changed, archived, sectionsTouched };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Command ────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function archivePath(cwd) {
|
|
144
|
+
return path.join(planningPath(cwd), MEMORY_DIR, STATE_ARCHIVE_FILE);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Append trimmed entries to the dated, append-only state archive (reversible). */
|
|
148
|
+
function appendArchive(cwd, entries, now) {
|
|
149
|
+
if (!entries.length) return;
|
|
150
|
+
const p = archivePath(cwd);
|
|
151
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
152
|
+
const stamp = now || '(undated)';
|
|
153
|
+
const block = `\n## Archived ${stamp}\n\n${entries.map((e) => e).join('\n')}\n`;
|
|
154
|
+
fs.appendFileSync(p, block, 'utf-8');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `memory optimize [--apply] [--keep N]` — reconcile state.md + consolidate
|
|
159
|
+
* over-budget agent logs. Dry-run by default: reports what WOULD change.
|
|
160
|
+
*/
|
|
161
|
+
function cmdMemoryOptimize(cwd, opts = {}, raw) {
|
|
162
|
+
const apply = !!opts.apply;
|
|
163
|
+
const keep = opts.keep;
|
|
164
|
+
const statePath = path.join(planningPath(cwd), 'state.md');
|
|
165
|
+
const before = safeReadFile(statePath);
|
|
166
|
+
|
|
167
|
+
const result = { apply, state: { changed: false }, agents: [], archived: 0 };
|
|
168
|
+
|
|
169
|
+
if (before != null) {
|
|
170
|
+
const opt = optimizeStateContent(before, { keep });
|
|
171
|
+
result.state = {
|
|
172
|
+
changed: opt.changed,
|
|
173
|
+
sections_touched: opt.sectionsTouched,
|
|
174
|
+
archived_entries: opt.archived.length,
|
|
175
|
+
before_bytes: Buffer.byteLength(before),
|
|
176
|
+
after_bytes: Buffer.byteLength(opt.content),
|
|
177
|
+
};
|
|
178
|
+
result.archived = opt.archived.length;
|
|
179
|
+
if (apply && opt.changed) {
|
|
180
|
+
appendArchive(cwd, opt.archived, opts.now);
|
|
181
|
+
writeStateMd(statePath, opt.content, cwd);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
result.state = { changed: false, reason: 'no_state_md' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Consolidate any per-agent log over the entry cap (reuses compactMemory, which
|
|
188
|
+
// no-ops under the cap). Dry-run counts entries without writing.
|
|
189
|
+
try {
|
|
190
|
+
for (const a of listMemoryAgents(cwd)) {
|
|
191
|
+
const rawMem = readMemory(cwd, a);
|
|
192
|
+
if (rawMem == null) continue;
|
|
193
|
+
const count = parseEntries(rawMem).length;
|
|
194
|
+
if (count > DEFAULT_MAX_ENTRIES) {
|
|
195
|
+
let removed = 0;
|
|
196
|
+
if (apply) { const r = compactMemory(cwd, a, DEFAULT_MAX_ENTRIES); removed = (r && r.removed) || 0; }
|
|
197
|
+
result.agents.push({ agent: a, entries: count, over_cap: true, compacted: apply, removed });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch { /* agent sweep is best-effort */ }
|
|
201
|
+
|
|
202
|
+
const summary = result.state.changed
|
|
203
|
+
? `${apply ? 'optimized' : 'would optimize'} state.md (${result.state.sections_touched.join(', ')}); ${result.archived} entr${result.archived === 1 ? 'y' : 'ies'} archived${result.agents.length ? `; ${result.agents.length} agent log(s)` : ''}`
|
|
204
|
+
: `state.md already lean${result.agents.length ? `; ${result.agents.length} agent log(s) over budget` : ''} — nothing to do`;
|
|
205
|
+
output(result, raw, summary);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Auto-optimize (A3) — flow-embedded reconcile ────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether auto-optimize is enabled for this project. Reads config.json directly
|
|
212
|
+
* (loadConfig doesn't surface the memory block) and defaults to ON — the point
|
|
213
|
+
* of the feature is that reconcile happens automatically, not by hand. Set
|
|
214
|
+
* `memory.auto_optimize: false` in .planning/config.json to opt out. Absent or
|
|
215
|
+
* malformed config → enabled.
|
|
216
|
+
*/
|
|
217
|
+
function autoOptimizeEnabled(cwd) {
|
|
218
|
+
try {
|
|
219
|
+
const raw = JSON.parse(fs.readFileSync(path.join(planningPath(cwd), 'config.json'), 'utf-8'));
|
|
220
|
+
return !(raw.memory && raw.memory.auto_optimize === false);
|
|
221
|
+
} catch {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Reconcile state.md as an embedded step of a flow (focus checkpoint, normal
|
|
228
|
+
* session record). Best-effort and side-effect-light: honors the config gate,
|
|
229
|
+
* is a true no-op when state.md is already lean (zero git churn), archives any
|
|
230
|
+
* overflow, and NEVER throws into the calling flow. Returns a small status.
|
|
231
|
+
* @returns {{optimized:boolean, reason?:string, sections?:string[], archived?:number}}
|
|
232
|
+
*/
|
|
233
|
+
function maybeAutoOptimizeMemory(cwd, opts = {}) {
|
|
234
|
+
try {
|
|
235
|
+
if (!autoOptimizeEnabled(cwd)) return { optimized: false, reason: 'disabled' };
|
|
236
|
+
const statePath = path.join(planningPath(cwd), 'state.md');
|
|
237
|
+
const before = safeReadFile(statePath);
|
|
238
|
+
if (before == null) return { optimized: false, reason: 'no_state_md' };
|
|
239
|
+
const opt = optimizeStateContent(before, { keep: opts.keep });
|
|
240
|
+
if (!opt.changed) return { optimized: false, reason: 'clean' };
|
|
241
|
+
appendArchive(cwd, opt.archived, opts.now);
|
|
242
|
+
writeStateMd(statePath, opt.content, cwd);
|
|
243
|
+
return { optimized: true, sections: opt.sectionsTouched, archived: opt.archived.length };
|
|
244
|
+
} catch {
|
|
245
|
+
return { optimized: false, reason: 'error' };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
optimizeStateContent, reconcileBullets, parseSections, joinSections, cmdMemoryOptimize,
|
|
251
|
+
maybeAutoOptimizeMemory, autoOptimizeEnabled,
|
|
252
|
+
APPEND_HEAVY, PLACEHOLDER, DEFAULT_KEEP, STATE_ARCHIVE_FILE,
|
|
253
|
+
};
|