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.
- package/bin/install-lib.cjs +14 -98
- package/hooks/dist/pan-cost-logger.js +57 -27
- package/hooks/dist/pan-trace-logger.js +47 -8
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +119 -0
- package/pan-wizard-core/bin/lib/focus.cjs +3 -0
- 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/state.cjs +4 -0
- package/pan-wizard-core/bin/pan-tools.cjs +11 -1
package/bin/install-lib.cjs
CHANGED
|
@@ -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
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
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
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
89
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
let
|
|
192
|
-
|
|
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(
|
|
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.
|
|
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
|
}
|