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.
- package/README.md +2 -1
- package/agents/pan-debugger.md +2 -2
- package/agents/pan-hardener.md +5 -2
- package/agents/pan-meta-reviewer.md +2 -1
- package/agents/pan-planner.md +16 -0
- package/agents/pan-reviewer.md +2 -1
- package/bin/install-lib.cjs +8 -0
- package/bin/install.js +3 -2
- package/commands/pan/audit-deployment.md +8 -8
- package/commands/pan/focus-auto.md +10 -6
- package/commands/pan/hygiene.md +69 -0
- package/commands/pan/milestone-done.md +3 -2
- package/hooks/dist/pan-cost-logger.js +54 -6
- package/hooks/dist/pan-trace-logger.js +41 -5
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/constants.cjs +40 -0
- package/pan-wizard-core/bin/lib/cost.cjs +26 -1
- package/pan-wizard-core/bin/lib/hud.cjs +14 -2
- package/pan-wizard-core/bin/lib/hygiene.cjs +447 -0
- package/pan-wizard-core/bin/lib/knowledge.cjs +28 -12
- package/pan-wizard-core/bin/lib/learn-index.cjs +17 -0
- package/pan-wizard-core/bin/lib/memory.cjs +146 -3
- package/pan-wizard-core/bin/lib/skill-align.cjs +364 -0
- package/pan-wizard-core/bin/lib/verify.cjs +10 -0
- package/pan-wizard-core/bin/pan-tools.cjs +47 -1
- package/pan-wizard-core/learnings/index.json +262 -10
- package/pan-wizard-core/learnings/internal/external-research.md +13 -1
- package/pan-wizard-core/learnings/universal/adversarial-verification.md +45 -0
- package/pan-wizard-core/learnings/universal/audit-convergence.md +33 -0
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +4 -4
- package/pan-wizard-core/learnings/universal/external-tool-truth.md +21 -0
- package/pan-wizard-core/learnings/universal/fix-campaigns.md +45 -0
- package/pan-wizard-core/learnings/universal/flaky-triage.md +33 -0
- package/pan-wizard-core/learnings/universal/golden-sets.md +33 -0
- package/pan-wizard-core/learnings/universal/harness-isolation.md +21 -0
- package/pan-wizard-core/learnings/universal/integration-verification.md +33 -0
- package/pan-wizard-core/learnings/universal/live-path-honesty.md +45 -0
- package/pan-wizard-core/learnings/universal/mcp-security.md +21 -0
- package/pan-wizard-core/learnings/universal/migration-safety.md +21 -0
- package/pan-wizard-core/learnings/universal/service-security.md +21 -0
- package/pan-wizard-core/learnings/universal/single-source-of-truth.md +33 -0
- package/pan-wizard-core/learnings/universal/test-integrity.md +21 -0
- package/pan-wizard-core/learnings/universal/workaround-catalog.md +21 -0
- package/pan-wizard-core/references/model-profiles.md +23 -1
- package/pan-wizard-core/workflows/exec-phase.md +12 -3
- 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
|
-
|
|
141
|
-
|
|
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];
|