pan-wizard 3.33.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.
- package/README.md +23 -20
- package/agents/pan-conductor.md +17 -16
- package/agents/pan-counterfactual.md +2 -2
- package/agents/pan-debugger.md +27 -8
- package/agents/pan-design-checker.md +1 -1
- package/agents/pan-designer.md +2 -2
- package/agents/pan-distiller.md +3 -3
- package/agents/pan-document_code.md +14 -14
- package/agents/pan-executor.md +12 -8
- package/agents/pan-experiment-runner.md +8 -7
- package/agents/pan-integration-checker.md +2 -2
- package/agents/pan-knowledge.md +1 -1
- package/agents/pan-meta-reviewer.md +3 -3
- package/agents/pan-optimizer.md +39 -43
- package/agents/pan-phase-researcher.md +8 -8
- package/agents/pan-plan-checker.md +30 -28
- package/agents/pan-planner.md +54 -43
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-project-researcher.md +11 -11
- package/agents/pan-release.md +1 -1
- package/agents/pan-research-synthesizer.md +2 -2
- package/agents/pan-reviewer.md +24 -2
- package/agents/pan-roadmapper.md +15 -14
- package/agents/pan-verifier.md +32 -24
- package/bin/install-lib.cjs +58 -23
- package/bin/install.js +47 -1
- package/commands/pan/army.md +15 -11
- package/commands/pan/assumptions.md +2 -2
- package/commands/pan/audit-deployment.md +11 -12
- package/commands/pan/cost.md +8 -4
- package/commands/pan/debug.md +10 -7
- package/commands/pan/design-phase.md +3 -3
- package/commands/pan/discuss-phase.md +2 -0
- package/commands/pan/exec-phase.md +21 -37
- package/commands/pan/experiment.md +9 -8
- package/commands/pan/focus-auto.md +22 -20
- package/commands/pan/focus-design.md +7 -6
- package/commands/pan/focus-doc-audit.md +4 -3
- package/commands/pan/focus-drift-walking.md +4 -3
- package/commands/pan/focus-exec.md +6 -6
- package/commands/pan/focus-plan.md +12 -11
- package/commands/pan/focus-scan.md +5 -3
- package/commands/pan/focus-sync.md +3 -2
- package/commands/pan/git.md +8 -8
- package/commands/pan/health.md +2 -2
- package/commands/pan/hud.md +3 -2
- package/commands/pan/hygiene.md +14 -7
- package/commands/pan/knowledge.md +9 -9
- package/commands/pan/learn.md +10 -7
- package/commands/pan/links.md +4 -1
- package/commands/pan/map-codebase.md +1 -1
- package/commands/pan/mcp-bridge.md +18 -18
- package/commands/pan/milestone-done.md +6 -3
- package/commands/pan/milestone-new.md +1 -1
- package/commands/pan/new-project.md +10 -10
- package/commands/pan/optimize.md +16 -14
- package/commands/pan/patches.md +2 -6
- package/commands/pan/pause.md +1 -1
- package/commands/pan/phase-tests.md +2 -1
- package/commands/pan/plan-phase.md +9 -19
- package/commands/pan/preview.md +1 -1
- package/commands/pan/profile.md +2 -2
- package/commands/pan/quick.md +1 -1
- package/commands/pan/remove-phase.md +1 -1
- package/commands/pan/report.md +2 -2
- package/commands/pan/research-phase.md +12 -11
- package/commands/pan/resume.md +9 -11
- package/commands/pan/retro.md +5 -3
- package/commands/pan/review-deep.md +7 -7
- package/commands/pan/settings.md +1 -1
- package/commands/pan/verify-phase.md +0 -11
- package/commands/pan/what-if.md +2 -2
- package/hooks/dist/pan-context-monitor.js +343 -44
- package/hooks/dist/pan-cost-logger.js +30 -0
- package/hooks/dist/pan-statusline.js +68 -22
- package/hooks/dist/pan-stop-guard.js +7 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
- package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
- package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
- package/pan-wizard-core/bin/lib/commands.cjs +33 -4
- package/pan-wizard-core/bin/lib/config.cjs +2 -1
- package/pan-wizard-core/bin/lib/constants.cjs +18 -6
- package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
- package/pan-wizard-core/bin/lib/core.cjs +53 -19
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
- package/pan-wizard-core/bin/lib/cost.cjs +142 -13
- package/pan-wizard-core/bin/lib/distill.cjs +6 -3
- package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
- package/pan-wizard-core/bin/lib/findings.cjs +9 -2
- package/pan-wizard-core/bin/lib/focus.cjs +13 -4
- package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
- package/pan-wizard-core/bin/lib/hygiene.cjs +178 -17
- package/pan-wizard-core/bin/lib/init.cjs +7 -4
- package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
- package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
- package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
- package/pan-wizard-core/bin/lib/memory.cjs +508 -54
- package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +84 -16
- package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
- package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
- package/pan-wizard-core/bin/lib/phase.cjs +15 -13
- package/pan-wizard-core/bin/lib/preview.cjs +3 -1
- package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
- package/pan-wizard-core/bin/lib/runner.cjs +2 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
- package/pan-wizard-core/bin/lib/state.cjs +43 -28
- package/pan-wizard-core/bin/lib/template.cjs +11 -4
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
- package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
- package/pan-wizard-core/bin/lib/verify-scope.cjs +5 -2
- package/pan-wizard-core/bin/lib/verify.cjs +30 -28
- package/pan-wizard-core/bin/lib/whatif.cjs +5 -3
- package/pan-wizard-core/bin/pan-tools.cjs +101 -15
- package/pan-wizard-core/learnings/index.json +5 -5
- package/pan-wizard-core/opencode/pan-wizard.js +111 -0
- package/pan-wizard-core/references/checkpoints.md +3 -3
- package/pan-wizard-core/references/continuation-format.md +3 -3
- package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/git-integration.md +4 -6
- package/pan-wizard-core/references/git-planning-commit.md +2 -2
- package/pan-wizard-core/references/guardrails.md +2 -2
- package/pan-wizard-core/references/handoff-decisions.md +3 -3
- package/pan-wizard-core/references/model-profile-resolution.md +5 -5
- package/pan-wizard-core/references/model-profiles.md +14 -9
- package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
- package/pan-wizard-core/references/planning-config.md +4 -4
- package/pan-wizard-core/references/tdd.md +10 -1
- package/pan-wizard-core/references/verification-patterns.md +5 -5
- package/pan-wizard-core/templates/codebase/architecture.md +2 -2
- package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
- package/pan-wizard-core/templates/codebase/concerns.md +3 -3
- package/pan-wizard-core/templates/codebase/integrations.md +6 -6
- package/pan-wizard-core/templates/codebase/relationships.md +2 -2
- package/pan-wizard-core/templates/codebase/stack.md +2 -2
- package/pan-wizard-core/templates/codebase/structure.md +4 -4
- package/pan-wizard-core/templates/codebase/testing.md +4 -4
- package/pan-wizard-core/templates/continue-here.md +1 -1
- package/pan-wizard-core/templates/debug-subagent-prompt.md +3 -2
- package/pan-wizard-core/templates/debug.md +15 -1
- package/pan-wizard-core/templates/milestone-archive.md +6 -6
- package/pan-wizard-core/templates/milestone.md +1 -1
- package/pan-wizard-core/templates/phase-prompt.md +29 -17
- package/pan-wizard-core/templates/planner-subagent-prompt.md +3 -5
- package/pan-wizard-core/templates/research-project/architecture.md +3 -3
- package/pan-wizard-core/templates/research-project/features.md +1 -1
- package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
- package/pan-wizard-core/templates/research-project/stack.md +3 -3
- package/pan-wizard-core/templates/research.md +5 -5
- package/pan-wizard-core/templates/retrospective.md +1 -1
- package/pan-wizard-core/templates/roadmap.md +15 -15
- package/pan-wizard-core/templates/summary-complex.md +1 -0
- package/pan-wizard-core/templates/summary-minimal.md +1 -0
- package/pan-wizard-core/templates/summary-standard.md +2 -1
- package/pan-wizard-core/templates/summary.md +18 -2
- package/pan-wizard-core/templates/uat.md +3 -3
- package/pan-wizard-core/templates/user-setup.md +8 -8
- package/pan-wizard-core/templates/verification-report.md +53 -8
- package/pan-wizard-core/workflows/add-phase.md +1 -1
- package/pan-wizard-core/workflows/assumptions.md +1 -1
- package/pan-wizard-core/workflows/discuss-phase.md +9 -8
- package/pan-wizard-core/workflows/exec-phase.md +26 -57
- package/pan-wizard-core/workflows/execute-plan.md +8 -7
- package/pan-wizard-core/workflows/health.md +13 -12
- package/pan-wizard-core/workflows/help.md +8 -9
- package/pan-wizard-core/workflows/learn.md +13 -11
- package/pan-wizard-core/workflows/map-codebase.md +10 -10
- package/pan-wizard-core/workflows/milestone-audit.md +10 -10
- package/pan-wizard-core/workflows/milestone-gaps.md +17 -24
- package/pan-wizard-core/workflows/milestone-new.md +7 -6
- package/pan-wizard-core/workflows/new-project.md +9 -9
- package/pan-wizard-core/workflows/optimize.md +11 -9
- package/pan-wizard-core/workflows/pause.md +6 -1
- package/pan-wizard-core/workflows/phase-tests.md +4 -4
- package/pan-wizard-core/workflows/plan-phase.md +31 -34
- package/pan-wizard-core/workflows/profile.md +2 -2
- package/pan-wizard-core/workflows/progress.md +5 -7
- package/pan-wizard-core/workflows/quick.md +5 -3
- package/pan-wizard-core/workflows/remove-phase.md +1 -1
- package/pan-wizard-core/workflows/research-phase.md +9 -5
- package/pan-wizard-core/workflows/resume-project.md +4 -6
- package/pan-wizard-core/workflows/retro.md +3 -1
- package/pan-wizard-core/workflows/settings.md +16 -24
- package/pan-wizard-core/workflows/todo-check.md +1 -1
- package/pan-wizard-core/workflows/transition.md +12 -12
- package/pan-wizard-core/workflows/update.md +25 -17
- package/pan-wizard-core/workflows/verify-phase.md +17 -15
- package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
- package/pan-zcode/README.md +7 -7
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/scripts/build-plugin.js +3 -1
- package/scripts/generate-skills-docs.py +13 -4
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory —
|
|
2
|
+
* Memory — the agent memory store
|
|
3
3
|
*
|
|
4
4
|
* Each agent has an append-only memory log at `.planning/memory/<agent>.md`.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
77
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
294
|
+
contents = existing + (needsNl ? eol : '') + withEol(`- ${finalEntry}\n`, eol);
|
|
129
295
|
} else {
|
|
130
296
|
const needsNl = !existing.endsWith('\n');
|
|
131
|
-
contents = existing + (needsNl ?
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
260
|
-
*
|
|
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
|
-
|
|
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,
|
|
277
|
-
const scored =
|
|
278
|
-
text, i, tokens: estMemoryTokens(text),
|
|
279
|
-
score:
|
|
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
|
-
|
|
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
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
|
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
|
-
: `
|
|
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
|
-
`.
|
|
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
|
-
|
|
361
|
-
output(
|
|
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
|
-
|
|
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
|
};
|