pan-wizard 2.9.1 → 3.4.1

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.
Files changed (58) hide show
  1. package/README.md +8 -8
  2. package/agents/pan-conductor.md +189 -0
  3. package/agents/pan-counterfactual.md +112 -0
  4. package/agents/pan-debugger.md +15 -1
  5. package/agents/pan-document_code.md +21 -0
  6. package/agents/pan-executor.md +16 -0
  7. package/agents/pan-hardener.md +113 -0
  8. package/agents/pan-integration-checker.md +2 -0
  9. package/agents/pan-knowledge.md +81 -0
  10. package/agents/pan-meta-reviewer.md +91 -0
  11. package/agents/pan-plan-checker.md +2 -0
  12. package/agents/pan-previewer.md +98 -0
  13. package/agents/pan-project-researcher.md +4 -4
  14. package/agents/pan-reviewer.md +2 -0
  15. package/agents/pan-verifier.md +2 -0
  16. package/bin/install-lib.cjs +197 -0
  17. package/bin/install.js +1999 -1959
  18. package/commands/pan/cost.md +132 -0
  19. package/commands/pan/exec-phase.md +15 -0
  20. package/commands/pan/focus-auto.md +18 -0
  21. package/commands/pan/focus-exec.md +10 -1
  22. package/commands/pan/knowledge.md +129 -0
  23. package/commands/pan/map-codebase.md +15 -0
  24. package/commands/pan/mcp-bridge.md +145 -0
  25. package/commands/pan/plan-phase.md +11 -0
  26. package/commands/pan/preview.md +114 -0
  27. package/commands/pan/profile.md +37 -0
  28. package/commands/pan/review-deep.md +128 -0
  29. package/commands/pan/verify-phase.md +11 -0
  30. package/commands/pan/what-if.md +146 -0
  31. package/hooks/dist/pan-cost-logger.js +102 -0
  32. package/hooks/dist/pan-statusline.js +154 -108
  33. package/package.json +1 -1
  34. package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
  35. package/pan-wizard-core/bin/lib/bus.cjs +251 -0
  36. package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
  37. package/pan-wizard-core/bin/lib/constants.cjs +39 -0
  38. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
  39. package/pan-wizard-core/bin/lib/core.cjs +91 -6
  40. package/pan-wizard-core/bin/lib/cost.cjs +359 -0
  41. package/pan-wizard-core/bin/lib/focus.cjs +100 -2
  42. package/pan-wizard-core/bin/lib/init.cjs +5 -5
  43. package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
  44. package/pan-wizard-core/bin/lib/memory.cjs +252 -0
  45. package/pan-wizard-core/bin/lib/phase.cjs +40 -13
  46. package/pan-wizard-core/bin/lib/preview.cjs +480 -0
  47. package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
  48. package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
  49. package/pan-wizard-core/bin/lib/state.cjs +2 -2
  50. package/pan-wizard-core/bin/lib/verify.cjs +34 -1
  51. package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
  52. package/pan-wizard-core/bin/pan-tools.cjs +239 -4
  53. package/pan-wizard-core/templates/playbook.md +53 -0
  54. package/pan-wizard-core/templates/preview-report.md +93 -0
  55. package/pan-wizard-core/templates/roadmap.md +24 -24
  56. package/pan-wizard-core/templates/state.md +12 -9
  57. package/pan-wizard-core/workflows/plan-phase.md +1 -1
  58. package/scripts/build-hooks.js +2 -1
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Knowledge — grounded Q&A, multi-turn discussion, playbook generation
3
+ * (Spec B v2 Y-3, v3.2).
4
+ *
5
+ * Three modes, one module:
6
+ * - ask — retrieve candidate files + format citation context for the agent
7
+ * - discuss — session state CRUD for multi-turn conversations on a phase
8
+ * - playbook — read all agents' memory and cluster into sections
9
+ *
10
+ * All three leverage Spec A infrastructure:
11
+ * - E-1 caching for stable input prefixes (ask, discuss)
12
+ * - E-4 memory for playbook source + discuss state
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { output, error, safeReadFile, toPosix } = require('./core.cjs');
18
+ const { PLANNING_DIR } = require('./constants.cjs');
19
+ const { planningPath } = require('./utils.cjs');
20
+ const { listMemoryAgents, readMemory } = require('./memory.cjs');
21
+
22
+ const CONVERSATIONS_DIR = 'conversations';
23
+ const PLAYBOOK_FILE = 'playbook.md';
24
+ const DEFAULT_MAX_SOURCES = 20;
25
+
26
+ // ─── Mode: ask — retrieval for grounded Q&A ────────────────────────────────
27
+
28
+ /**
29
+ * Candidate source directories that pan-knowledge agent can cite.
30
+ */
31
+ const CITATION_ROOTS = [
32
+ '.planning/project.md',
33
+ '.planning/requirements.md',
34
+ '.planning/roadmap.md',
35
+ '.planning/state.md',
36
+ '.planning/standards.md',
37
+ '.planning/patterns.md',
38
+ '.planning/phases',
39
+ '.planning/milestones',
40
+ '.planning/memory',
41
+ 'docs',
42
+ 'CHANGELOG.md',
43
+ 'README.md',
44
+ 'CLAUDE.md',
45
+ ];
46
+
47
+ /**
48
+ * Score a candidate file by naive keyword matching against the question.
49
+ * Not a vector index — just a frequency-based ranker so the agent reads
50
+ * the most relevant files first.
51
+ */
52
+ function scoreRelevance(question, content) {
53
+ if (!content) return 0;
54
+ const words = question.toLowerCase().split(/\W+/).filter(w => w.length >= 3);
55
+ if (words.length === 0) return 0;
56
+ const body = content.toLowerCase();
57
+ let score = 0;
58
+ for (const w of words) {
59
+ const count = (body.match(new RegExp(`\\b${w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'g')) || []).length;
60
+ score += count;
61
+ }
62
+ return score;
63
+ }
64
+
65
+ /**
66
+ * Walk a path (file or directory, 1 level deep for .md files) and return
67
+ * {file, score} entries.
68
+ */
69
+ function gatherCandidates(cwd, question) {
70
+ const candidates = [];
71
+ for (const rel of CITATION_ROOTS) {
72
+ const abs = path.join(cwd, rel);
73
+ let stat;
74
+ try { stat = fs.statSync(abs); } catch { continue; }
75
+ if (stat.isFile()) {
76
+ const content = safeReadFile(abs);
77
+ candidates.push({ file: toPosix(rel), score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
78
+ } else if (stat.isDirectory()) {
79
+ let entries = [];
80
+ try { entries = fs.readdirSync(abs); } catch { continue; }
81
+ for (const entry of entries) {
82
+ const entryAbs = path.join(abs, entry);
83
+ let entryStat;
84
+ try { entryStat = fs.statSync(entryAbs); } catch { continue; }
85
+ if (entryStat.isFile() && entry.endsWith('.md')) {
86
+ const entryRel = toPosix(path.join(rel, entry));
87
+ const content = safeReadFile(entryAbs);
88
+ candidates.push({ file: entryRel, score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
89
+ } else if (entryStat.isDirectory()) {
90
+ // One more level for phases/<NN>/ and milestones/
91
+ let sub = [];
92
+ try { sub = fs.readdirSync(entryAbs); } catch { continue; }
93
+ for (const s of sub) {
94
+ if (!s.endsWith('.md')) continue;
95
+ const subRel = toPosix(path.join(rel, entry, s));
96
+ const content = safeReadFile(path.join(entryAbs, s));
97
+ candidates.push({ file: subRel, score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ return candidates;
104
+ }
105
+
106
+ /**
107
+ * Retrieve ranked candidate sources for a question.
108
+ *
109
+ * @param {string} cwd - Project root
110
+ * @param {string} question - User's natural-language question
111
+ * @param {Object} [opts] - {max_sources}
112
+ * @returns {Object} {question, sources: Array<{file, score, bytes}>, total_candidates}
113
+ */
114
+ function ask(cwd, question, opts) {
115
+ if (typeof question !== 'string' || !question.trim()) {
116
+ return { error: 'question must be a non-empty string' };
117
+ }
118
+ const max = Math.max(1, Math.min(100, Number(opts?.max_sources) || DEFAULT_MAX_SOURCES));
119
+ const all = gatherCandidates(cwd, question);
120
+ const ranked = all
121
+ .filter(c => c.score > 0 || c.file.endsWith('project.md') || c.file.endsWith('requirements.md'))
122
+ .sort((a, b) => b.score - a.score || a.file.localeCompare(b.file))
123
+ .slice(0, max);
124
+ return {
125
+ question,
126
+ sources: ranked,
127
+ total_candidates: all.length,
128
+ returned: ranked.length,
129
+ };
130
+ }
131
+
132
+ // ─── Mode: discuss — session state for multi-turn conversations ────────────
133
+
134
+ function conversationsDir(cwd) {
135
+ return path.join(planningPath(cwd), CONVERSATIONS_DIR);
136
+ }
137
+
138
+ function sessionFile(cwd, phaseNum) {
139
+ return path.join(conversationsDir(cwd), String(phaseNum), 'session.json');
140
+ }
141
+
142
+ /**
143
+ * Read or initialize a discussion session for a phase.
144
+ */
145
+ function loadSession(cwd, phaseNum) {
146
+ const file = sessionFile(cwd, phaseNum);
147
+ try {
148
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
149
+ } catch {
150
+ return { phase: String(phaseNum), turns: [], created: new Date().toISOString() };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Append a user question + agent response turn.
156
+ *
157
+ * @param {string} cwd
158
+ * @param {string} phaseNum
159
+ * @param {{role: 'user'|'agent', content: string, cites?: string[]}} turn
160
+ * @returns {{appended: true, turn_count: number, file: string}|{error: string}}
161
+ */
162
+ function appendTurn(cwd, phaseNum, turn) {
163
+ if (!phaseNum) return { error: 'phaseNum required' };
164
+ if (!turn || !turn.role || !turn.content) return { error: 'turn requires role + content' };
165
+ if (turn.role !== 'user' && turn.role !== 'agent') {
166
+ return { error: 'turn.role must be "user" or "agent"' };
167
+ }
168
+
169
+ const session = loadSession(cwd, phaseNum);
170
+ session.turns.push({
171
+ ts: new Date().toISOString(),
172
+ role: turn.role,
173
+ content: turn.content,
174
+ cites: Array.isArray(turn.cites) ? turn.cites : [],
175
+ });
176
+ session.last_updated = session.turns[session.turns.length - 1].ts;
177
+
178
+ const file = sessionFile(cwd, phaseNum);
179
+ try {
180
+ fs.mkdirSync(path.dirname(file), { recursive: true });
181
+ fs.writeFileSync(file, JSON.stringify(session, null, 2), 'utf-8');
182
+ } catch (e) {
183
+ return { error: `Failed to write session: ${e.message}` };
184
+ }
185
+
186
+ return { appended: true, turn_count: session.turns.length, file: toPosix(path.relative(cwd, file)) };
187
+ }
188
+
189
+ // ─── Mode: playbook — cluster agent memory into sections ───────────────────
190
+
191
+ /**
192
+ * Category heuristics. Maps keyword patterns to playbook sections.
193
+ * Order matters — first match wins.
194
+ */
195
+ const PLAYBOOK_CATEGORIES = [
196
+ { name: 'Conventions', pattern: /\b(convention|style|format|naming|prefer)\b/i },
197
+ { name: 'Gotchas', pattern: /\b(gotcha|pitfall|edge\s*case|surprise|careful)\b/i },
198
+ { name: 'Decisions', pattern: /\b(decision|decided|chose|picked|adopt)\b/i },
199
+ { name: 'Tool choices', pattern: /\b(library|package|framework|tool|alternative)\b/i },
200
+ { name: 'Anti-patterns', pattern: /\b(anti.?pattern|avoid|do not|don't|never)\b/i },
201
+ { name: 'Recurring gaps', pattern: /\brecurring\s+(gap|plan\s+gap|issue)\b/i },
202
+ ];
203
+
204
+ function categorizeEntry(entry) {
205
+ for (const cat of PLAYBOOK_CATEGORIES) {
206
+ if (cat.pattern.test(entry)) return cat.name;
207
+ }
208
+ return 'General';
209
+ }
210
+
211
+ /**
212
+ * Generate a structured playbook from all agents' memory files.
213
+ *
214
+ * @param {string} cwd - Project root
215
+ * @returns {Object} {sections: {name: [{agent, entry}]}, agent_count, entry_count}
216
+ */
217
+ function buildPlaybook(cwd) {
218
+ const { agents } = listMemoryAgents(cwd);
219
+ const sections = {};
220
+ let entryCount = 0;
221
+
222
+ for (const a of agents) {
223
+ const mem = readMemory(cwd, a.agent);
224
+ if (!mem) continue;
225
+ for (const entry of mem.entries) {
226
+ const cat = categorizeEntry(entry);
227
+ if (!sections[cat]) sections[cat] = [];
228
+ sections[cat].push({ agent: a.agent, entry });
229
+ entryCount += 1;
230
+ }
231
+ }
232
+
233
+ return {
234
+ sections,
235
+ agent_count: agents.length,
236
+ entry_count: entryCount,
237
+ generated: new Date().toISOString(),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Serialize the playbook object to markdown and write to `.planning/playbook.md`.
243
+ */
244
+ function writePlaybook(cwd, playbook) {
245
+ const lines = [];
246
+ lines.push('---');
247
+ lines.push('type: playbook');
248
+ lines.push(`generated: ${playbook.generated}`);
249
+ lines.push(`source_agents: ${playbook.agent_count}`);
250
+ lines.push(`entries: ${playbook.entry_count}`);
251
+ lines.push('---');
252
+ lines.push('');
253
+ lines.push('# PAN Playbook');
254
+ lines.push('');
255
+ lines.push(`Accumulated lessons across ${playbook.agent_count} agents and ${playbook.entry_count} memory entries. Regenerated from \`.planning/memory/*.md\`.`);
256
+ lines.push('');
257
+
258
+ const sectionOrder = [
259
+ ...PLAYBOOK_CATEGORIES.map(c => c.name),
260
+ 'General',
261
+ ];
262
+
263
+ for (const section of sectionOrder) {
264
+ const items = playbook.sections[section];
265
+ if (!items || items.length === 0) continue;
266
+ lines.push(`## ${section}`);
267
+ lines.push('');
268
+ for (const item of items) {
269
+ lines.push(`- ${item.entry} _— from \`${item.agent}\`_`);
270
+ }
271
+ lines.push('');
272
+ }
273
+
274
+ const file = path.join(planningPath(cwd), PLAYBOOK_FILE);
275
+ try {
276
+ fs.writeFileSync(file, lines.join('\n'), 'utf-8');
277
+ } catch (e) {
278
+ return { error: `Failed to write playbook: ${e.message}` };
279
+ }
280
+ return { written: true, file: toPosix(path.relative(cwd, file)) };
281
+ }
282
+
283
+ // ─── CLI wrappers ───────────────────────────────────────────────────────────
284
+
285
+ function cmdKnowledgeAsk(cwd, question, opts, raw) {
286
+ if (!question) error('Usage: knowledge ask <question>');
287
+ output(ask(cwd, question, opts), raw);
288
+ }
289
+
290
+ function cmdKnowledgeDiscuss(cwd, phaseNum, opts, raw) {
291
+ const subcmd = opts?.subcmd;
292
+ if (subcmd === 'read') {
293
+ output(loadSession(cwd, phaseNum), raw);
294
+ } else if (subcmd === 'append') {
295
+ output(appendTurn(cwd, phaseNum, {
296
+ role: opts.role,
297
+ content: opts.content,
298
+ cites: opts.cites ? opts.cites.split(',') : [],
299
+ }), raw);
300
+ } else {
301
+ error('Usage: knowledge discuss <phase> --subcmd read|append [--role user|agent --content "..."]');
302
+ }
303
+ }
304
+
305
+ function cmdKnowledgePlaybook(cwd, opts, raw) {
306
+ const playbook = buildPlaybook(cwd);
307
+ if (opts?.preview) {
308
+ output(playbook, raw);
309
+ return;
310
+ }
311
+ const result = writePlaybook(cwd, playbook);
312
+ if (result.error) { output(result, raw); return; }
313
+ output({ ...result, agent_count: playbook.agent_count, entry_count: playbook.entry_count }, raw);
314
+ }
315
+
316
+ module.exports = {
317
+ ask,
318
+ loadSession,
319
+ appendTurn,
320
+ buildPlaybook,
321
+ writePlaybook,
322
+ scoreRelevance,
323
+ categorizeEntry,
324
+ cmdKnowledgeAsk,
325
+ cmdKnowledgeDiscuss,
326
+ cmdKnowledgePlaybook,
327
+ CITATION_ROOTS,
328
+ CONVERSATIONS_DIR,
329
+ PLAYBOOK_FILE,
330
+ PLAYBOOK_CATEGORIES,
331
+ };
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Memory — cross-phase agent memory layer
3
+ *
4
+ * Each agent has an append-only memory log at `.planning/memory/<agent>.md`.
5
+ * Agents read their memory at start of each invocation and append lessons
6
+ * learned at end. Compaction keeps file size bounded.
7
+ *
8
+ * File format: a markdown file with a stable YAML frontmatter header and
9
+ * an append-only "## Entries" section containing one bullet per entry:
10
+ *
11
+ * ---
12
+ * agent: pan-planner
13
+ * created: 2026-04-18
14
+ * ---
15
+ *
16
+ * ## Entries
17
+ *
18
+ * - 2026-04-18: Prefer bulk writes over per-row commits for Postgres
19
+ * - 2026-04-19: ...
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { output, error } = require('./core.cjs');
25
+ const { PLANNING_DIR } = require('./constants.cjs');
26
+ const { planningPath } = require('./utils.cjs');
27
+
28
+ const MEMORY_DIR = 'memory';
29
+ const DEFAULT_MAX_ENTRIES = 500;
30
+ const AGENT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
31
+
32
+ function memoryDir(cwd) {
33
+ return path.join(planningPath(cwd), MEMORY_DIR);
34
+ }
35
+
36
+ function memoryFile(cwd, agent) {
37
+ return path.join(memoryDir(cwd), `${agent}.md`);
38
+ }
39
+
40
+ function validateAgentName(agent) {
41
+ if (typeof agent !== 'string' || !AGENT_NAME_RE.test(agent)) {
42
+ return `Invalid agent name: ${agent}. Must match ${AGENT_NAME_RE}`;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function today() {
48
+ return new Date().toISOString().slice(0, 10);
49
+ }
50
+
51
+ /**
52
+ * Read the memory file for an agent.
53
+ * @param {string} cwd - Project root
54
+ * @param {string} agent - Agent name
55
+ * @returns {{agent: string, entries: string[], raw: string}|null}
56
+ */
57
+ function readMemory(cwd, agent) {
58
+ const err = validateAgentName(agent);
59
+ if (err) return null;
60
+ let raw;
61
+ try {
62
+ raw = fs.readFileSync(memoryFile(cwd, agent), 'utf-8');
63
+ } catch {
64
+ return null;
65
+ }
66
+ const entries = parseEntries(raw);
67
+ return { agent, entries, raw };
68
+ }
69
+
70
+ /**
71
+ * Parse bullet entries from a memory file's body.
72
+ * @param {string} raw - File contents
73
+ * @returns {string[]} ordered entries (oldest → newest, as stored)
74
+ */
75
+ function parseEntries(raw) {
76
+ const entries = [];
77
+ const lines = raw.split(/\r?\n/);
78
+ let inEntries = false;
79
+ for (const line of lines) {
80
+ if (/^##\s+Entries\s*$/.test(line)) { inEntries = true; continue; }
81
+ if (inEntries && /^##\s+/.test(line)) break;
82
+ if (!inEntries) continue;
83
+ const match = line.match(/^-\s+(.+)$/);
84
+ if (match) entries.push(match[1]);
85
+ }
86
+ return entries;
87
+ }
88
+
89
+ /**
90
+ * Append a single entry to an agent's memory log. Creates file+dir if absent.
91
+ * Entries are prefixed with today's date automatically unless already prefixed.
92
+ * @param {string} cwd - Project root
93
+ * @param {string} agent - Agent name
94
+ * @param {string} entry - Single-line lesson (newlines will be collapsed)
95
+ * @returns {{appended: true, file: string, count: number}|{error: string}}
96
+ */
97
+ function appendMemory(cwd, agent, entry) {
98
+ const err = validateAgentName(agent);
99
+ if (err) return { error: err };
100
+ if (typeof entry !== 'string' || !entry.trim()) {
101
+ return { error: 'entry must be a non-empty string' };
102
+ }
103
+
104
+ const cleaned = entry.replace(/\r?\n/g, ' ').trim();
105
+ const datePrefixed = /^\d{4}-\d{2}-\d{2}:/.test(cleaned);
106
+ const finalEntry = datePrefixed ? cleaned : `${today()}: ${cleaned}`;
107
+
108
+ try {
109
+ fs.mkdirSync(memoryDir(cwd), { recursive: true });
110
+ } catch (e) {
111
+ return { error: `Failed to create memory dir: ${e.message}` };
112
+ }
113
+
114
+ const file = memoryFile(cwd, agent);
115
+ let existing = '';
116
+ try {
117
+ existing = fs.readFileSync(file, 'utf-8');
118
+ } catch {
119
+ // new file
120
+ }
121
+
122
+ let contents;
123
+ if (!existing) {
124
+ contents = buildHeader(agent) + '\n\n## Entries\n\n- ' + finalEntry + '\n';
125
+ } else if (/##\s+Entries/.test(existing)) {
126
+ // Ensure file ends with newline, then append bullet.
127
+ const needsNl = !existing.endsWith('\n');
128
+ contents = existing + (needsNl ? '\n' : '') + `- ${finalEntry}\n`;
129
+ } else {
130
+ const needsNl = !existing.endsWith('\n');
131
+ contents = existing + (needsNl ? '\n' : '') + '\n## Entries\n\n- ' + finalEntry + '\n';
132
+ }
133
+
134
+ try {
135
+ fs.writeFileSync(file, contents, 'utf-8');
136
+ } catch (e) {
137
+ return { error: `Failed to write memory file: ${e.message}` };
138
+ }
139
+
140
+ const count = parseEntries(contents).length;
141
+ return { appended: true, file, count };
142
+ }
143
+
144
+ function buildHeader(agent) {
145
+ return `---\nagent: ${agent}\ncreated: ${today()}\n---`;
146
+ }
147
+
148
+ /**
149
+ * Trim a memory file to the last N entries. Preserves frontmatter header.
150
+ * @param {string} cwd - Project root
151
+ * @param {string} agent - Agent name
152
+ * @param {number} maxEntries - Keep this many most-recent entries
153
+ * @returns {{compacted: true, kept: number, removed: number}|{error: string}}
154
+ */
155
+ function compactMemory(cwd, agent, maxEntries = DEFAULT_MAX_ENTRIES) {
156
+ const err = validateAgentName(agent);
157
+ if (err) return { error: err };
158
+ const max = Number(maxEntries);
159
+ if (!Number.isFinite(max) || max < 1) {
160
+ return { error: `maxEntries must be a positive integer, got ${maxEntries}` };
161
+ }
162
+
163
+ const file = memoryFile(cwd, agent);
164
+ let raw;
165
+ try {
166
+ raw = fs.readFileSync(file, 'utf-8');
167
+ } catch {
168
+ return { error: `No memory file for agent: ${agent}` };
169
+ }
170
+
171
+ const entries = parseEntries(raw);
172
+ if (entries.length <= max) {
173
+ return { compacted: true, kept: entries.length, removed: 0 };
174
+ }
175
+
176
+ const keep = entries.slice(-max);
177
+ const removed = entries.length - keep.length;
178
+
179
+ const headerMatch = raw.match(/^---[\s\S]*?---/);
180
+ const header = headerMatch ? headerMatch[0] : buildHeader(agent);
181
+ const body = '\n\n## Entries\n\n' + keep.map(e => `- ${e}`).join('\n') + '\n';
182
+ try {
183
+ fs.writeFileSync(file, header + body, 'utf-8');
184
+ } catch (e) {
185
+ return { error: `Failed to write memory file: ${e.message}` };
186
+ }
187
+ return { compacted: true, kept: keep.length, removed };
188
+ }
189
+
190
+ /**
191
+ * List all agents that have a memory file.
192
+ * @param {string} cwd - Project root
193
+ * @returns {{agents: Array<{agent: string, entries: number}>}}
194
+ */
195
+ function listMemoryAgents(cwd) {
196
+ let files;
197
+ try {
198
+ files = fs.readdirSync(memoryDir(cwd));
199
+ } catch {
200
+ return { agents: [] };
201
+ }
202
+ const agents = [];
203
+ for (const f of files) {
204
+ if (!f.endsWith('.md')) continue;
205
+ const name = f.slice(0, -3);
206
+ if (!AGENT_NAME_RE.test(name)) continue;
207
+ const mem = readMemory(cwd, name);
208
+ agents.push({ agent: name, entries: mem ? mem.entries.length : 0 });
209
+ }
210
+ agents.sort((a, b) => a.agent.localeCompare(b.agent));
211
+ return { agents };
212
+ }
213
+
214
+ // ─── CLI command wrappers ────────────────────────────────────────────────────
215
+
216
+ function cmdMemoryRead(cwd, agent, raw) {
217
+ if (!agent) { error('Usage: memory read <agent>'); }
218
+ const result = readMemory(cwd, agent);
219
+ if (!result) { output({ agent, entries: [], exists: false }, raw); return; }
220
+ output({ agent, entries: result.entries, exists: true }, raw);
221
+ }
222
+
223
+ function cmdMemoryAppend(cwd, agent, entry, raw) {
224
+ if (!agent || !entry) { error('Usage: memory append <agent> <entry>'); }
225
+ const result = appendMemory(cwd, agent, entry);
226
+ output(result, raw);
227
+ }
228
+
229
+ function cmdMemoryList(cwd, raw) {
230
+ output(listMemoryAgents(cwd), raw);
231
+ }
232
+
233
+ function cmdMemoryCompact(cwd, agent, maxEntries, raw) {
234
+ if (!agent) { error('Usage: memory compact <agent> [max]'); }
235
+ const result = compactMemory(cwd, agent, maxEntries || DEFAULT_MAX_ENTRIES);
236
+ output(result, raw);
237
+ }
238
+
239
+ module.exports = {
240
+ readMemory,
241
+ appendMemory,
242
+ compactMemory,
243
+ listMemoryAgents,
244
+ parseEntries,
245
+ validateAgentName,
246
+ cmdMemoryRead,
247
+ cmdMemoryAppend,
248
+ cmdMemoryList,
249
+ cmdMemoryCompact,
250
+ MEMORY_DIR,
251
+ DEFAULT_MAX_ENTRIES,
252
+ };