pan-wizard 3.19.0 → 3.20.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.
@@ -1324,105 +1324,21 @@ return { areas_mapped: maps.filter(Boolean).length, synthesis }
1324
1324
 
1325
1325
  // ─── AGENTS.md universal rules layer (ADR-0028 Phase 3) ─────────────────────
1326
1326
  //
1327
- // AGENTS.md is the cross-runtime project-instructions standard; every PAN
1328
- // target runtime (and Antigravity CLI) reads it natively. PAN contributes one
1329
- // marker-fenced section so agents in any runtime understand the PAN context
1330
- // when reading the repo. User content outside the markers is never touched.
1327
+ // The builders + markers live under pan-wizard-core/ (the single source of
1328
+ // truth, shipped into every install) so the installer and the installed
1329
+ // `pan-tools memory rebuild` regenerate byte-identical content. They are
1330
+ // re-exported here so all existing installer callers and tests keep importing
1331
+ // them from install-lib unchanged.
1331
1332
 
1332
- const PAN_AGENTS_BEGIN = '<!-- BEGIN PAN WIZARD -->';
1333
- const PAN_AGENTS_END = '<!-- END PAN WIZARD -->';
1334
-
1335
- /**
1336
- * Build the PAN section for AGENTS.md (marker-fenced, runtime-neutral).
1337
- * @returns {string} The fenced section, no leading/trailing blank lines.
1338
- */
1339
- function buildAgentsMdSection() {
1340
- return [
1341
- PAN_AGENTS_BEGIN,
1342
- '## PAN Wizard',
1343
- '',
1344
- 'This project uses PAN Wizard for structured, phase-based planning and execution.',
1345
- '',
1346
- '- `.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.',
1347
- '- PAN commands install as `pan-*` skills/commands (for example `/pan-help`, `/pan-new-project`, `/pan-exec-phase`). Start with `/pan-help`.',
1348
- '- The `pan-tools` dispatcher backs every command; it lives under `pan-wizard-core/` inside the runtime\'s config directory (or `.agents/` for unified installs).',
1349
- PAN_AGENTS_END,
1350
- ].join('\n');
1351
- }
1352
-
1353
- /**
1354
- * Insert or replace the PAN section in AGENTS.md content.
1355
- * - No existing content (null/empty) → just the section.
1356
- * - Markers present → replace exactly the fenced block, preserving everything
1357
- * around it.
1358
- * - Markers absent → append with a separating blank line.
1359
- * @param {string|null} existing - Current AGENTS.md content, or null if absent
1360
- * @param {string} section - Output of buildAgentsMdSection()
1361
- * @returns {string} New file content (always newline-terminated)
1362
- */
1363
- function upsertAgentsMdSection(existing, section) {
1364
- if (!existing || !existing.trim()) {
1365
- return section + '\n';
1366
- }
1367
- const beginIdx = existing.indexOf(PAN_AGENTS_BEGIN);
1368
- const endIdx = existing.indexOf(PAN_AGENTS_END);
1369
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
1370
- const before = existing.slice(0, beginIdx);
1371
- const after = existing.slice(endIdx + PAN_AGENTS_END.length);
1372
- return before + section + after;
1373
- }
1374
- return existing.trimEnd() + '\n\n' + section + '\n';
1375
- }
1376
-
1377
- /**
1378
- * Remove the PAN section from AGENTS.md content.
1379
- * @param {string} existing - Current AGENTS.md content
1380
- * @returns {string|null} Content without the PAN block, or null when nothing
1381
- * meaningful remains (caller should delete the file).
1382
- */
1383
- function removeAgentsMdSection(existing) {
1384
- if (!existing) return null;
1385
- const beginIdx = existing.indexOf(PAN_AGENTS_BEGIN);
1386
- const endIdx = existing.indexOf(PAN_AGENTS_END);
1387
- if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
1388
- return existing; // no PAN block — leave untouched
1389
- }
1390
- const before = existing.slice(0, beginIdx);
1391
- const after = existing.slice(endIdx + PAN_AGENTS_END.length);
1392
- const remaining = (before.trimEnd() + '\n\n' + after.trimStart()).trim();
1393
- return remaining ? remaining + '\n' : null;
1394
- }
1395
-
1396
- /**
1397
- * Ensure CLAUDE.md bridges to AGENTS.md via a marker-fenced @AGENTS.md import
1398
- * (Claude Code's documented pattern for adopting the universal rules file).
1399
- * Idempotent; preserves all user content.
1400
- * @param {string|null} existing - Current CLAUDE.md content, or null if absent
1401
- * @returns {string} New file content
1402
- */
1403
- function ensureClaudeMdImport(existing) {
1404
- const block = `${PAN_AGENTS_BEGIN}\n@AGENTS.md\n${PAN_AGENTS_END}`;
1405
- if (!existing || !existing.trim()) {
1406
- return block + '\n';
1407
- }
1408
- if (existing.includes(PAN_AGENTS_BEGIN)) {
1409
- return existing; // bridge (or another PAN block) already present
1410
- }
1411
- if (/^@AGENTS\.md\s*$/m.test(existing)) {
1412
- return existing; // user already imports AGENTS.md themselves
1413
- }
1414
- return existing.trimEnd() + '\n\n' + block + '\n';
1415
- }
1416
-
1417
- /**
1418
- * Remove the PAN bridge block from CLAUDE.md content.
1419
- * @param {string} existing - Current CLAUDE.md content
1420
- * @returns {string|null} Content without the bridge, or null when nothing
1421
- * meaningful remains (caller should delete the file).
1422
- */
1423
- function removeClaudeMdImport(existing) {
1424
- return removeAgentsMdSection(existing);
1425
- }
1333
+ const {
1334
+ PAN_AGENTS_BEGIN,
1335
+ PAN_AGENTS_END,
1336
+ buildAgentsMdSection,
1337
+ upsertAgentsMdSection,
1338
+ removeAgentsMdSection,
1339
+ ensureClaudeMdImport,
1340
+ removeClaudeMdImport,
1341
+ } = require('../pan-wizard-core/bin/lib/agents-md.cjs');
1426
1342
 
1427
1343
  // ─── Exports ────────────────────────────────────────────────────────────────
1428
1344
 
@@ -56,41 +56,41 @@ function buildCostRecord(data, cwd) {
56
56
  // Only log actual subagent stops; ignore other Stop variants.
57
57
  if (data.hook_event_name && data.hook_event_name !== 'SubagentStop') return null;
58
58
 
59
- // P-1805 (v3.7.8): if data.usage is missing/empty (Claude Code headless mode
60
- // doesn't include it in the SubagentStop payload), fall back to reading the
61
- // transcript_path JSONL and summing usage across the subagent's messages.
62
- // Same approach as pan-trace-logger.js for consistency.
63
- //
64
- // 2026-06: the SubagentStop payload carries no model id either, which left
65
- // every hook record with model:null and /pan:cost unable to price it. The
66
- // transcript's assistant messages carry message.model right next to the
67
- // usage we already read — capture it whenever data.model is absent.
68
- let inputTokens = extractNumber(data.usage, 'input_tokens');
69
- let outputTokens = extractNumber(data.usage, 'output_tokens');
70
- let cacheRead = extractNumber(data.usage, 'cache_read_input_tokens');
71
- let cacheWrite = extractNumber(data.usage, 'cache_creation_input_tokens');
59
+ // Per-call token counts come from the transcript SLICE — the records since
60
+ // this transcript's previous SubagentStop cursor. The SubagentStop `data.usage`,
61
+ // when Claude Code supplies it, is a CUMULATIVE session counter, NOT this
62
+ // subagent's delta, so logging it verbatim stamped impossible per-row magnitudes
63
+ // (tens of millions of output tokens, billions of cache-read) onto every record
64
+ // and made /pan:cost and the optimizer unusable (field reports 2026-06 / 2026-07).
65
+ // The transcript slice is the authoritative per-invocation delta; `data.usage`
66
+ // is a guarded fallback used only when no transcript is available.
72
67
  let model = typeof data.model === 'string' && data.model ? data.model : null;
73
- const needUsage = (inputTokens + outputTokens + cacheRead + cacheWrite) === 0;
74
- if ((needUsage || !model) && data.transcript_path) {
75
- // Attribute only the transcript slice since the previous SubagentStop for
76
- // this transcript, so a shared-session transcript is never re-summed on
77
- // every event (field report 2026-06 — the billion-token cache-read bug).
68
+ let inputTokens = 0;
69
+ let outputTokens = 0;
70
+ let cacheRead = 0;
71
+ let cacheWrite = 0;
72
+ if (data.transcript_path) {
78
73
  const cursor = readCursor(cwd);
79
74
  const since = cursor[data.transcript_path] || 0;
80
75
  const fromTranscript = readUsageFromTranscript(data.transcript_path, data.session_id, since);
81
- if (needUsage) {
82
- inputTokens = fromTranscript.input_tokens;
83
- outputTokens = fromTranscript.output_tokens;
84
- cacheRead = fromTranscript.cache_read_input_tokens;
85
- cacheWrite = fromTranscript.cache_creation_input_tokens;
86
- }
76
+ inputTokens = fromTranscript.input_tokens;
77
+ outputTokens = fromTranscript.output_tokens;
78
+ cacheRead = fromTranscript.cache_read_input_tokens;
79
+ cacheWrite = fromTranscript.cache_creation_input_tokens;
87
80
  if (!model) model = fromTranscript.model;
88
- // Advance the cursor to the end of the transcript so the next subagent's
89
- // record starts fresh (these slices partition the transcript — no overlap).
81
+ // Advance the cursor so the next subagent's record starts fresh — the slices
82
+ // partition the transcript, so it is never re-summed on every event.
90
83
  if (fromTranscript.lineCount > since) {
91
84
  cursor[data.transcript_path] = fromTranscript.lineCount;
92
85
  writeCursor(cwd, cursor);
93
86
  }
87
+ } else {
88
+ // No transcript to slice — best-effort from data.usage, plausibility-guarded
89
+ // so a cumulative counter can never slip through as a per-call value.
90
+ inputTokens = clampPlausible(extractNumber(data.usage, 'input_tokens'));
91
+ outputTokens = clampPlausible(extractNumber(data.usage, 'output_tokens'));
92
+ cacheRead = clampPlausible(extractNumber(data.usage, 'cache_read_input_tokens'));
93
+ cacheWrite = clampPlausible(extractNumber(data.usage, 'cache_creation_input_tokens'));
94
94
  }
95
95
 
96
96
  const record = {
@@ -118,6 +118,15 @@ function extractNumber(obj, key) {
118
118
  return typeof v === 'number' ? v : 0;
119
119
  }
120
120
 
121
+ // A single subagent call's token counts never realistically exceed this; a value
122
+ // above it is a cumulative session counter that leaked in, so we drop it to 0
123
+ // rather than poison the ledger. Generous vs. any real call, tiny vs. the
124
+ // billions/tens-of-millions the cumulative bug produced.
125
+ const PLAUSIBLE_MAX = 20000000;
126
+ function clampPlausible(n) {
127
+ return typeof n === 'number' && n >= 0 && n <= PLAUSIBLE_MAX ? n : 0;
128
+ }
129
+
121
130
  /**
122
131
  * P-1805 (v3.7.8): read transcript JSONL and sum usage across assistant messages.
123
132
  *
@@ -184,13 +193,34 @@ function appendRecord(cwd, record) {
184
193
  try {
185
194
  const dir = path.join(cwd, '.planning', METRICS_DIR);
186
195
  fs.mkdirSync(dir, { recursive: true });
187
- fs.appendFileSync(path.join(dir, TOKENS_FILE), JSON.stringify(record) + '\n', 'utf-8');
196
+ const file = path.join(dir, TOKENS_FILE);
197
+ // Idempotency guard: a re-fired SubagentStop must not double-log. Skip the
198
+ // append when this record is identical (every field but the timestamp) to
199
+ // the immediately-preceding row — the source of ~57% duplicate rows in the
200
+ // field (2026-07). Best-effort: any read error just proceeds with the append.
201
+ if (isDuplicateOfLastRecord(file, record)) return false;
202
+ fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf-8');
188
203
  return true;
189
204
  } catch {
190
205
  return false;
191
206
  }
192
207
  }
193
208
 
209
+ /** True when `record` equals the last JSONL row of `file`, ignoring `ts`. */
210
+ function isDuplicateOfLastRecord(file, record) {
211
+ let prev;
212
+ try {
213
+ const raw = fs.readFileSync(file, 'utf-8');
214
+ const lines = raw.split('\n').filter(Boolean);
215
+ if (!lines.length) return false;
216
+ prev = JSON.parse(lines[lines.length - 1]);
217
+ } catch {
218
+ return false; // no file / unreadable / bad JSON → not a duplicate
219
+ }
220
+ const strip = (r) => { const { ts, ...rest } = r; return JSON.stringify(rest); };
221
+ return strip(prev) === strip(record);
222
+ }
223
+
194
224
  // ─── Stdin driver ───────────────────────────────────────────────────────────
195
225
 
196
226
  if (require.main === module) {
@@ -98,6 +98,14 @@ function extractNumber(obj, key) {
98
98
  return typeof v === 'number' ? v : 0;
99
99
  }
100
100
 
101
+ // Drop implausibly large per-call token counts (a cumulative counter that leaked
102
+ // through the no-transcript fallback) to 0 rather than record them. Mirrors
103
+ // pan-cost-logger's guard.
104
+ const PLAUSIBLE_MAX = 20000000;
105
+ function clampPlausible(n) {
106
+ return typeof n === 'number' && n >= 0 && n <= PLAUSIBLE_MAX ? n : 0;
107
+ }
108
+
101
109
  /**
102
110
  * P-1805 (v3.7.8): extract usage totals by reading the SubagentStop transcript.
103
111
  * The hook payload from Claude Code in headless mode does NOT include
@@ -183,13 +191,15 @@ function buildTraceEvents(data, sessionId, cwd) {
183
191
  const ts = new Date().toISOString();
184
192
  const agent = data.agent_type || data.subagent_type || 'unknown';
185
193
 
186
- // P-1805: prefer usage from data.usage when present (interactive Claude Code path).
187
- // Fall back to reading the transcript file (headless `claude -p` path — usage
188
- // not in payload but discoverable via transcript_path).
189
- let inputTokens = extractNumber(data.usage, 'input_tokens');
190
- let outputTokens = extractNumber(data.usage, 'output_tokens');
191
- let cacheRead = extractNumber(data.usage, 'cache_read_input_tokens');
192
- if ((inputTokens + outputTokens + cacheRead) === 0 && data.transcript_path) {
194
+ // Per-call tokens come from the transcript SLICE. The SubagentStop `data.usage`,
195
+ // when present, is a CUMULATIVE session counter — not this subagent's delta — so
196
+ // logging it verbatim produced impossible per-row magnitudes (see pan-cost-logger
197
+ // for the full rationale). The slice is authoritative; data.usage is only a
198
+ // plausibility-guarded fallback when no transcript is available.
199
+ let inputTokens = 0;
200
+ let outputTokens = 0;
201
+ let cacheRead = 0;
202
+ if (data.transcript_path) {
193
203
  const cursor = readTraceCursor(cwd);
194
204
  const since = cursor[data.transcript_path] || 0;
195
205
  const fromTranscript = readUsageFromTranscript(data.transcript_path, data.session_id, since);
@@ -200,6 +210,10 @@ function buildTraceEvents(data, sessionId, cwd) {
200
210
  cursor[data.transcript_path] = fromTranscript.lineCount;
201
211
  writeTraceCursor(cwd, cursor);
202
212
  }
213
+ } else {
214
+ inputTokens = clampPlausible(extractNumber(data.usage, 'input_tokens'));
215
+ outputTokens = clampPlausible(extractNumber(data.usage, 'output_tokens'));
216
+ cacheRead = clampPlausible(extractNumber(data.usage, 'cache_read_input_tokens'));
203
217
  }
204
218
  const totalTokens = inputTokens + outputTokens;
205
219
 
@@ -261,14 +275,39 @@ function appendTraceEvents(cwd, events, sessionId) {
261
275
  try {
262
276
  const sessionDir = path.join(getTracesDir(cwd), sessionId);
263
277
  fs.mkdirSync(sessionDir, { recursive: true });
278
+ const file = path.join(sessionDir, TRACE_EVENT_FILE);
279
+ // Idempotency guard: a re-fired SubagentStop must not double-log. If this
280
+ // batch's completion event duplicates the last agent_completion already in
281
+ // the file (every field but ts), skip the whole batch — the source of the
282
+ // ~57% duplicate completion rows in the field (2026-07).
283
+ const completion = events.find(e => e && e.category === 'agent_completion');
284
+ if (completion && isDuplicateCompletion(file, completion)) return false;
264
285
  const lines = events.map(e => JSON.stringify(e)).join('\n') + '\n';
265
- fs.appendFileSync(path.join(sessionDir, TRACE_EVENT_FILE), lines, 'utf-8');
286
+ fs.appendFileSync(file, lines, 'utf-8');
266
287
  return true;
267
288
  } catch {
268
289
  return false;
269
290
  }
270
291
  }
271
292
 
293
+ /** True when `completion` matches the file's last agent_completion row, ignoring ts. */
294
+ function isDuplicateCompletion(file, completion) {
295
+ let last;
296
+ try {
297
+ const raw = fs.readFileSync(file, 'utf-8');
298
+ for (const line of raw.split('\n')) {
299
+ if (!line) continue;
300
+ let e; try { e = JSON.parse(line); } catch { continue; }
301
+ if (e && e.category === 'agent_completion') last = e;
302
+ }
303
+ } catch {
304
+ return false;
305
+ }
306
+ if (!last) return false;
307
+ const strip = (e) => { const { ts, ...rest } = e; return JSON.stringify(rest); };
308
+ return strip(last) === strip(completion);
309
+ }
310
+
272
311
  // ─── Stdin driver ────────────────────────────────────────────────────────────
273
312
 
274
313
  if (require.main === module) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.19.0",
3
+ "version": "3.20.0",
4
4
  "description": "Command a bot army for your codebase: an Opus Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -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
+ };
@@ -848,6 +848,9 @@ function focusAutoCheckpointCommit(cwd, cycle, run) {
848
848
  // is a planning-doc committer, so honor it. commit_docs=false → hands the .planning
849
849
  // commit (and any report regeneration) back to the user.
850
850
  if (config.commit_docs === false) return null;
851
+ // Reconcile the always-loaded project memory before staging so the committed
852
+ // .planning/ snapshot carries the trimmed state.md (no-op when already lean).
853
+ try { require('./memory-optimize.cjs').maybeAutoOptimizeMemory(cwd); } catch { /* never block the checkpoint */ }
851
854
  // Enabled projects: refresh the HTML reports before staging so the committed
852
855
  // .planning/ snapshot reflects this cycle.
853
856
  maybeRenderPhaseReports(cwd);
@@ -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
+ };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PAN memory rebuild (A2) — regenerate DERIVED memory from source, idempotently.
5
+ *
6
+ * "Rebuild" is a projection, not a mutation: it re-emits only the regions PAN
7
+ * owns and can reproduce from source, and leaves everything the user wrote
8
+ * alone. Per the memory-management research, a rebuild must be an idempotent
9
+ * projection touching only derived regions — running it twice changes nothing.
10
+ *
11
+ * Three derived targets:
12
+ * 1. AGENTS.md — the universal, cross-runtime tools memory. PAN owns exactly
13
+ * the marker-fenced `<!-- BEGIN/END PAN WIZARD -->` section (every runtime,
14
+ * including Copilot/.github, reads AGENTS.md natively). User content
15
+ * outside the markers is preserved byte-for-byte.
16
+ * 2. CLAUDE.md — the Claude bridge (`@AGENTS.md` import), regenerated only
17
+ * when the Claude runtime is installed here.
18
+ * 3. state.md — its YAML frontmatter is re-derived from the body (phase
19
+ * progress, status). The body prose is never touched.
20
+ *
21
+ * Dry-run by default (`--apply` to write). Refuses to run inside the PAN source
22
+ * repository, mirroring the installer and experiment guards.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { output, safeReadFile } = require('./core.cjs');
28
+ const { planningPath } = require('./utils.cjs');
29
+ const { syncStateFrontmatter } = require('./state.cjs');
30
+ const {
31
+ buildAgentsMdSection,
32
+ upsertAgentsMdSection,
33
+ ensureClaudeMdImport,
34
+ } = require('./agents-md.cjs');
35
+
36
+ // Source repo root — mirrors experiment.cjs / install.js. __dirname is
37
+ // .../pan-wizard-core/bin/lib, so three levels up is the repo (or install) root.
38
+ const PAN_SOURCE_ROOT = path.resolve(__dirname, '..', '..', '..');
39
+
40
+ // Runtime → config directory. A runtime is "installed here" when its dir exists
41
+ // in the project. AGENTS.md is shared by all; only Claude gets a bridge file.
42
+ const RUNTIME_DIRS = {
43
+ claude: '.claude',
44
+ codex: '.codex',
45
+ gemini: '.gemini',
46
+ opencode: '.opencode',
47
+ copilot: '.github',
48
+ };
49
+
50
+ function normPath(p) {
51
+ return process.platform === 'win32' ? p.toLowerCase() : p;
52
+ }
53
+
54
+ /**
55
+ * True only when `cwd` is the genuine PAN source repository — inside
56
+ * PAN_SOURCE_ROOT *and* that root actually looks like the source checkout
57
+ * (has bin/install.js). In an install layout PAN_SOURCE_ROOT resolves to the
58
+ * runtime config dir, which has no bin/install.js, so this stays false.
59
+ */
60
+ function isInsideSourceRepo(cwd) {
61
+ const abs = normPath(path.resolve(cwd));
62
+ const src = normPath(PAN_SOURCE_ROOT);
63
+ const inside = abs === src || abs.startsWith(src + path.sep) || abs.startsWith(src + '/');
64
+ if (!inside) return false;
65
+ return fs.existsSync(path.join(PAN_SOURCE_ROOT, 'bin', 'install.js')) &&
66
+ fs.existsSync(path.join(PAN_SOURCE_ROOT, 'pan-wizard-core'));
67
+ }
68
+
69
+ /** Which PAN runtimes are installed in this project (by config-dir presence). */
70
+ function detectRuntimes(cwd) {
71
+ return Object.entries(RUNTIME_DIRS)
72
+ .filter(([, dir]) => {
73
+ try { return fs.statSync(path.join(cwd, dir)).isDirectory(); }
74
+ catch { return false; }
75
+ })
76
+ .map(([name]) => name);
77
+ }
78
+
79
+ /**
80
+ * Rebuild one file: compute the desired content from `existing`, compare, and
81
+ * (on apply) write only when it differs. Returns a per-target status.
82
+ */
83
+ function rebuildFile(filePath, existing, desired, apply) {
84
+ const absent = existing == null;
85
+ if (existing === desired) return { action: 'unchanged', wrote: false };
86
+ const action = absent ? 'create' : 'update';
87
+ if (apply) fs.writeFileSync(filePath, desired, 'utf-8');
88
+ return { action, wrote: apply };
89
+ }
90
+
91
+ /**
92
+ * `memory rebuild [--apply]` — regenerate derived tools-memory + state.md
93
+ * frontmatter. Dry-run by default: reports what WOULD change.
94
+ */
95
+ function cmdMemoryRebuild(cwd, opts = {}, raw) {
96
+ const apply = !!opts.apply;
97
+
98
+ if (isInsideSourceRepo(cwd)) {
99
+ output(
100
+ { error: 'source_repo', rebuilt: [] },
101
+ raw,
102
+ `refusing to rebuild memory inside the PAN source repository (${PAN_SOURCE_ROOT})`,
103
+ );
104
+ return;
105
+ }
106
+
107
+ const runtimes = detectRuntimes(cwd);
108
+ const targets = [];
109
+
110
+ // 1. AGENTS.md — universal PAN section (all runtimes read it natively).
111
+ {
112
+ const p = path.join(cwd, 'AGENTS.md');
113
+ const existing = safeReadFile(p);
114
+ const desired = upsertAgentsMdSection(existing, buildAgentsMdSection());
115
+ targets.push({ file: 'AGENTS.md', ...rebuildFile(p, existing, desired, apply) });
116
+ }
117
+
118
+ // 2. CLAUDE.md — Claude bridge, only when the Claude runtime is installed.
119
+ if (runtimes.includes('claude')) {
120
+ const p = path.join(cwd, 'CLAUDE.md');
121
+ const existing = safeReadFile(p);
122
+ const desired = ensureClaudeMdImport(existing);
123
+ targets.push({ file: 'CLAUDE.md', ...rebuildFile(p, existing, desired, apply) });
124
+ }
125
+
126
+ // 3. state.md — re-derive YAML frontmatter from the body (progress/status).
127
+ {
128
+ const p = path.join(planningPath(cwd), 'state.md');
129
+ const existing = safeReadFile(p);
130
+ if (existing != null) {
131
+ const desired = syncStateFrontmatter(existing, cwd);
132
+ targets.push({ file: '.planning/state.md', ...rebuildFile(p, existing, desired, apply) });
133
+ }
134
+ }
135
+
136
+ const changed = targets.filter((t) => t.action !== 'unchanged');
137
+ const result = {
138
+ apply,
139
+ runtimes,
140
+ rebuilt: targets,
141
+ changed_count: changed.length,
142
+ };
143
+ const summary = changed.length === 0
144
+ ? `tools memory already current (${targets.map((t) => t.file).join(', ')}) — nothing to do`
145
+ : `${apply ? 'rebuilt' : 'would rebuild'} ${changed.map((t) => `${t.file} (${t.action})`).join(', ')}`;
146
+ output(result, raw, summary);
147
+ }
148
+
149
+ module.exports = {
150
+ cmdMemoryRebuild,
151
+ detectRuntimes,
152
+ isInsideSourceRepo,
153
+ rebuildFile,
154
+ PAN_SOURCE_ROOT,
155
+ RUNTIME_DIRS,
156
+ };
@@ -505,6 +505,9 @@ function cmdStateRecordSession(cwd, options, raw) {
505
505
 
506
506
  if (updated.length > 0) {
507
507
  writeStateMd(statePath, content, cwd);
508
+ // Normal-flow checkpoint: reconcile the always-loaded project memory once
509
+ // the session is recorded (no-op when already lean; never throws).
510
+ try { require('./memory-optimize.cjs').maybeAutoOptimizeMemory(cwd); } catch { /* best-effort */ }
508
511
  output({ recorded: true, updated }, raw, 'true');
509
512
  } else {
510
513
  output({ recorded: false, reason: 'No session fields found in state.md' }, raw, 'false');
@@ -1021,6 +1024,7 @@ module.exports = {
1021
1024
  stateExtractField,
1022
1025
  stateReplaceField,
1023
1026
  writeStateMd,
1027
+ syncStateFrontmatter,
1024
1028
  cmdStateLoad,
1025
1029
  cmdStateGet,
1026
1030
  cmdStatePatch,
@@ -951,8 +951,18 @@ async function main() {
951
951
  }, raw);
952
952
  } else if (subcommand === 'budget') {
953
953
  memory.cmdMemoryBudget(cwd, raw);
954
+ } else if (subcommand === 'optimize') {
955
+ const keepArg = getArgValue(args, '--keep');
956
+ require('./lib/memory-optimize.cjs').cmdMemoryOptimize(cwd, {
957
+ apply: args.includes('--apply'),
958
+ keep: keepArg ? Number(keepArg) : undefined,
959
+ }, raw);
960
+ } else if (subcommand === 'rebuild') {
961
+ require('./lib/memory-rebuild.cjs').cmdMemoryRebuild(cwd, {
962
+ apply: args.includes('--apply'),
963
+ }, raw);
954
964
  } else {
955
- error('Unknown memory subcommand. Available: read, append, list, compact, select, budget');
965
+ error('Unknown memory subcommand. Available: read, append, list, compact, select, budget, optimize, rebuild');
956
966
  }
957
967
  break;
958
968
  }