pan-wizard 3.12.3 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +2 -1
  2. package/agents/pan-debugger.md +2 -2
  3. package/agents/pan-hardener.md +5 -2
  4. package/agents/pan-meta-reviewer.md +2 -1
  5. package/agents/pan-planner.md +16 -0
  6. package/agents/pan-reviewer.md +2 -1
  7. package/bin/install-lib.cjs +8 -0
  8. package/bin/install.js +3 -2
  9. package/commands/pan/audit-deployment.md +8 -8
  10. package/commands/pan/focus-auto.md +10 -6
  11. package/commands/pan/hygiene.md +69 -0
  12. package/commands/pan/milestone-done.md +3 -2
  13. package/hooks/dist/pan-cost-logger.js +54 -6
  14. package/hooks/dist/pan-trace-logger.js +41 -5
  15. package/package.json +1 -1
  16. package/pan-wizard-core/bin/lib/constants.cjs +40 -0
  17. package/pan-wizard-core/bin/lib/cost.cjs +26 -1
  18. package/pan-wizard-core/bin/lib/hud.cjs +14 -2
  19. package/pan-wizard-core/bin/lib/hygiene.cjs +447 -0
  20. package/pan-wizard-core/bin/lib/knowledge.cjs +28 -12
  21. package/pan-wizard-core/bin/lib/learn-index.cjs +17 -0
  22. package/pan-wizard-core/bin/lib/memory.cjs +146 -3
  23. package/pan-wizard-core/bin/lib/skill-align.cjs +364 -0
  24. package/pan-wizard-core/bin/lib/verify.cjs +10 -0
  25. package/pan-wizard-core/bin/pan-tools.cjs +47 -1
  26. package/pan-wizard-core/learnings/index.json +262 -10
  27. package/pan-wizard-core/learnings/internal/external-research.md +13 -1
  28. package/pan-wizard-core/learnings/universal/adversarial-verification.md +45 -0
  29. package/pan-wizard-core/learnings/universal/audit-convergence.md +33 -0
  30. package/pan-wizard-core/learnings/universal/autonomous-loop.md +4 -4
  31. package/pan-wizard-core/learnings/universal/external-tool-truth.md +21 -0
  32. package/pan-wizard-core/learnings/universal/fix-campaigns.md +45 -0
  33. package/pan-wizard-core/learnings/universal/flaky-triage.md +33 -0
  34. package/pan-wizard-core/learnings/universal/golden-sets.md +33 -0
  35. package/pan-wizard-core/learnings/universal/harness-isolation.md +21 -0
  36. package/pan-wizard-core/learnings/universal/integration-verification.md +33 -0
  37. package/pan-wizard-core/learnings/universal/live-path-honesty.md +45 -0
  38. package/pan-wizard-core/learnings/universal/mcp-security.md +21 -0
  39. package/pan-wizard-core/learnings/universal/migration-safety.md +21 -0
  40. package/pan-wizard-core/learnings/universal/service-security.md +21 -0
  41. package/pan-wizard-core/learnings/universal/single-source-of-truth.md +33 -0
  42. package/pan-wizard-core/learnings/universal/test-integrity.md +21 -0
  43. package/pan-wizard-core/learnings/universal/workaround-catalog.md +21 -0
  44. package/pan-wizard-core/references/model-profiles.md +23 -1
  45. package/pan-wizard-core/workflows/exec-phase.md +12 -3
  46. package/pan-wizard-core/workflows/plan-phase.md +1 -0
@@ -22,7 +22,7 @@
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
24
  const { output, error } = require('./core.cjs');
25
- const { PLANNING_DIR } = require('./constants.cjs');
25
+ const { PLANNING_DIR, 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
26
  const { planningPath } = require('./utils.cjs');
27
27
 
28
28
  const MEMORY_DIR = 'memory';
@@ -137,8 +137,18 @@ function appendMemory(cwd, agent, entry) {
137
137
  return { error: `Failed to write memory file: ${e.message}` };
138
138
  }
139
139
 
140
- const count = parseEntries(contents).length;
141
- return { appended: true, file, count };
140
+ let count = parseEntries(contents).length;
141
+ // Soft auto-compaction (ADR-0036): only above a HIGH soft cap (2× the manual
142
+ // cap) so it never silently drops entries a user expects to survive; trims to
143
+ // DEFAULT_MAX_ENTRIES and surfaces the result — never fully silent.
144
+ let auto_compacted;
145
+ if (count >= DEFAULT_MAX_ENTRIES * MEMORY_SOFT_CAP_MULT) {
146
+ const c = compactMemory(cwd, agent, DEFAULT_MAX_ENTRIES);
147
+ if (c && c.compacted) { auto_compacted = { kept: c.kept, removed: c.removed }; count = c.kept; }
148
+ }
149
+ return auto_compacted
150
+ ? { appended: true, file, count, auto_compacted }
151
+ : { appended: true, file, count };
142
152
  }
143
153
 
144
154
  function buildHeader(agent) {
@@ -211,6 +221,125 @@ function listMemoryAgents(cwd) {
211
221
  return { agents };
212
222
  }
213
223
 
224
+ // ─── Cue + recency scoped, token-budgeted read (ADR-0036 FW-2) ───────────────
225
+
226
+ /** Tokenize a cue into lowercase words of length >= 3. */
227
+ function cueTokens(cue) {
228
+ return (typeof cue === 'string' ? cue.toLowerCase() : '').match(/[a-z0-9]{3,}/g) || [];
229
+ }
230
+
231
+ /** Whole-word keyword-frequency score of an entry against cue tokens. */
232
+ function scoreEntry(entry, tokens) {
233
+ if (!tokens || !tokens.length) return 0;
234
+ const lc = entry.toLowerCase();
235
+ let s = 0;
236
+ for (const t of tokens) {
237
+ const re = new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
238
+ s += (lc.match(re) || []).length;
239
+ }
240
+ return s;
241
+ }
242
+
243
+ function estMemoryTokens(str) {
244
+ return Math.max(1, Math.ceil((str || '').length / CHARS_PER_TOKEN));
245
+ }
246
+
247
+ /**
248
+ * Select a cue-relevant, recency-floored, token-budgeted slice of an agent's
249
+ * 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.
251
+ *
252
+ * Always keeps the newest `recencyFloor` entries (recall never returns empty on
253
+ * a non-empty log); fills the remaining budget by cue relevance, falling back to
254
+ * recency-only when the cue is empty or matches nothing; greedily packs under
255
+ * `tokenBudget`. Output is in stored (chronological) order.
256
+ *
257
+ * @param {string} cwd
258
+ * @param {string} agent
259
+ * @param {{cue?: string, tokenBudget?: number, recencyFloor?: number}} [opts]
260
+ * @returns {{agent, cue, selected: string[], total_tokens, considered, dropped, mode}|{error}}
261
+ */
262
+ function selectMemory(cwd, agent, opts = {}) {
263
+ const err = validateAgentName(agent);
264
+ if (err) return { error: err };
265
+ const mem = readMemory(cwd, agent);
266
+ if (!mem || mem.entries.length === 0) {
267
+ return { agent, cue: opts.cue || '', selected: [], total_tokens: 0, considered: 0, dropped: 0, mode: 'empty' };
268
+ }
269
+ const all = mem.entries; // oldest -> newest
270
+ const bN = Number(opts.tokenBudget);
271
+ const budget = Number.isFinite(bN) && bN > 0 ? bN : MEMORY_SELECT_BUDGET_TOKENS;
272
+ const fN = Number(opts.recencyFloor);
273
+ const recencyFloor = Number.isFinite(fN) && fN >= 0 ? fN : MEMORY_RECENCY_FLOOR;
274
+ const tokens = cueTokens(opts.cue);
275
+
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),
280
+ }));
281
+ const anyCueHit = scored.some(e => Number.isFinite(e.score) && e.score > 0);
282
+ // Priority: recency-floor first (Infinity), then cue score, then newest.
283
+ scored.sort((a, b) => b.score - a.score || b.i - a.i);
284
+
285
+ const chosen = [];
286
+ let total = 0, dropped = 0;
287
+ for (const e of scored) {
288
+ if (total + e.tokens > budget) { dropped++; continue; }
289
+ chosen.push(e); total += e.tokens;
290
+ }
291
+ // Guarantee non-empty on a non-empty log even if budget < the smallest entry.
292
+ if (chosen.length === 0) {
293
+ const newest = scored.reduce((a, b) => (b.i > a.i ? b : a));
294
+ chosen.push(newest); total += newest.tokens; dropped = Math.max(0, dropped - 1);
295
+ }
296
+ 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 };
299
+ }
300
+
301
+ /**
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 input 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.
307
+ *
308
+ * @returns {{memory_tokens, agents, median_input_tokens, fraction, status, advisory}}
309
+ */
310
+ function memoryLoadBudget(cwd, opts = {}) {
311
+ const { agents } = listMemoryAgents(cwd);
312
+ let memoryTokens = 0;
313
+ for (const a of agents) {
314
+ const mem = readMemory(cwd, a.agent);
315
+ if (mem) memoryTokens += estMemoryTokens(mem.raw);
316
+ }
317
+ let median = null;
318
+ try {
319
+ const cost = require('./cost.cjs');
320
+ const inputs = (cost.readRecords(cwd) || [])
321
+ .filter(r => !cost.isSuspectRecord(r))
322
+ .map(r => Number(r.input_tokens) || 0)
323
+ .filter(n => n > 0)
324
+ .sort((a, b) => a - b);
325
+ if (inputs.length) median = inputs[Math.floor(inputs.length / 2)];
326
+ } catch { /* thin/absent ledger — absolute-token check only */ }
327
+
328
+ const fraction = median ? memoryTokens / median : null;
329
+ const warnT = opts.warnTokens || MEMORY_LOAD_WARN_TOKENS;
330
+ const critT = opts.critTokens || MEMORY_LOAD_CRIT_TOKENS;
331
+ const maxFrac = opts.maxFraction || MEMORY_LOAD_MAX_FRACTION;
332
+ let status = 'ok';
333
+ if (memoryTokens >= critT || (fraction != null && fraction >= maxFrac * 2)) status = 'critical';
334
+ else if (memoryTokens >= warnT || (fraction != null && fraction >= maxFrac)) status = 'warning';
335
+ const advisory = status === 'ok'
336
+ ? 'Memory-load within budget.'
337
+ : `Memory injection is ~${memoryTokens} tokens across ${agents.length} agent log(s)` +
338
+ (fraction != null ? ` (~${Math.round(fraction * 100)}% of median agent input)` : '') +
339
+ `. Bound it with cue-scoped 'memory select' or trim with 'memory compact <agent>'.`;
340
+ return { memory_tokens: memoryTokens, agents: agents.length, median_input_tokens: median, fraction, status, advisory };
341
+ }
342
+
214
343
  // ─── CLI command wrappers ────────────────────────────────────────────────────
215
344
 
216
345
  function cmdMemoryRead(cwd, agent, raw) {
@@ -236,17 +365,31 @@ function cmdMemoryCompact(cwd, agent, maxEntries, raw) {
236
365
  output(result, raw);
237
366
  }
238
367
 
368
+ function cmdMemorySelect(cwd, agent, opts, raw) {
369
+ if (!agent) { error('Usage: memory select <agent> [--cue <text>] [--token-budget N] [--recency-floor N]'); }
370
+ output(selectMemory(cwd, agent, opts || {}), raw);
371
+ }
372
+
373
+ function cmdMemoryBudget(cwd, raw) {
374
+ output(memoryLoadBudget(cwd), raw);
375
+ }
376
+
239
377
  module.exports = {
240
378
  readMemory,
241
379
  appendMemory,
242
380
  compactMemory,
243
381
  listMemoryAgents,
382
+ selectMemory,
383
+ memoryLoadBudget,
384
+ scoreEntry,
244
385
  parseEntries,
245
386
  validateAgentName,
246
387
  cmdMemoryRead,
247
388
  cmdMemoryAppend,
248
389
  cmdMemoryList,
249
390
  cmdMemoryCompact,
391
+ cmdMemorySelect,
392
+ cmdMemoryBudget,
250
393
  MEMORY_DIR,
251
394
  DEFAULT_MAX_ENTRIES,
252
395
  };
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Skill-Align — Skill-Aligned Decomposition (SAD) pass for planning
3
+ * (ADR-0038, spec: docs/specs/skill-aligned-decomposition.md).
4
+ *
5
+ * SkillWeaver's finding (arXiv 2606.18051): one-shot task decomposition
6
+ * misaligns with the tool/skill library that actually exists; feeding
7
+ * retrieved skill candidates back to the decomposer to realign vocabulary
8
+ * and granularity lifted decomposition accuracy 51% → 92%.
9
+ *
10
+ * PAN's adoption is deliberately minimal and advisory:
11
+ * - index built on the fly (~140 small files; no persisted index, no
12
+ * staleness, no installer changes)
13
+ * - keyword scoring via knowledge.cjs scoreRelevance (no embeddings,
14
+ * no vector store — ADR-0036 guardrail)
15
+ * - output is names + one-line descriptions, budget-bounded, with
16
+ * explicit `dropped` reporting (no silent caps)
17
+ * - fail-open: missing roots are skipped, never thrown — the planner
18
+ * proceeds as today if anything is absent
19
+ *
20
+ * The "rewrite the decomposition" half of the loop stays in the
21
+ * pan-planner agent (it is already an LLM); this module only retrieves.
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { output, error, safeReadFile, toPosix } = require('./core.cjs');
27
+ const {
28
+ CHARS_PER_TOKEN,
29
+ SKILL_ALIGN_TOP_K,
30
+ SKILL_ALIGN_MIN_SCORE,
31
+ SKILL_ALIGN_VOCAB_BUDGET_TOKENS,
32
+ SKILL_ALIGN_MAX_TASKS,
33
+ SKILL_ALIGN_CONTENT_CAP,
34
+ } = require('./constants.cjs');
35
+ const { scoreRelevance } = require('./knowledge.cjs');
36
+ const { readIndex } = require('./learn-index.cjs');
37
+
38
+ /**
39
+ * Planning glue words stripped from task cues before scoring. Without this,
40
+ * "Create the API" matches every skill file containing "create". Nouns that
41
+ * carry skill signal (test, phase, plan, commit, ...) are deliberately kept.
42
+ */
43
+ const SAD_STOPWORDS = new Set([
44
+ 'create', 'add', 'implement', 'update', 'write', 'make', 'build',
45
+ 'setup', 'set', 'ensure', 'use', 'using', 'new', 'the', 'and', 'for',
46
+ 'with', 'this', 'that', 'from', 'into', 'each', 'all', 'should', 'must',
47
+ 'task', 'tasks', 'file', 'files',
48
+ ]);
49
+
50
+ /**
51
+ * Skill roots walked by buildSkillIndex, relative to the resolved root.
52
+ * The same relative layout holds in the source repo and in every install
53
+ * (root = the directory containing pan-wizard-core/).
54
+ */
55
+ const SKILL_ROOTS = [
56
+ { kind: 'command', rel: path.join('commands', 'pan'), recursive: false },
57
+ { kind: 'template', rel: path.join('pan-wizard-core', 'templates'), recursive: true },
58
+ { kind: 'reference', rel: path.join('pan-wizard-core', 'references'), recursive: false },
59
+ ];
60
+
61
+ /**
62
+ * Default skill root: three levels up from lib/ — the install root
63
+ * (~/.claude/) or the source repo root. Mirrors experiment.cjs
64
+ * PAN_SOURCE_ROOT; both layouts keep commands/ and pan-wizard-core/
65
+ * side by side.
66
+ */
67
+ function resolveSkillRoot() {
68
+ return path.resolve(__dirname, '..', '..', '..');
69
+ }
70
+
71
+ /**
72
+ * Frontmatter is only needed for two scalar keys; a targeted line scan
73
+ * avoids importing the full YAML parser for files that may not have
74
+ * frontmatter at all.
75
+ */
76
+ function readNameDescription(content) {
77
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
78
+ if (!m) return {};
79
+ const out = {};
80
+ for (const line of m[1].split(/\r?\n/)) {
81
+ const kv = line.match(/^(name|description):\s*(.+)$/);
82
+ if (kv && !(kv[1] in out)) out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** First markdown heading, or first non-empty non-delimiter line. */
88
+ function firstHeading(content) {
89
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '');
90
+ for (const line of body.split(/\r?\n/)) {
91
+ const t = line.trim();
92
+ if (!t || t === '---' || /^<\/?[\w-]+>$/.test(t)) continue;
93
+ const h = t.match(/^#+\s+(.+)$/);
94
+ return (h ? h[1] : t).slice(0, 120);
95
+ }
96
+ return '';
97
+ }
98
+
99
+ function listMdFiles(dir, recursive) {
100
+ const files = [];
101
+ let entries = [];
102
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
103
+ for (const e of entries) {
104
+ const abs = path.join(dir, e.name);
105
+ if (e.isDirectory()) {
106
+ if (recursive) files.push(...listMdFiles(abs, true));
107
+ } else if (e.name.endsWith('.md')) {
108
+ files.push(abs);
109
+ }
110
+ }
111
+ return files;
112
+ }
113
+
114
+ /**
115
+ * Build the skill index from the filesystem. Never throws: roots or
116
+ * learnings that are missing/unreadable are skipped and reported in
117
+ * stats.skipped_roots.
118
+ *
119
+ * @param {string} root - install root or source repo root
120
+ * @returns {{entries: Array, stats: {entries: number, by_kind: Object, skipped_roots: string[]}}}
121
+ */
122
+ function buildSkillIndex(root) {
123
+ const entries = [];
124
+ const skipped = [];
125
+
126
+ for (const sr of SKILL_ROOTS) {
127
+ const dir = path.join(root, sr.rel);
128
+ const files = listMdFiles(dir, sr.recursive);
129
+ if (files.length === 0) { skipped.push(toPosix(sr.rel)); continue; }
130
+ for (const abs of files) {
131
+ const content = safeReadFile(abs);
132
+ if (!content) continue;
133
+ const fm = readNameDescription(content);
134
+ const relFile = toPosix(path.relative(root, abs));
135
+ const name = fm.name
136
+ || toPosix(path.relative(path.join(root, sr.rel), abs)).replace(/\.md$/, '');
137
+ const description = fm.description || firstHeading(content);
138
+ entries.push({
139
+ kind: sr.kind,
140
+ name,
141
+ description,
142
+ file: relFile,
143
+ tokens_est: Math.ceil(content.length / CHARS_PER_TOKEN),
144
+ // scoring head, not serialized in CLI output
145
+ _head: `${name} ${description} ${content.slice(0, SKILL_ALIGN_CONTENT_CAP)}`,
146
+ });
147
+ }
148
+ }
149
+
150
+ // Learnings topics via the existing index (built on the fly if index.json
151
+ // is absent). A root with no learnings yields zero topics, not an error.
152
+ let topics = [];
153
+ try { topics = readIndex(root).topics || []; } catch { topics = []; }
154
+ if (topics.length === 0) skipped.push(toPosix(path.join('pan-wizard-core', 'learnings')));
155
+ for (const t of topics) {
156
+ const content = safeReadFile(path.join(root, t.file)) || '';
157
+ const name = `${t.scope}/${t.name}`;
158
+ const ids = (t.patterns || []).join(', ');
159
+ const description = content
160
+ ? `${firstHeading(content)}${ids ? ` (${ids})` : ''}`
161
+ : ids;
162
+ entries.push({
163
+ kind: 'learning',
164
+ name,
165
+ description,
166
+ file: toPosix(t.file),
167
+ tokens_est: t.size_tokens_est || Math.ceil(content.length / CHARS_PER_TOKEN),
168
+ _head: `${name} ${description} ${content.slice(0, SKILL_ALIGN_CONTENT_CAP)}`,
169
+ });
170
+ }
171
+
172
+ const byKind = {};
173
+ for (const e of entries) byKind[e.kind] = (byKind[e.kind] || 0) + 1;
174
+ return {
175
+ entries,
176
+ stats: { entries: entries.length, by_kind: byKind, skipped_roots: skipped },
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Parse a draft task blob into task strings. Accepts markdown bullets,
182
+ * numbered lists, checkboxes, or plain lines; drops headings and blanks.
183
+ */
184
+ function parseDraftTasks(text) {
185
+ if (typeof text !== 'string') return [];
186
+ const tasks = [];
187
+ for (const line of text.split(/\r?\n/)) {
188
+ let t = line.trim();
189
+ if (!t || /^#{1,6}\s/.test(t) || t === '---') continue;
190
+ t = t.replace(/^(?:[-*+]|\d+[.)])\s+/, '').replace(/^\[[ xX]\]\s+/, '').trim();
191
+ if (t.length < 3) continue;
192
+ tasks.push(t);
193
+ }
194
+ return tasks;
195
+ }
196
+
197
+ /** Strip planning glue words so cues carry skill signal only. */
198
+ function cleanCue(task) {
199
+ return task
200
+ .split(/\W+/)
201
+ .filter(w => w.length >= 3 && !SAD_STOPWORDS.has(w.toLowerCase()))
202
+ .join(' ');
203
+ }
204
+
205
+ /**
206
+ * The SAD retrieval pass: score each draft task against the skill index,
207
+ * return per-task top-k matches plus a deduped, budget-packed vocabulary
208
+ * hint list for the planner to realign against.
209
+ *
210
+ * @param {string} root - skill root (resolveSkillRoot() for callers without an override)
211
+ * @param {string[]} tasks - draft task strings
212
+ * @param {Object} [opts] - {topK, minScore, tokenBudget}
213
+ * @returns {Object} result per docs/specs/skill-aligned-decomposition.md §3.3
214
+ */
215
+ function alignTasks(root, tasks, opts) {
216
+ if (!Array.isArray(tasks) || tasks.length === 0) {
217
+ return { error: 'no tasks to align — provide a non-empty draft task list' };
218
+ }
219
+ if (tasks.length > SKILL_ALIGN_MAX_TASKS) {
220
+ return { error: `draft has ${tasks.length} tasks — max ${SKILL_ALIGN_MAX_TASKS}; a draft this large is a planning smell, split the phase` };
221
+ }
222
+ const topK = Math.max(1, Math.min(10, Number(opts?.topK) || SKILL_ALIGN_TOP_K));
223
+ const minScore = Math.max(1, Number(opts?.minScore) || SKILL_ALIGN_MIN_SCORE);
224
+ const budget = Math.max(100, Number(opts?.tokenBudget) || SKILL_ALIGN_VOCAB_BUDGET_TOKENS);
225
+
226
+ const { entries, stats } = buildSkillIndex(root);
227
+
228
+ const perTask = [];
229
+ const aggregate = new Map(); // kind/name -> {entry, totalScore}
230
+ for (const task of tasks) {
231
+ const cue = cleanCue(String(task));
232
+ const scored = [];
233
+ if (cue) {
234
+ for (const e of entries) {
235
+ const score = scoreRelevance(cue, e._head);
236
+ if (score >= minScore) scored.push({ e, score });
237
+ }
238
+ }
239
+ scored.sort((a, b) => b.score - a.score || a.e.name.localeCompare(b.e.name));
240
+ const top = scored.slice(0, topK);
241
+ for (const { e, score } of top) {
242
+ const key = `${e.kind}/${e.name}`;
243
+ const agg = aggregate.get(key) || { entry: e, totalScore: 0 };
244
+ agg.totalScore += score;
245
+ aggregate.set(key, agg);
246
+ }
247
+ perTask.push({
248
+ task: String(task),
249
+ matches: top.map(({ e, score }) => ({ kind: e.kind, name: e.name, file: e.file, score })),
250
+ matched: top.length > 0,
251
+ });
252
+ }
253
+
254
+ // Vocabulary: deduped union of all matches, ranked by aggregate score,
255
+ // greedy-packed into the token budget. Overflow is reported, not hidden.
256
+ const ranked = [...aggregate.values()]
257
+ .sort((a, b) => b.totalScore - a.totalScore || a.entry.name.localeCompare(b.entry.name));
258
+ const vocabulary = [];
259
+ const dropped = [];
260
+ let vocabTokens = 0;
261
+ for (const { entry } of ranked) {
262
+ const tokens = Math.ceil((entry.name.length + entry.description.length) / CHARS_PER_TOKEN);
263
+ if (vocabTokens + tokens > budget) {
264
+ dropped.push({ kind: entry.kind, name: entry.name, tokens });
265
+ continue;
266
+ }
267
+ vocabulary.push({
268
+ kind: entry.kind,
269
+ name: entry.name,
270
+ description: entry.description,
271
+ file: entry.file,
272
+ tokens,
273
+ });
274
+ vocabTokens += tokens;
275
+ }
276
+
277
+ const matchedCount = perTask.filter(t => t.matched).length;
278
+ return {
279
+ tasks: perTask,
280
+ coverage: {
281
+ matched: matchedCount,
282
+ total: perTask.length,
283
+ ratio: perTask.length ? Math.round((matchedCount / perTask.length) * 100) / 100 : 0,
284
+ },
285
+ vocabulary,
286
+ vocabulary_tokens: vocabTokens,
287
+ dropped,
288
+ index_stats: stats,
289
+ top_k: topK,
290
+ min_score: minScore,
291
+ token_budget: budget,
292
+ };
293
+ }
294
+
295
+ // ─── CLI wrappers ───────────────────────────────────────────────────────────
296
+
297
+ function cmdSkillsIndex(root, raw) {
298
+ const { entries, stats } = buildSkillIndex(root);
299
+ const result = {
300
+ entries: entries.map(({ _head, ...rest }) => rest),
301
+ total: stats.entries,
302
+ by_kind: stats.by_kind,
303
+ skipped_roots: stats.skipped_roots,
304
+ };
305
+ if (raw) {
306
+ const lines = [`Skill index (${stats.entries} entries):`, ''];
307
+ for (const e of result.entries) {
308
+ lines.push(` [${e.kind.padEnd(9)}] ${e.name.padEnd(36)} ${String(e.tokens_est).padStart(5)}t ${e.description}`);
309
+ }
310
+ if (stats.skipped_roots.length > 0) {
311
+ lines.push('', `Skipped roots (missing/empty): ${stats.skipped_roots.join(', ')}`);
312
+ }
313
+ output(result, true, lines.join('\n'));
314
+ } else {
315
+ output(result, false);
316
+ }
317
+ }
318
+
319
+ function cmdSkillsAlign(root, opts, raw) {
320
+ let draft = opts?.draft;
321
+ if (!draft && opts?.draftFile) {
322
+ draft = safeReadFile(opts.draftFile);
323
+ if (draft === null) {
324
+ output({ error: `draft file not found or unreadable: ${opts.draftFile}` }, raw);
325
+ return;
326
+ }
327
+ }
328
+ if (!draft || !draft.trim()) {
329
+ error('Usage: skills align (--draft "<text>" | --draft-file <path>) [--top <k>] [--min-score <n>] [--token-budget <n>] [--source-root <path>]');
330
+ }
331
+ const tasks = parseDraftTasks(draft);
332
+ const result = alignTasks(root, tasks, opts);
333
+ if (result.error) { output(result, raw); return; }
334
+ if (raw) {
335
+ const lines = [`SAD alignment: ${result.coverage.matched}/${result.coverage.total} tasks matched (${result.index_stats.entries} skills indexed)`, ''];
336
+ for (const t of result.tasks) {
337
+ lines.push(` ${t.matched ? '✓' : '✗'} ${t.task}`);
338
+ for (const m of t.matches) {
339
+ lines.push(` [${m.kind}] ${m.name} (score ${m.score})`);
340
+ }
341
+ }
342
+ lines.push('', `Vocabulary hints (${result.vocabulary.length} skills, ${result.vocabulary_tokens}t of ${result.token_budget}t budget):`);
343
+ for (const v of result.vocabulary) {
344
+ lines.push(` [${v.kind.padEnd(9)}] ${v.name} — ${v.description}`);
345
+ }
346
+ if (result.dropped.length > 0) {
347
+ lines.push(`Dropped (over budget): ${result.dropped.map(d => d.name).join(', ')}`);
348
+ }
349
+ output(result, true, lines.join('\n'));
350
+ } else {
351
+ output(result, false);
352
+ }
353
+ }
354
+
355
+ module.exports = {
356
+ resolveSkillRoot,
357
+ buildSkillIndex,
358
+ parseDraftTasks,
359
+ alignTasks,
360
+ cmdSkillsIndex,
361
+ cmdSkillsAlign,
362
+ SAD_STOPWORDS,
363
+ SKILL_ROOTS,
364
+ };
@@ -1186,6 +1186,7 @@ function cmdValidateHealth(cwd, options, raw) {
1186
1186
  // Check 10 (optional): full validation — run tests and build
1187
1187
  let testStatus;
1188
1188
  let buildStatus;
1189
+ let memoryBudget;
1189
1190
  if (options.full) {
1190
1191
  testStatus = runFullTestCheck(cwd);
1191
1192
  buildStatus = runFullBuildCheck(cwd);
@@ -1195,6 +1196,14 @@ function cmdValidateHealth(cwd, options, raw) {
1195
1196
  if (buildStatus.pass === false) {
1196
1197
  addIssue('error', 'BUILD_FAIL', `Build failed (exit code ${buildStatus.exitCode})`, 'Fix build errors');
1197
1198
  }
1199
+ // Memory-load budget (ADR-0036 acceptance signal): keep per-agent memory
1200
+ // injection bounded as logs grow. Read-only, non-blocking.
1201
+ memoryBudget = require('./memory.cjs').memoryLoadBudget(cwd);
1202
+ if (memoryBudget.status === 'critical') {
1203
+ addIssue('warning', 'MEM_BUDGET', memoryBudget.advisory, "Run 'pan-tools memory compact <agent>' or scope injection with 'memory select'");
1204
+ } else if (memoryBudget.status === 'warning') {
1205
+ addIssue('info', 'MEM_BUDGET', memoryBudget.advisory, "Run 'pan-tools memory compact <agent>' or scope injection with 'memory select'");
1206
+ }
1198
1207
  }
1199
1208
 
1200
1209
  // Determine overall status from error/warning counts
@@ -1252,6 +1261,7 @@ function cmdValidateHealth(cwd, options, raw) {
1252
1261
  if (options.full) {
1253
1262
  result.test_status = testStatus;
1254
1263
  result.build_status = buildStatus;
1264
+ result.memory_budget = memoryBudget;
1255
1265
  }
1256
1266
  if (options.drift) {
1257
1267
  result.drift_status = driftResult;
@@ -205,6 +205,8 @@ const cost = require('./lib/cost.cjs');
205
205
  const preview = require('./lib/preview.cjs');
206
206
  const reviewDeep = require('./lib/review-deep.cjs');
207
207
  const knowledge = require('./lib/knowledge.cjs');
208
+ const skillAlign = require('./lib/skill-align.cjs');
209
+ const hygiene = require('./lib/hygiene.cjs');
208
210
  const whatif = require('./lib/whatif.cjs');
209
211
  const bridge = require('./lib/bridge.cjs');
210
212
  const optimize = require('./lib/optimize.cjs');
@@ -910,8 +912,16 @@ async function main() {
910
912
  memory.cmdMemoryList(cwd, raw);
911
913
  } else if (subcommand === 'compact') {
912
914
  memory.cmdMemoryCompact(cwd, args[2], args[3], raw);
915
+ } else if (subcommand === 'select') {
916
+ memory.cmdMemorySelect(cwd, args[2], {
917
+ cue: getArgValue(args, '--cue'),
918
+ tokenBudget: getArgValue(args, '--token-budget'),
919
+ recencyFloor: getArgValue(args, '--recency-floor'),
920
+ }, raw);
921
+ } else if (subcommand === 'budget') {
922
+ memory.cmdMemoryBudget(cwd, raw);
913
923
  } else {
914
- error('Unknown memory subcommand. Available: read, append, list, compact');
924
+ error('Unknown memory subcommand. Available: read, append, list, compact, select, budget');
915
925
  }
916
926
  break;
917
927
  }
@@ -970,6 +980,7 @@ async function main() {
970
980
  const maxSources = getArgValue(args, '--max-sources');
971
981
  knowledge.cmdKnowledgeAsk(cwd, question, {
972
982
  max_sources: maxSources ? Number(maxSources) : undefined,
983
+ recall_cue: getArgValue(args, '--recall-cue'),
973
984
  }, raw);
974
985
  } else if (subcommand === 'discuss') {
975
986
  const phaseNum = args[2];
@@ -989,6 +1000,41 @@ async function main() {
989
1000
  break;
990
1001
  }
991
1002
 
1003
+ case 'skills': {
1004
+ const subcommand = args[1];
1005
+ const skillRoot = getArgValue(args, '--source-root') || skillAlign.resolveSkillRoot();
1006
+ if (subcommand === 'index') {
1007
+ skillAlign.cmdSkillsIndex(skillRoot, raw);
1008
+ } else if (subcommand === 'align') {
1009
+ skillAlign.cmdSkillsAlign(skillRoot, {
1010
+ draft: getArgValue(args, '--draft'),
1011
+ draftFile: getArgValue(args, '--draft-file'),
1012
+ topK: getArgValue(args, '--top'),
1013
+ minScore: getArgValue(args, '--min-score'),
1014
+ tokenBudget: getArgValue(args, '--token-budget'),
1015
+ }, raw);
1016
+ } else {
1017
+ error('Unknown skills subcommand. Available: index, align');
1018
+ }
1019
+ break;
1020
+ }
1021
+
1022
+ case 'hygiene': {
1023
+ const subcommand = args[1];
1024
+ const hygieneOpts = {
1025
+ traceAgeDays: getArgValue(args, '--trace-age-days'),
1026
+ apply: args.includes('--apply'),
1027
+ };
1028
+ if (subcommand === 'scan') {
1029
+ hygiene.cmdHygieneScan(cwd, hygieneOpts, raw);
1030
+ } else if (subcommand === 'clean') {
1031
+ hygiene.cmdHygieneClean(cwd, hygieneOpts, raw);
1032
+ } else {
1033
+ error('Unknown hygiene subcommand. Available: scan, clean [--apply] [--trace-age-days N]');
1034
+ }
1035
+ break;
1036
+ }
1037
+
992
1038
  case 'review-deep': {
993
1039
  const subcommand = args[1];
994
1040
  const phaseNum = args[2];