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.
@@ -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 { /* skip malformed line */ }
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, budget_remaining: budgetRemaining, cycles_remaining: cyclesRemaining }, raw);
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, budget_remaining: budgetRemaining, cycles_remaining: cyclesRemaining }, raw);
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
+ };