pan-wizard 3.32.0 → 3.34.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.
Files changed (209) hide show
  1. package/README.md +23 -20
  2. package/agents/pan-conductor.md +18 -17
  3. package/agents/pan-counterfactual.md +2 -2
  4. package/agents/pan-debugger.md +27 -8
  5. package/agents/pan-design-checker.md +1 -1
  6. package/agents/pan-designer.md +2 -2
  7. package/agents/pan-distiller.md +3 -3
  8. package/agents/pan-document_code.md +16 -16
  9. package/agents/pan-executor.md +15 -11
  10. package/agents/pan-experiment-runner.md +15 -17
  11. package/agents/pan-hardener.md +15 -15
  12. package/agents/pan-integration-checker.md +2 -2
  13. package/agents/pan-knowledge.md +2 -2
  14. package/agents/pan-meta-reviewer.md +5 -5
  15. package/agents/pan-optimizer.md +40 -44
  16. package/agents/pan-phase-researcher.md +10 -10
  17. package/agents/pan-plan-checker.md +36 -30
  18. package/agents/pan-planner.md +77 -53
  19. package/agents/pan-previewer.md +1 -1
  20. package/agents/pan-project-researcher.md +21 -78
  21. package/agents/pan-release.md +1 -1
  22. package/agents/pan-research-synthesizer.md +2 -2
  23. package/agents/pan-reviewer.md +24 -2
  24. package/agents/pan-roadmapper.md +20 -62
  25. package/agents/pan-verifier.md +53 -30
  26. package/bin/install-lib.cjs +228 -38
  27. package/bin/install.js +86 -15
  28. package/commands/pan/army.md +16 -12
  29. package/commands/pan/assumptions.md +2 -2
  30. package/commands/pan/audit-deployment.md +11 -12
  31. package/commands/pan/cost.md +8 -4
  32. package/commands/pan/debug.md +10 -7
  33. package/commands/pan/design-phase.md +3 -3
  34. package/commands/pan/discuss-phase.md +2 -0
  35. package/commands/pan/exec-phase.md +21 -37
  36. package/commands/pan/experiment.md +11 -10
  37. package/commands/pan/focus-auto.md +33 -27
  38. package/commands/pan/focus-design.md +9 -6
  39. package/commands/pan/focus-doc-audit.md +4 -3
  40. package/commands/pan/focus-drift-walking.md +4 -3
  41. package/commands/pan/focus-exec.md +7 -7
  42. package/commands/pan/focus-plan.md +12 -11
  43. package/commands/pan/focus-scan.md +7 -10
  44. package/commands/pan/focus-sync.md +3 -2
  45. package/commands/pan/git.md +8 -8
  46. package/commands/pan/health.md +2 -2
  47. package/commands/pan/hud.md +3 -2
  48. package/commands/pan/hygiene.md +14 -7
  49. package/commands/pan/knowledge.md +9 -9
  50. package/commands/pan/learn.md +10 -7
  51. package/commands/pan/links.md +4 -1
  52. package/commands/pan/map-codebase.md +5 -5
  53. package/commands/pan/mcp-bridge.md +18 -18
  54. package/commands/pan/milestone-done.md +7 -5
  55. package/commands/pan/milestone-new.md +1 -1
  56. package/commands/pan/new-project.md +10 -10
  57. package/commands/pan/optimize.md +16 -14
  58. package/commands/pan/patches.md +2 -6
  59. package/commands/pan/pause.md +1 -1
  60. package/commands/pan/phase-tests.md +4 -3
  61. package/commands/pan/plan-phase.md +9 -19
  62. package/commands/pan/preview.md +1 -1
  63. package/commands/pan/profile.md +4 -4
  64. package/commands/pan/quick.md +1 -1
  65. package/commands/pan/remove-phase.md +1 -1
  66. package/commands/pan/report.md +2 -2
  67. package/commands/pan/research-phase.md +14 -15
  68. package/commands/pan/resume.md +9 -11
  69. package/commands/pan/retro.md +5 -3
  70. package/commands/pan/review-deep.md +10 -10
  71. package/commands/pan/settings.md +1 -1
  72. package/commands/pan/todo-add.md +2 -2
  73. package/commands/pan/todo-check.md +2 -2
  74. package/commands/pan/verify-phase.md +0 -11
  75. package/commands/pan/what-if.md +2 -2
  76. package/hooks/dist/pan-context-monitor.js +343 -44
  77. package/hooks/dist/pan-cost-logger.js +30 -0
  78. package/hooks/dist/pan-state-reinject.js +140 -15
  79. package/hooks/dist/pan-statusline.js +68 -22
  80. package/hooks/dist/pan-stop-guard.js +7 -2
  81. package/package.json +1 -1
  82. package/pan-wizard-core/bin/lib/agents-md.cjs +48 -7
  83. package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
  84. package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
  85. package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
  86. package/pan-wizard-core/bin/lib/commands.cjs +126 -5
  87. package/pan-wizard-core/bin/lib/config.cjs +112 -1
  88. package/pan-wizard-core/bin/lib/constants.cjs +29 -15
  89. package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
  90. package/pan-wizard-core/bin/lib/core.cjs +77 -23
  91. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
  92. package/pan-wizard-core/bin/lib/cost.cjs +149 -10
  93. package/pan-wizard-core/bin/lib/distill.cjs +6 -3
  94. package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
  95. package/pan-wizard-core/bin/lib/findings.cjs +15 -4
  96. package/pan-wizard-core/bin/lib/focus.cjs +13 -4
  97. package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
  98. package/pan-wizard-core/bin/lib/hygiene.cjs +178 -17
  99. package/pan-wizard-core/bin/lib/init.cjs +12 -5
  100. package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
  101. package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
  102. package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
  103. package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
  104. package/pan-wizard-core/bin/lib/memory.cjs +508 -54
  105. package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
  106. package/pan-wizard-core/bin/lib/optimize.cjs +84 -16
  107. package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
  108. package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
  109. package/pan-wizard-core/bin/lib/phase.cjs +67 -15
  110. package/pan-wizard-core/bin/lib/preview.cjs +3 -1
  111. package/pan-wizard-core/bin/lib/review-deep.cjs +2 -2
  112. package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
  113. package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
  114. package/pan-wizard-core/bin/lib/runner.cjs +2 -1
  115. package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
  116. package/pan-wizard-core/bin/lib/state.cjs +47 -31
  117. package/pan-wizard-core/bin/lib/template.cjs +11 -4
  118. package/pan-wizard-core/bin/lib/verdict.cjs +54 -4
  119. package/pan-wizard-core/bin/lib/verify-drift.cjs +14 -5
  120. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
  121. package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
  122. package/pan-wizard-core/bin/lib/verify-scope.cjs +74 -4
  123. package/pan-wizard-core/bin/lib/verify.cjs +117 -35
  124. package/pan-wizard-core/bin/lib/whatif.cjs +5 -3
  125. package/pan-wizard-core/bin/pan-tools.cjs +111 -17
  126. package/pan-wizard-core/learnings/index.json +5 -5
  127. package/pan-wizard-core/mcp/server.cjs +18 -9
  128. package/pan-wizard-core/opencode/pan-wizard.js +111 -0
  129. package/pan-wizard-core/references/checkpoints.md +7 -5
  130. package/pan-wizard-core/references/continuation-format.md +3 -3
  131. package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
  132. package/pan-wizard-core/references/design-methodology.md +1 -1
  133. package/pan-wizard-core/references/git-integration.md +4 -6
  134. package/pan-wizard-core/references/git-planning-commit.md +2 -2
  135. package/pan-wizard-core/references/guardrails.md +2 -2
  136. package/pan-wizard-core/references/handoff-decisions.md +3 -3
  137. package/pan-wizard-core/references/model-profile-resolution.md +5 -5
  138. package/pan-wizard-core/references/model-profiles.md +14 -9
  139. package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
  140. package/pan-wizard-core/references/planning-config.md +4 -4
  141. package/pan-wizard-core/references/tdd.md +10 -1
  142. package/pan-wizard-core/references/verdict-contract.md +2 -1
  143. package/pan-wizard-core/references/verification-patterns.md +5 -5
  144. package/pan-wizard-core/templates/codebase/architecture.md +2 -2
  145. package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
  146. package/pan-wizard-core/templates/codebase/concerns.md +3 -3
  147. package/pan-wizard-core/templates/codebase/integrations.md +6 -6
  148. package/pan-wizard-core/templates/codebase/relationships.md +2 -2
  149. package/pan-wizard-core/templates/codebase/stack.md +2 -2
  150. package/pan-wizard-core/templates/codebase/structure.md +4 -4
  151. package/pan-wizard-core/templates/codebase/testing.md +4 -4
  152. package/pan-wizard-core/templates/continue-here.md +1 -1
  153. package/pan-wizard-core/templates/debug-subagent-prompt.md +3 -2
  154. package/pan-wizard-core/templates/debug.md +15 -1
  155. package/pan-wizard-core/templates/discovery.md +1 -1
  156. package/pan-wizard-core/templates/milestone-archive.md +6 -6
  157. package/pan-wizard-core/templates/milestone.md +1 -1
  158. package/pan-wizard-core/templates/phase-prompt.md +31 -18
  159. package/pan-wizard-core/templates/planner-subagent-prompt.md +3 -5
  160. package/pan-wizard-core/templates/research-project/architecture.md +3 -3
  161. package/pan-wizard-core/templates/research-project/features.md +1 -1
  162. package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
  163. package/pan-wizard-core/templates/research-project/stack.md +3 -3
  164. package/pan-wizard-core/templates/research.md +5 -5
  165. package/pan-wizard-core/templates/retrospective.md +1 -1
  166. package/pan-wizard-core/templates/roadmap.md +15 -15
  167. package/pan-wizard-core/templates/summary-complex.md +1 -0
  168. package/pan-wizard-core/templates/summary-minimal.md +1 -0
  169. package/pan-wizard-core/templates/summary-standard.md +2 -1
  170. package/pan-wizard-core/templates/summary.md +18 -2
  171. package/pan-wizard-core/templates/uat.md +3 -3
  172. package/pan-wizard-core/templates/user-setup.md +8 -8
  173. package/pan-wizard-core/templates/verification-report.md +54 -4
  174. package/pan-wizard-core/workflows/add-phase.md +1 -1
  175. package/pan-wizard-core/workflows/assumptions.md +1 -1
  176. package/pan-wizard-core/workflows/discuss-phase.md +9 -8
  177. package/pan-wizard-core/workflows/exec-phase.md +34 -60
  178. package/pan-wizard-core/workflows/execute-plan.md +8 -7
  179. package/pan-wizard-core/workflows/health.md +13 -11
  180. package/pan-wizard-core/workflows/help.md +8 -9
  181. package/pan-wizard-core/workflows/learn.md +13 -11
  182. package/pan-wizard-core/workflows/map-codebase.md +10 -10
  183. package/pan-wizard-core/workflows/milestone-audit.md +10 -10
  184. package/pan-wizard-core/workflows/milestone-gaps.md +17 -24
  185. package/pan-wizard-core/workflows/milestone-new.md +7 -6
  186. package/pan-wizard-core/workflows/new-project.md +10 -10
  187. package/pan-wizard-core/workflows/optimize.md +11 -9
  188. package/pan-wizard-core/workflows/pause.md +6 -1
  189. package/pan-wizard-core/workflows/phase-tests.md +4 -4
  190. package/pan-wizard-core/workflows/plan-phase.md +31 -34
  191. package/pan-wizard-core/workflows/profile.md +2 -2
  192. package/pan-wizard-core/workflows/progress.md +5 -7
  193. package/pan-wizard-core/workflows/quick.md +10 -8
  194. package/pan-wizard-core/workflows/remove-phase.md +1 -1
  195. package/pan-wizard-core/workflows/research-phase.md +9 -5
  196. package/pan-wizard-core/workflows/resume-project.md +4 -6
  197. package/pan-wizard-core/workflows/retro.md +3 -1
  198. package/pan-wizard-core/workflows/settings.md +16 -24
  199. package/pan-wizard-core/workflows/todo-check.md +1 -1
  200. package/pan-wizard-core/workflows/transition.md +12 -12
  201. package/pan-wizard-core/workflows/update.md +25 -17
  202. package/pan-wizard-core/workflows/verify-phase.md +24 -17
  203. package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
  204. package/pan-zcode/README.md +7 -7
  205. package/pan-zcode/bin/install-zcode.js +1 -1
  206. package/scripts/build-agent-plugin.js +19 -5
  207. package/scripts/build-plugin.js +3 -1
  208. package/scripts/generate-skills-docs.py +13 -4
  209. package/scripts/test-surface.cjs +4 -0
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Memory — cross-phase agent memory layer
2
+ * Memory — the agent memory store
3
3
  *
4
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.
5
+ * Compaction keeps file size bounded.
6
+ *
7
+ * PAN's workflows do not load this store into agents (ADR-0036, amended
8
+ * `2026-10-04`). The harness found that a recorded lesson changed no phase's work
9
+ * beyond what state.md, the summaries and the code already carried, so exec-phase
10
+ * and plan-phase stopped injecting it and nothing writes to it automatically. The
11
+ * store, these commands and hygiene remain for anyone who records lessons by hand;
12
+ * `/pan:knowledge` reads it.
7
13
  *
8
14
  * File format: a markdown file with a stable YAML frontmatter header and
9
15
  * an append-only "## Entries" section containing one bullet per entry:
@@ -17,17 +23,41 @@
17
23
  *
18
24
  * - 2026-04-18: Prefer bulk writes over per-row commits for Postgres
19
25
  * - 2026-04-19: ...
26
+ *
27
+ * Cited, verified, expiring entries (memory optimisation O4 — the Copilot Memory
28
+ * pattern). An entry may end in a metadata comment:
29
+ *
30
+ * - 2026-10-04: Batch inserts through the writer <!-- cites: src/db/writer.js#bulkInsert; used: 2026-10-12 -->
31
+ *
32
+ * `cites` names the code the lesson rests on (`path` or `path#symbol`, relative to
33
+ * the project root). `memory select` checks every citation against the working
34
+ * tree and leaves out an entry whose evidence is gone. `used` is the last day the
35
+ * entry was selected with `memory select --mark-used`. An entry not used for
36
+ * MEMORY_EXPIRE_DAYS is left out too, and `memory prune` archives both kinds to
37
+ * `.planning/memory/archive/<agent>.md`, never deleting.
38
+ *
39
+ * Not every file in `.planning/memory/` is an agent log. RESERVED_MEMORY_NAMES
40
+ * are PAN's own archives there, and they are never loaded as memory: the
41
+ * quarantine holds directives PAN refused to follow (ADR-0040).
20
42
  */
21
43
 
22
44
  const fs = require('fs');
23
45
  const path = require('path');
24
- const { output, error } = require('./core.cjs');
25
- const { CHARS_PER_TOKEN, MEMORY_SELECT_BUDGET_TOKENS, MEMORY_RECENCY_FLOOR, MEMORY_SOFT_CAP_MULT, MEMORY_LOAD_WARN_TOKENS, MEMORY_LOAD_CRIT_TOKENS, MEMORY_LOAD_MAX_FRACTION } = require('./constants.cjs');
26
- const { planningPath } = require('./utils.cjs');
46
+ const { output, error, toLf, dominantEol, withEol } = require('./core.cjs');
47
+ const { CHARS_PER_TOKEN, MEMORY_SELECT_BUDGET_TOKENS, MEMORY_RECENCY_FLOOR, MEMORY_SOFT_CAP_MULT, MEMORY_LOAD_WARN_TOKENS, MEMORY_LOAD_CRIT_TOKENS, MEMORY_LOAD_MAX_FRACTION, MEMORY_EXPIRE_DAYS, MEMORY_CITED_FILE_MAX_BYTES } = require('./constants.cjs');
48
+ const { planningPath, planningRel } = require('./utils.cjs');
27
49
 
28
50
  const MEMORY_DIR = 'memory';
51
+ const MEMORY_ARCHIVE_DIR = 'archive';
29
52
  const DEFAULT_MAX_ENTRIES = 500;
30
53
  const AGENT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
54
+ /**
55
+ * Files PAN writes into `.planning/memory/` that are not agent logs: the ADR-0040
56
+ * quarantine (memory-optimize QUARANTINE_FILE), the state.md archive
57
+ * (STATE_ARCHIVE_FILE) and distill's patterns (distill PATTERNS_FILE). A test
58
+ * pins these names to those constants.
59
+ */
60
+ const RESERVED_MEMORY_NAMES = ['quarantine', 'state-archive', 'distill-patterns'];
31
61
 
32
62
  function memoryDir(cwd) {
33
63
  return path.join(planningPath(cwd), MEMORY_DIR);
@@ -37,13 +67,124 @@ function memoryFile(cwd, agent) {
37
67
  return path.join(memoryDir(cwd), `${agent}.md`);
38
68
  }
39
69
 
70
+ function isReservedMemoryName(name) {
71
+ return RESERVED_MEMORY_NAMES.includes(String(name).toLowerCase());
72
+ }
73
+
40
74
  function validateAgentName(agent) {
41
75
  if (typeof agent !== 'string' || !AGENT_NAME_RE.test(agent)) {
42
76
  return `Invalid agent name: ${agent}. Must match ${AGENT_NAME_RE}`;
43
77
  }
78
+ if (isReservedMemoryName(agent)) {
79
+ return `${agent}.md is one of PAN's archives in .planning/memory/, not an agent log; it is never read or written as memory`;
80
+ }
44
81
  return null;
45
82
  }
46
83
 
84
+ // ─── Entry metadata: citations and last use (O4) ────────────────────────────
85
+
86
+ const ENTRY_META_RE = /\s*<!--\s*((?:cites|evidence|used|uses)\s*:[^>]*?)\s*-->\s*$/i;
87
+ const ENTRY_DATE_RE = /^(\d{4}-\d{2}-\d{2}):\s*/;
88
+
89
+ /**
90
+ * An entry's parts: `{ date, text, cites: string[], evidence, used, uses }`.
91
+ * `evidence` is the observed failure a recorded lesson came from (`finding:f_…` or
92
+ * `trace:<session>`, O6); `uses` counts the days it was selected with `--mark-used`.
93
+ */
94
+ function parseEntryMeta(entry) {
95
+ const s = String(entry == null ? '' : entry);
96
+ const m = s.match(ENTRY_META_RE);
97
+ const body = m ? s.slice(0, m.index) : s;
98
+ const d = body.match(ENTRY_DATE_RE);
99
+ const meta = { date: d ? d[1] : null, text: d ? body.slice(d[0].length) : body, cites: [], evidence: null, used: null, uses: 0 };
100
+ if (m) {
101
+ for (const field of m[1].split(';')) {
102
+ const kv = field.match(/^\s*(cites|evidence|used|uses)\s*:\s*(.*?)\s*$/i);
103
+ if (!kv) continue;
104
+ const key = kv[1].toLowerCase();
105
+ if (key === 'cites') meta.cites = kv[2].split(',').map(c => c.trim()).filter(Boolean);
106
+ else if (key === 'evidence') meta.evidence = kv[2] || null;
107
+ else if (key === 'used') { if (/^\d{4}-\d{2}-\d{2}$/.test(kv[2])) meta.used = kv[2]; }
108
+ else if (/^\d+$/.test(kv[2])) meta.uses = Number(kv[2]);
109
+ }
110
+ }
111
+ return meta;
112
+ }
113
+
114
+ /** The entry string for `meta` — the inverse of parseEntryMeta. */
115
+ function formatEntry(meta) {
116
+ const fields = [];
117
+ if (meta.cites && meta.cites.length) fields.push(`cites: ${meta.cites.join(', ')}`);
118
+ if (meta.evidence) fields.push(`evidence: ${meta.evidence}`);
119
+ if (meta.used) fields.push(`used: ${meta.used}`);
120
+ if (meta.uses) fields.push(`uses: ${meta.uses}`);
121
+ const head = meta.date ? `${meta.date}: ${meta.text}` : meta.text;
122
+ return fields.length ? `${head} <!-- ${fields.join('; ')} -->` : head;
123
+ }
124
+
125
+ /**
126
+ * Why a citation (`path` or `path#symbol`) does not hold in the working tree, or
127
+ * null when it does. Paths are relative to the project root and may not leave it.
128
+ * A symbol made of word characters must appear as a whole word; any other symbol
129
+ * as a substring.
130
+ */
131
+ function citationProblem(cwd, cite) {
132
+ const s = String(cite || '');
133
+ const hash = s.indexOf('#');
134
+ const rel = hash === -1 ? s : s.slice(0, hash);
135
+ const symbol = hash === -1 ? '' : s.slice(hash + 1);
136
+ if (!rel || path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) return 'not a project-relative path';
137
+ const root = path.resolve(cwd);
138
+ const abs = path.resolve(root, rel);
139
+ if (abs !== root && !abs.startsWith(root + path.sep)) return 'outside the project';
140
+ // One open handle for the check and the read, so the file cannot change between them.
141
+ let fd;
142
+ try { fd = fs.openSync(abs, 'r'); } catch { return 'file not found'; }
143
+ let text;
144
+ try {
145
+ const st = fs.fstatSync(fd);
146
+ if (!symbol) return null;
147
+ if (!st.isFile()) return 'a symbol needs a file';
148
+ if (st.size > MEMORY_CITED_FILE_MAX_BYTES) return null;
149
+ try { text = fs.readFileSync(fd, 'utf-8'); } catch { return 'unreadable'; }
150
+ } finally { fs.closeSync(fd); }
151
+ const found = /^\w+$/.test(symbol) ? new RegExp(`\\b${symbol}\\b`).test(text) : text.includes(symbol);
152
+ return found ? null : `\`${symbol}\` not found`;
153
+ }
154
+
155
+ function daysSince(isoDate, now) {
156
+ return Math.floor((now - Date.parse(`${isoDate}T00:00:00Z`)) / 86400000);
157
+ }
158
+
159
+ /**
160
+ * Whether `memory select` returns an entry: `valid`, `expired` (not used for `expireDays`;
161
+ * checked first, it needs no file reads) or `stale` (a citation no longer holds).
162
+ * An undated entry never expires; an uncited one is never stale.
163
+ */
164
+ function entryStatus(cwd, entry, { now = Date.now(), expireDays = MEMORY_EXPIRE_DAYS } = {}) {
165
+ const meta = parseEntryMeta(entry);
166
+ const last = meta.used || meta.date;
167
+ if (last && expireDays > 0) {
168
+ const age = daysSince(last, now);
169
+ if (age > expireDays) return { status: 'expired', meta, last_used: last, age_days: age };
170
+ }
171
+ const missing = [];
172
+ for (const c of meta.cites) {
173
+ const p = citationProblem(cwd, c);
174
+ if (p) missing.push(`${c} (${p})`);
175
+ }
176
+ return missing.length ? { status: 'stale', meta, missing } : { status: 'valid', meta };
177
+ }
178
+
179
+ function isoDay(now) {
180
+ return new Date(now).toISOString().slice(0, 10);
181
+ }
182
+
183
+ function expireDaysFrom(v) {
184
+ const n = Number(v);
185
+ return v != null && v !== '' && Number.isInteger(n) && n >= 0 ? n : MEMORY_EXPIRE_DAYS;
186
+ }
187
+
47
188
  function today() {
48
189
  return new Date().toISOString().slice(0, 10);
49
190
  }
@@ -73,28 +214,41 @@ function readMemory(cwd, agent) {
73
214
  * @returns {string[]} ordered entries (oldest → newest, as stored)
74
215
  */
75
216
  function parseEntries(raw) {
76
- const entries = [];
77
- const lines = raw.split(/\r?\n/);
217
+ const lines = toLf(raw).split('\n');
218
+ return entryLineIndexes(lines).map(i => lines[i].match(/^-\s+(.+)$/)[1]);
219
+ }
220
+
221
+ /** Line indexes of the `## Entries` bullets in LF lines, in order — parseEntries' walk. */
222
+ function entryLineIndexes(lines) {
223
+ const idx = [];
78
224
  let inEntries = false;
79
- for (const line of lines) {
225
+ for (let i = 0; i < lines.length; i++) {
226
+ const line = lines[i];
80
227
  if (/^##\s+Entries\s*$/.test(line)) { inEntries = true; continue; }
81
228
  if (inEntries && /^##\s+/.test(line)) break;
82
- if (!inEntries) continue;
83
- const match = line.match(/^-\s+(.+)$/);
84
- if (match) entries.push(match[1]);
229
+ if (inEntries && /^-\s+(.+)$/.test(line)) idx.push(i);
85
230
  }
86
- return entries;
231
+ return idx;
232
+ }
233
+
234
+ /** Citations from `--cites a.js#x,b.js` (string) or an array. */
235
+ function citeList(cites) {
236
+ if (Array.isArray(cites)) return cites.map(c => String(c).trim()).filter(Boolean);
237
+ return typeof cites === 'string' ? cites.split(',').map(c => c.trim()).filter(Boolean) : [];
87
238
  }
88
239
 
89
240
  /**
90
241
  * Append a single entry to an agent's memory log. Creates file+dir if absent.
91
242
  * Entries are prefixed with today's date automatically unless already prefixed.
243
+ * `opts.cites` attaches citations; each must hold in the working tree now, since a
244
+ * lesson whose evidence is already missing would never be selected.
92
245
  * @param {string} cwd - Project root
93
246
  * @param {string} agent - Agent name
94
247
  * @param {string} entry - Single-line lesson (newlines will be collapsed)
248
+ * @param {{cites?: string|string[]}} [opts]
95
249
  * @returns {{appended: true, file: string, count: number}|{error: string}}
96
250
  */
97
- function appendMemory(cwd, agent, entry) {
251
+ function appendMemory(cwd, agent, entry, opts = {}) {
98
252
  const err = validateAgentName(agent);
99
253
  if (err) return { error: err };
100
254
  if (typeof entry !== 'string' || !entry.trim()) {
@@ -103,7 +257,17 @@ function appendMemory(cwd, agent, entry) {
103
257
 
104
258
  const cleaned = entry.replace(/\r?\n/g, ' ').trim();
105
259
  const datePrefixed = /^\d{4}-\d{2}-\d{2}:/.test(cleaned);
106
- const finalEntry = datePrefixed ? cleaned : `${today()}: ${cleaned}`;
260
+ let finalEntry = datePrefixed ? cleaned : `${today()}: ${cleaned}`;
261
+ const extra = citeList(opts.cites);
262
+ if (extra.length) {
263
+ const meta = parseEntryMeta(finalEntry);
264
+ meta.cites = [...new Set([...meta.cites, ...extra])];
265
+ finalEntry = formatEntry(meta);
266
+ }
267
+ for (const c of parseEntryMeta(finalEntry).cites) {
268
+ const problem = citationProblem(cwd, c);
269
+ if (problem) return { error: `citation ${c}: ${problem}` };
270
+ }
107
271
 
108
272
  try {
109
273
  fs.mkdirSync(memoryDir(cwd), { recursive: true });
@@ -119,16 +283,18 @@ function appendMemory(cwd, agent, entry) {
119
283
  // new file
120
284
  }
121
285
 
286
+ // Built on LF, written in the file's own line ending (a CRLF log stays CRLF).
287
+ const eol = dominantEol(existing);
122
288
  let contents;
123
289
  if (!existing) {
124
290
  contents = buildHeader(agent) + '\n\n## Entries\n\n- ' + finalEntry + '\n';
125
291
  } else if (/##\s+Entries/.test(existing)) {
126
292
  // Ensure file ends with newline, then append bullet.
127
293
  const needsNl = !existing.endsWith('\n');
128
- contents = existing + (needsNl ? '\n' : '') + `- ${finalEntry}\n`;
294
+ contents = existing + (needsNl ? eol : '') + withEol(`- ${finalEntry}\n`, eol);
129
295
  } else {
130
296
  const needsNl = !existing.endsWith('\n');
131
- contents = existing + (needsNl ? '\n' : '') + '\n## Entries\n\n- ' + finalEntry + '\n';
297
+ contents = existing + (needsNl ? eol : '') + withEol('\n## Entries\n\n- ' + finalEntry + '\n', eol);
132
298
  }
133
299
 
134
300
  try {
@@ -156,7 +322,31 @@ function buildHeader(agent) {
156
322
  }
157
323
 
158
324
  /**
159
- * Trim a memory file to the last N entries. Preserves frontmatter header.
325
+ * Append a block to `.planning/memory/archive/<agent>.md`, creating it with its
326
+ * preamble. Callers write the archive BEFORE they rewrite the log, so an entry is
327
+ * never in neither place.
328
+ * @param {string} cwd - Project root
329
+ * @param {string} agent - Agent name (already validated)
330
+ * @param {string} block - Bullet block to append (LF)
331
+ * @returns {string} The archive's absolute path
332
+ */
333
+ function appendToMemoryArchive(cwd, agent, block) {
334
+ fs.mkdirSync(path.join(memoryDir(cwd), MEMORY_ARCHIVE_DIR), { recursive: true });
335
+ const archivePath = path.join(memoryDir(cwd), MEMORY_ARCHIVE_DIR, `${agent}.md`);
336
+ const preamble = `# Archived memory: ${agent}\n\nEntries moved out of \`${planningRel(MEMORY_DIR, `${agent}.md`)}\` by \`memory prune\` (their cited code was gone, or they went unused for the expiry window) or by compaction (past the log's entry cap). To restore one, move its line back under \`## Entries\` there.\n`;
337
+ try {
338
+ fs.writeFileSync(archivePath, preamble, { flag: 'wx', encoding: 'utf-8' });
339
+ } catch (e) {
340
+ if (e.code !== 'EEXIST') throw e;
341
+ }
342
+ fs.appendFileSync(archivePath, withEol(block, dominantEol(fs.readFileSync(archivePath, 'utf-8'))), 'utf-8');
343
+ return archivePath;
344
+ }
345
+
346
+ /**
347
+ * Trim a memory file to the last N entries. Preserves frontmatter header. The
348
+ * entries it drops go to the agent's archive first, as `memory prune` does: they
349
+ * may be lessons a person recorded, and compaction used to discard them.
160
350
  * @param {string} cwd - Project root
161
351
  * @param {string} agent - Agent name
162
352
  * @param {number} maxEntries - Keep this many most-recent entries
@@ -184,41 +374,58 @@ function compactMemory(cwd, agent, maxEntries = DEFAULT_MAX_ENTRIES) {
184
374
  }
185
375
 
186
376
  const keep = entries.slice(-max);
187
- const removed = entries.length - keep.length;
377
+ const dropped = entries.slice(0, entries.length - keep.length);
378
+ const removed = dropped.length;
188
379
 
380
+ // 1. The archive first.
381
+ try {
382
+ appendToMemoryArchive(cwd, agent, `\n<!-- compacted on ${isoDay(new Date())} -->\n`
383
+ + dropped.map(e => `- ${e}\n - compacted: past the ${max}-entry cap`).join('\n') + '\n');
384
+ } catch (e) {
385
+ return { error: `Failed to archive the compacted entries: ${e.message}` };
386
+ }
387
+ // 2. Only then rewrite the log.
189
388
  const headerMatch = raw.match(/^---[\s\S]*?---/);
190
389
  const header = headerMatch ? headerMatch[0] : buildHeader(agent);
191
390
  const body = '\n\n## Entries\n\n' + keep.map(e => `- ${e}`).join('\n') + '\n';
192
391
  try {
193
- fs.writeFileSync(file, header + body, 'utf-8');
392
+ fs.writeFileSync(file, withEol(toLf(header) + body, dominantEol(raw)), 'utf-8');
194
393
  } catch (e) {
195
394
  return { error: `Failed to write memory file: ${e.message}` };
196
395
  }
197
- return { compacted: true, kept: keep.length, removed };
396
+ return { compacted: true, kept: keep.length, removed, archived: removed, archive: planningRel(MEMORY_DIR, MEMORY_ARCHIVE_DIR, `${agent}.md`) };
198
397
  }
199
398
 
200
399
  /**
201
- * List all agents that have a memory file.
400
+ * List the agent logs in `.planning/memory/` — the files memory is loaded from.
401
+ * Every other `.md` file there is listed under `not_loaded` with the reason: PAN's
402
+ * own archives (RESERVED_MEMORY_NAMES; the quarantine holds directives PAN refused
403
+ * to follow) and files with no `## Entries` list, whose text `memory select` can
404
+ * neither verify nor expire.
202
405
  * @param {string} cwd - Project root
203
- * @returns {{agents: Array<{agent: string, entries: number}>}}
406
+ * @returns {{agents: Array<{agent: string, entries: number}>, not_loaded: Array<{file: string, reason: string}>}}
204
407
  */
205
408
  function listMemoryAgents(cwd) {
206
409
  let files;
207
410
  try {
208
411
  files = fs.readdirSync(memoryDir(cwd));
209
412
  } catch {
210
- return { agents: [] };
413
+ return { agents: [], not_loaded: [] };
211
414
  }
212
415
  const agents = [];
416
+ const notLoaded = [];
213
417
  for (const f of files) {
214
418
  if (!f.endsWith('.md')) continue;
215
419
  const name = f.slice(0, -3);
216
- if (!AGENT_NAME_RE.test(name)) continue;
420
+ if (isReservedMemoryName(name)) { notLoaded.push({ file: f, reason: 'PAN archive, never loaded as memory' }); continue; }
421
+ if (!AGENT_NAME_RE.test(name)) { notLoaded.push({ file: f, reason: 'not a valid agent name' }); continue; }
217
422
  const mem = readMemory(cwd, name);
218
- agents.push({ agent: name, entries: mem ? mem.entries.length : 0 });
423
+ if (!mem || !/^##\s+Entries\s*$/m.test(mem.raw)) { notLoaded.push({ file: f, reason: 'no `## Entries` list' }); continue; }
424
+ agents.push({ agent: name, entries: mem.entries.length });
219
425
  }
220
426
  agents.sort((a, b) => a.agent.localeCompare(b.agent));
221
- return { agents };
427
+ notLoaded.sort((a, b) => a.file.localeCompare(b.file));
428
+ return { agents, not_loaded: notLoaded };
222
429
  }
223
430
 
224
431
  // ─── Cue + recency scoped, token-budgeted read (ADR-0036 FW-2) ───────────────
@@ -247,36 +454,58 @@ function estMemoryTokens(str) {
247
454
  /**
248
455
  * Select a cue-relevant, recency-floored, token-budgeted slice of an agent's
249
456
  * memory instead of the whole log (ADR-0036 FW-2) — distill-and-select on the
250
- * memory axis, so per-agent memory injection can't flood context.
457
+ * memory axis, so whoever hands an agent its memory cannot flood its context.
251
458
  *
252
459
  * Always keeps the newest `recencyFloor` entries (recall never returns empty on
253
460
  * a non-empty log); fills the remaining budget by cue relevance, falling back to
254
461
  * recency-only when the cue is empty or matches nothing; greedily packs under
255
462
  * `tokenBudget`. Output is in stored (chronological) order.
256
463
  *
464
+ * Only valid entries are candidates (O4): an entry whose cited code is gone
465
+ * (`stale`) or that was not used for `expireDays` (`expired`) is left out and
466
+ * reported. `all` takes every valid entry with no budget. `markUsed` records
467
+ * today as the last use of each selected entry, in the file.
468
+ *
257
469
  * @param {string} cwd
258
470
  * @param {string} agent
259
- * @param {{cue?: string, tokenBudget?: number, recencyFloor?: number}} [opts]
260
- * @returns {{agent, cue, selected: string[], total_tokens, considered, dropped, mode}|{error}}
471
+ * @param {{cue?: string, tokenBudget?: number, recencyFloor?: number, all?: boolean,
472
+ * markUsed?: boolean, expireDays?: number, now?: number}} [opts]
473
+ * @returns {{agent, cue, selected: string[], total_tokens, considered, dropped, mode,
474
+ * stale: Array<{entry, missing}>, expired: Array<{entry, last_used}>, marked_used?: number}|{error}}
261
475
  */
262
476
  function selectMemory(cwd, agent, opts = {}) {
263
477
  const err = validateAgentName(agent);
264
478
  if (err) return { error: err };
265
479
  const mem = readMemory(cwd, agent);
266
480
  if (!mem || mem.entries.length === 0) {
267
- return { agent, cue: opts.cue || '', selected: [], total_tokens: 0, considered: 0, dropped: 0, mode: 'empty' };
481
+ return { agent, cue: opts.cue || '', selected: [], total_tokens: 0, considered: 0, dropped: 0, mode: 'empty', stale: [], expired: [] };
482
+ }
483
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
484
+ const expireDays = expireDaysFrom(opts.expireDays);
485
+ const stale = [];
486
+ const expired = [];
487
+ const valid = []; // { text, i } with i the entry's index in the log
488
+ mem.entries.forEach((text, i) => {
489
+ const s = entryStatus(cwd, text, { now, expireDays });
490
+ if (s.status === 'expired') expired.push({ entry: text, last_used: s.last_used });
491
+ else if (s.status === 'stale') stale.push({ entry: text, missing: s.missing });
492
+ else valid.push({ text, i });
493
+ });
494
+ const base = { agent, cue: opts.cue || '', considered: mem.entries.length, stale, expired };
495
+ if (valid.length === 0) {
496
+ return { ...base, selected: [], total_tokens: 0, dropped: 0, mode: 'empty' };
268
497
  }
269
- const all = mem.entries; // oldest -> newest
498
+
270
499
  const bN = Number(opts.tokenBudget);
271
- const budget = Number.isFinite(bN) && bN > 0 ? bN : MEMORY_SELECT_BUDGET_TOKENS;
500
+ const budget = opts.all ? Infinity : (Number.isFinite(bN) && bN > 0 ? bN : MEMORY_SELECT_BUDGET_TOKENS);
272
501
  const fN = Number(opts.recencyFloor);
273
502
  const recencyFloor = Number.isFinite(fN) && fN >= 0 ? fN : MEMORY_RECENCY_FLOOR;
274
503
  const tokens = cueTokens(opts.cue);
275
504
 
276
- const floorFrom = Math.max(0, all.length - recencyFloor);
277
- const scored = all.map((text, i) => ({
278
- text, i, tokens: estMemoryTokens(text),
279
- score: i >= floorFrom ? Infinity : scoreEntry(text, tokens),
505
+ const floorFrom = Math.max(0, valid.length - recencyFloor);
506
+ const scored = valid.map((v, k) => ({
507
+ text: v.text, i: v.i, tokens: estMemoryTokens(v.text),
508
+ score: k >= floorFrom ? Infinity : scoreEntry(v.text, tokens),
280
509
  }));
281
510
  const anyCueHit = scored.some(e => Number.isFinite(e.score) && e.score > 0);
282
511
  // Priority: recency-floor first (Infinity), then cue score, then newest.
@@ -294,22 +523,190 @@ function selectMemory(cwd, agent, opts = {}) {
294
523
  chosen.push(newest); total += newest.tokens; dropped = Math.max(0, dropped - 1);
295
524
  }
296
525
  chosen.sort((a, b) => a.i - b.i); // chronological for output
297
- const mode = tokens.length === 0 ? 'recency' : (anyCueHit ? 'cue' : 'recency');
298
- return { agent, cue: opts.cue || '', selected: chosen.map(e => e.text), total_tokens: total, considered: all.length, dropped, mode };
526
+ const mode = opts.all ? 'all' : tokens.length === 0 ? 'recency' : (anyCueHit ? 'cue' : 'recency');
527
+ const result = { ...base, selected: chosen.map(e => e.text), total_tokens: total, dropped, mode };
528
+ if (opts.markUsed) result.marked_used = markEntriesUsed(cwd, agent, chosen.map(e => e.i), isoDay(now));
529
+ return result;
530
+ }
531
+
532
+ /**
533
+ * Record `day` as the last use of the entries at `indexes` (positions in the log),
534
+ * rewriting only those bullet lines, in the file's own line ending. Returns how
535
+ * many changed; an entry already marked for `day` is left alone.
536
+ */
537
+ function markEntriesUsed(cwd, agent, indexes, day) {
538
+ const file = memoryFile(cwd, agent);
539
+ let raw;
540
+ try { raw = fs.readFileSync(file, 'utf-8'); } catch { return 0; }
541
+ const lines = toLf(raw).split('\n');
542
+ const at = entryLineIndexes(lines);
543
+ let changed = 0;
544
+ for (const k of indexes) {
545
+ const li = at[k];
546
+ if (li === undefined) continue;
547
+ const meta = parseEntryMeta(lines[li].replace(/^-\s+/, ''));
548
+ if (meta.used === day) continue;
549
+ meta.used = day;
550
+ meta.uses = (meta.uses || 0) + 1; // days selected: the use telemetry O6 asked for
551
+ lines[li] = `- ${formatEntry(meta)}`;
552
+ changed++;
553
+ }
554
+ if (changed) fs.writeFileSync(file, withEol(lines.join('\n'), dominantEol(raw)), 'utf-8');
555
+ return changed;
299
556
  }
300
557
 
558
+ // ─── The gated write path (O6) ───────────────────────────────────────────────
559
+
560
+ const LESSON_MIN_CHARS = 20;
561
+ const LESSON_MAX_CHARS = 300;
562
+ const TRACE_SESSION_RE = /^[A-Za-z0-9._-]{1,120}$/;
563
+ const sameText = (a, b) => String(a || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() === String(b || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
564
+
301
565
  /**
302
- * Memory-load telemetry gate (ADR-0036 acceptance signal). Estimates the tokens
303
- * of memory that would be injected whole (every agent log) and compares to the
304
- * median per-agent PROMPT from the trustworthy cost ledger (suspect records
305
- * quarantined). Read-only, non-blocking; degrades to an absolute-token check
306
- * when the ledger is thin.
566
+ * Record a lesson in an agent's memory — the gated write path (memory
567
+ * optimisation O6). The market and the research agree on what makes agent memory
568
+ * pay: lessons from observed failures, carrying the correction and its evidence,
569
+ * never raw traces and never what the code already says. So a lesson is recorded
570
+ * only:
571
+ * - from evidence of a corrected failure: `finding` (a finding in the ledger whose
572
+ * status is `fixed`: a verifier gap a fix round closed) or `trace` (a trace
573
+ * session, where the optimizer saw a recurring tool failure);
574
+ * - with at least one citation that holds (O4), the code the correction lives in;
575
+ * - as one line of 20–300 characters that says the correction, not the finding;
576
+ * - when it is not a directive to bypass the process (ADR-0040);
577
+ * - once: one lesson per finding, and never a lesson already in the log.
578
+ * A refusal is `{recorded: false, reason}`; bad input is `{error}`.
579
+ * @param {string} cwd
580
+ * @param {string} agent
581
+ * @param {{lesson: string, finding?: string, trace?: string, cites?: string|string[]}} opts
582
+ */
583
+ function recordLesson(cwd, agent, opts = {}) {
584
+ const err = validateAgentName(agent);
585
+ if (err) return { error: err };
586
+ if (opts.finding && opts.trace) return { error: 'give one piece of evidence: --finding <id> or --trace <session>' };
587
+ const lesson = String(opts.lesson || '').replace(/\s+/g, ' ').trim();
588
+ const refuse = (reason, extra = {}) => ({ recorded: false, agent, reason, ...extra });
589
+ if (lesson.length < LESSON_MIN_CHARS) return refuse(`a lesson needs at least ${LESSON_MIN_CHARS} characters: write the correction, not a label`);
590
+ if (lesson.length > LESSON_MAX_CHARS) return refuse(`a lesson is one rule of at most ${LESSON_MAX_CHARS} characters`);
591
+ if (/<!--|--!?>/.test(lesson)) return refuse('a lesson may not carry an HTML comment');
592
+ if (require('./memory-optimize.cjs').isSuspiciousDirective(lesson)) {
593
+ return refuse('it reads as a directive to override or bypass the process (ADR-0040), so it is not recorded');
594
+ }
595
+
596
+ let evidence;
597
+ if (opts.finding) {
598
+ const f = require('./findings.cjs').listFindings(cwd, {}).findings.find(x => x.id === opts.finding);
599
+ if (!f) return refuse(`no finding ${opts.finding} in the findings ledger`);
600
+ if (f.status !== 'fixed') return refuse(`finding ${opts.finding} is ${f.status}, not fixed: a lesson comes from a failure that was corrected`);
601
+ if (sameText(lesson, f.summary)) return refuse('the lesson repeats the finding: write the correction, what to do next time');
602
+ evidence = `finding:${f.id}`;
603
+ } else if (opts.trace) {
604
+ if (!TRACE_SESSION_RE.test(opts.trace)) return refuse(`not a trace session id: ${opts.trace}`);
605
+ try { fs.statSync(planningPath(cwd, 'optimization', 'traces', opts.trace, 'trace.jsonl')); } catch {
606
+ return refuse(`no trace session ${opts.trace} under ${planningRel('optimization', 'traces')}/`);
607
+ }
608
+ evidence = `trace:${opts.trace}`;
609
+ } else {
610
+ return refuse('a lesson needs evidence of the failure it corrects: --finding <id> (a fixed finding) or --trace <session>');
611
+ }
612
+
613
+ const cites = citeList(opts.cites);
614
+ if (!cites.length) return refuse('a lesson needs --cites <path[#symbol]>: the code the correction lives in');
615
+ for (const c of cites) {
616
+ const problem = citationProblem(cwd, c);
617
+ if (problem) return refuse(`citation ${c}: ${problem}`);
618
+ }
619
+
620
+ const mem = readMemory(cwd, agent);
621
+ for (const e of mem ? mem.entries : []) {
622
+ const m = parseEntryMeta(e);
623
+ if (evidence.startsWith('finding:') && m.evidence === evidence) return refuse(`${evidence} already has a lesson: ${m.text}`, { duplicate: true });
624
+ if (sameText(m.text, lesson)) return refuse('the same lesson is already in the log', { duplicate: true });
625
+ }
626
+
627
+ const entry = formatEntry({ date: today(), text: lesson, cites, evidence });
628
+ const r = appendMemory(cwd, agent, entry);
629
+ if (r.error) return r;
630
+ try {
631
+ require('./optimize.cjs').logTraceEvent(cwd, {
632
+ type: 'decision', category: 'memory_recorded', agent: 'orchestrator', impact: 'minor',
633
+ description: `lesson recorded for ${agent} from ${evidence}`,
634
+ context: { agent, evidence, cites },
635
+ });
636
+ } catch { /* telemetry is best effort */ }
637
+ return { recorded: true, agent, evidence, entry, file: planningRel(MEMORY_DIR, `${agent}.md`), count: r.count };
638
+ }
639
+
640
+ /**
641
+ * Archive the entries `memory select` no longer returns — stale (cited code gone) and
642
+ * expired (unused for `days`) — from one agent's log or all of them. The archive
643
+ * at `.planning/memory/archive/<agent>.md` is written FIRST, then only those
644
+ * bullet lines leave the log, so an interruption can duplicate and never lose.
645
+ * Dry-run unless `apply`.
646
+ * @param {string} cwd
647
+ * @param {string|null} agent - one agent, or null for every agent log
648
+ * @param {{apply?: boolean, days?: number, now?: number}} [opts]
649
+ */
650
+ function pruneMemory(cwd, agent, opts = {}) {
651
+ const names = agent ? [agent] : listMemoryAgents(cwd).agents.map(a => a.agent);
652
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
653
+ const expireDays = expireDaysFrom(opts.days);
654
+ const day = isoDay(now);
655
+ const agents = [];
656
+ for (const name of names) {
657
+ const err = validateAgentName(name);
658
+ if (err) return { error: err };
659
+ const mem = readMemory(cwd, name);
660
+ if (!mem) {
661
+ if (agent) return { error: `No memory file for agent: ${name}` };
662
+ continue;
663
+ }
664
+ const archive = [];
665
+ mem.entries.forEach((text, i) => {
666
+ const s = entryStatus(cwd, text, { now, expireDays });
667
+ if (s.status === 'expired') archive.push({ i, entry: text, reason: `not used since ${s.last_used}` });
668
+ else if (s.status === 'stale') archive.push({ i, entry: text, reason: `cited code gone: ${s.missing.join(', ')}` });
669
+ });
670
+ agents.push({ agent: name, archive, kept: mem.entries.length - archive.length, raw: mem.raw });
671
+ }
672
+ const total = agents.reduce((n, a) => n + a.archive.length, 0);
673
+ const summary = {
674
+ expire_days: expireDays,
675
+ archive_dir: planningRel(MEMORY_DIR, MEMORY_ARCHIVE_DIR),
676
+ agents: agents.map(a => ({ agent: a.agent, kept: a.kept, archive: a.archive.map(({ entry, reason }) => ({ entry, reason })) })),
677
+ archivable: total,
678
+ applied: false,
679
+ dry_run: !opts.apply,
680
+ };
681
+ if (!opts.apply || total === 0) return summary;
682
+
683
+ fs.mkdirSync(path.join(memoryDir(cwd), MEMORY_ARCHIVE_DIR), { recursive: true });
684
+ for (const a of agents) {
685
+ if (!a.archive.length) continue;
686
+ // 1. The archive first.
687
+ appendToMemoryArchive(cwd, a.agent, `\n<!-- pruned on ${day} -->\n` + a.archive.map(x => `- ${x.entry}\n - pruned: ${x.reason}`).join('\n') + '\n');
688
+ // 2. Only then take those bullet lines out of the log.
689
+ const lines = toLf(a.raw).split('\n');
690
+ const at = entryLineIndexes(lines);
691
+ const drop = new Set(a.archive.map(x => at[x.i]));
692
+ fs.writeFileSync(memoryFile(cwd, a.agent), withEol(lines.filter((_, li) => !drop.has(li)).join('\n'), dominantEol(a.raw)), 'utf-8');
693
+ }
694
+ return { ...summary, applied: true, dry_run: false };
695
+ }
696
+
697
+ /**
698
+ * Memory-load telemetry (ADR-0036 acceptance signal). Estimates the tokens the
699
+ * whole store would add to an agent that was handed every agent log, and compares
700
+ * them to the median per-agent PROMPT from the trustworthy cost ledger (suspect
701
+ * records quarantined). Read-only, non-blocking; degrades to an absolute-token
702
+ * check when the ledger is thin. PAN's workflows no longer hand memory to agents
703
+ * (`2026-10-04`), so this sizes the store for someone who would.
307
704
  *
308
705
  * The prompt is `input + cache_read + cache_write`, not `input` alone: under prompt
309
706
  * caching the uncached remainder is tens of tokens, so dividing by it reported 1.8k
310
707
  * of memory as 8,940% of a "median agent input" and called it critical (field sweep
311
- * 2026-09-17). Memory is injected into the whole prompt, so the whole prompt is what
312
- * it must be measured against.
708
+ * 2026-09-17). Memory handed to an agent joins the whole prompt, so the whole
709
+ * prompt is what it must be measured against.
313
710
  *
314
711
  * @returns {{memory_tokens, agents, median_prompt_tokens, fraction, status, advisory}}
315
712
  */
@@ -340,9 +737,9 @@ function memoryLoadBudget(cwd, opts = {}) {
340
737
  else if (memoryTokens >= warnT || (fraction != null && fraction >= maxFrac)) status = 'warning';
341
738
  const advisory = status === 'ok'
342
739
  ? 'Memory-load within budget.'
343
- : `Memory injection is ~${memoryTokens} tokens across ${agents.length} agent log(s)` +
740
+ : `Agent memory is ~${memoryTokens} tokens across ${agents.length} agent log(s)` +
344
741
  (fraction != null ? ` (~${Math.round(fraction * 100)}% of a median agent prompt)` : '') +
345
- `. Bound it with cue-scoped 'memory select' or trim with 'memory compact <agent>'.`;
742
+ `. PAN's workflows do not load it into agents; to hand some of it to one, use cue-scoped 'memory select', and trim it with 'memory compact <agent>' or 'memory prune'.`;
346
743
  return { memory_tokens: memoryTokens, agents: agents.length, median_prompt_tokens: median, fraction, status, advisory };
347
744
  }
348
745
 
@@ -350,19 +747,45 @@ function memoryLoadBudget(cwd, opts = {}) {
350
747
 
351
748
  function cmdMemoryRead(cwd, agent, raw) {
352
749
  if (!agent) { error('Usage: memory read <agent>'); }
750
+ // An archive or invalid name is refused, as append, select and prune refuse it:
751
+ // answering `exists: false` hid that state-archive.md was right there.
752
+ const refused = validateAgentName(agent);
753
+ if (refused) { output({ agent, error: refused }, raw); return; }
353
754
  const result = readMemory(cwd, agent);
354
755
  if (!result) { output({ agent, entries: [], exists: false }, raw); return; }
355
756
  output({ agent, entries: result.entries, exists: true }, raw);
356
757
  }
357
758
 
358
- function cmdMemoryAppend(cwd, agent, entry, raw) {
359
- if (!agent || !entry) { error('Usage: memory append <agent> <entry>'); }
360
- const result = appendMemory(cwd, agent, entry);
361
- output(result, raw);
759
+ function cmdMemoryAppend(cwd, agent, entry, raw, opts = {}) {
760
+ if (!agent || !entry) { error('Usage: memory append <agent> <entry> [--cites <path[#symbol]>,...]'); }
761
+ // A refused entry is reported as `{ error }` on stdout, like every append error.
762
+ output(appendMemory(cwd, agent, entry, opts), raw);
763
+ }
764
+
765
+ /**
766
+ * Use telemetry across the agent logs (O6): how many entries carry evidence of the
767
+ * failure they came from, cite code, and were ever used (`uses`, counted by
768
+ * `memory select --mark-used`). An entry never used is memory nobody reads.
769
+ */
770
+ function memoryUsage(cwd, agents) {
771
+ const u = { entries: 0, with_evidence: 0, cited: 0, used: 0, never_used: 0 };
772
+ for (const a of agents) {
773
+ const mem = readMemory(cwd, a.agent);
774
+ for (const e of mem ? mem.entries : []) {
775
+ const m = parseEntryMeta(e);
776
+ u.entries++;
777
+ if (m.evidence) u.with_evidence++;
778
+ if (m.cites.length) u.cited++;
779
+ if (m.uses > 0 || m.used) u.used++;
780
+ else u.never_used++;
781
+ }
782
+ }
783
+ return u;
362
784
  }
363
785
 
364
786
  function cmdMemoryList(cwd, raw) {
365
- output(listMemoryAgents(cwd), raw);
787
+ const l = listMemoryAgents(cwd);
788
+ output({ ...l, usage: memoryUsage(cwd, l.agents) }, raw);
366
789
  }
367
790
 
368
791
  function cmdMemoryCompact(cwd, agent, maxEntries, raw) {
@@ -372,10 +795,31 @@ function cmdMemoryCompact(cwd, agent, maxEntries, raw) {
372
795
  }
373
796
 
374
797
  function cmdMemorySelect(cwd, agent, opts, raw) {
375
- if (!agent) { error('Usage: memory select <agent> [--cue <text>] [--token-budget N] [--recency-floor N]'); }
798
+ if (!agent) { error('Usage: memory select <agent> [--cue <text>] [--token-budget N] [--recency-floor N] [--all] [--mark-used] [--days N]'); }
376
799
  output(selectMemory(cwd, agent, opts || {}), raw);
377
800
  }
378
801
 
802
+ /** `memory record <agent> --lesson <text> (--finding <id> | --trace <session>) --cites <path[#symbol]>,...` */
803
+ function cmdMemoryRecord(cwd, agent, opts, raw) {
804
+ if (!agent) { error('Usage: memory record <agent> --lesson "<correction>" (--finding <id> | --trace <session>) --cites <path[#symbol]>,...'); }
805
+ const r = recordLesson(cwd, agent, opts || {});
806
+ if (r.error) { error(r.error); }
807
+ // A refusal is the gate working, reported as data (exit 0) with the reason.
808
+ output(r, raw, r.recorded ? `recorded for ${r.agent} from ${r.evidence}` : `not recorded: ${r.reason}`);
809
+ }
810
+
811
+ /** `memory prune [<agent>] [--apply] [--days N]` */
812
+ function cmdMemoryPrune(cwd, agent, opts, raw) {
813
+ const r = pruneMemory(cwd, agent || null, opts || {});
814
+ if (r.error) { error(r.error); }
815
+ const text = r.applied
816
+ ? `archived ${r.archivable} entr${r.archivable === 1 ? 'y' : 'ies'} to ${r.archive_dir}/`
817
+ : r.archivable
818
+ ? `would archive ${r.archivable} entr${r.archivable === 1 ? 'y' : 'ies'} (stale or unused for ${r.expire_days} days) — rerun with --apply`
819
+ : 'nothing to prune — every entry is cited correctly and was used recently';
820
+ output(r, raw, text);
821
+ }
822
+
379
823
  function cmdMemoryBudget(cwd, raw) {
380
824
  output(memoryLoadBudget(cwd), raw);
381
825
  }
@@ -386,16 +830,26 @@ module.exports = {
386
830
  compactMemory,
387
831
  listMemoryAgents,
388
832
  selectMemory,
833
+ pruneMemory,
834
+ recordLesson,
389
835
  memoryLoadBudget,
390
836
  scoreEntry,
391
837
  parseEntries,
838
+ parseEntryMeta,
839
+ formatEntry,
840
+ citationProblem,
841
+ entryStatus,
392
842
  validateAgentName,
393
843
  cmdMemoryRead,
394
844
  cmdMemoryAppend,
395
845
  cmdMemoryList,
396
846
  cmdMemoryCompact,
397
847
  cmdMemorySelect,
848
+ cmdMemoryPrune,
849
+ cmdMemoryRecord,
398
850
  cmdMemoryBudget,
399
851
  MEMORY_DIR,
852
+ MEMORY_ARCHIVE_DIR,
853
+ RESERVED_MEMORY_NAMES,
400
854
  DEFAULT_MAX_ENTRIES,
401
855
  };