pan-wizard 3.12.5 → 3.14.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 +5 -5
- package/agents/pan-hardener.md +1 -0
- package/agents/pan-meta-reviewer.md +1 -0
- package/agents/pan-planner.md +16 -0
- package/agents/pan-reviewer.md +1 -0
- package/bin/install-lib.cjs +8 -0
- 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-context-monitor.js +24 -12
- package/hooks/dist/pan-statusline.js +7 -1
- package/package.json +5 -5
- package/pan-wizard-core/bin/lib/config.cjs +14 -1
- package/pan-wizard-core/bin/lib/constants.cjs +40 -0
- package/pan-wizard-core/bin/lib/core.cjs +6 -2
- package/pan-wizard-core/bin/lib/doc-lint.cjs +86 -1
- package/pan-wizard-core/bin/lib/focus.cjs +48 -2
- package/pan-wizard-core/bin/lib/hud.cjs +19 -3
- package/pan-wizard-core/bin/lib/hygiene.cjs +447 -0
- package/pan-wizard-core/bin/lib/knowledge.cjs +30 -14
- 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/optimize.cjs +2 -2
- package/pan-wizard-core/bin/lib/phase-remove.cjs +1 -1
- package/pan-wizard-core/bin/lib/phase.cjs +4 -4
- package/pan-wizard-core/bin/lib/review-deep.cjs +3 -1
- package/pan-wizard-core/bin/lib/skill-align.cjs +364 -0
- package/pan-wizard-core/bin/lib/verify.cjs +182 -61
- package/pan-wizard-core/bin/pan-tools.cjs +57 -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 +4 -1
- package/pan-wizard-core/workflows/exec-phase.md +26 -3
- package/pan-wizard-core/workflows/plan-phase.md +1 -0
- package/scripts/release-check.js +29 -14
- package/scripts/run-tests.cjs +44 -0
|
@@ -69,6 +69,23 @@ const RELEVANCE = {
|
|
|
69
69
|
'test-patterns': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
|
|
70
70
|
'test-strategy': { planner: 'high', executor: 'medium', verifier: 'high', reviewer: 'high' },
|
|
71
71
|
|
|
72
|
+
// Field-harvest 2026-07 (docs/FIELD-HARVEST-2026-07.md)
|
|
73
|
+
'live-path-honesty': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
|
|
74
|
+
'test-integrity': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
|
|
75
|
+
'adversarial-verification': { planner: 'medium', executor: 'low', verifier: 'high', reviewer: 'high' },
|
|
76
|
+
'integration-verification': { planner: 'high', executor: 'low', verifier: 'high', reviewer: 'medium' },
|
|
77
|
+
'single-source-of-truth': { planner: 'high', executor: 'high', verifier: 'medium', reviewer: 'high' },
|
|
78
|
+
'migration-safety': { planner: 'high', executor: 'high', verifier: 'medium', reviewer: 'high' },
|
|
79
|
+
'flaky-triage': { planner: 'low', executor: 'medium', verifier: 'high', reviewer: 'medium' },
|
|
80
|
+
'external-tool-truth': { planner: 'low', executor: 'high', verifier: 'high', reviewer: 'low' },
|
|
81
|
+
'golden-sets': { planner: 'high', executor: 'medium', verifier: 'high', reviewer: 'medium' },
|
|
82
|
+
'harness-isolation': { planner: 'high', executor: 'medium', verifier: 'low', reviewer: 'low' },
|
|
83
|
+
'workaround-catalog': { planner: 'low', executor: 'high', verifier: 'medium', reviewer: 'low' },
|
|
84
|
+
'service-security': { planner: 'medium', executor: 'high', verifier: 'high', reviewer: 'high' },
|
|
85
|
+
'mcp-security': { planner: 'medium', executor: 'medium', verifier: 'medium', reviewer: 'high' },
|
|
86
|
+
'audit-convergence': { planner: 'high', executor: 'low', verifier: 'high', reviewer: 'high' },
|
|
87
|
+
'fix-campaigns': { planner: 'high', executor: 'medium', verifier: 'high', reviewer: 'high' },
|
|
88
|
+
|
|
72
89
|
// PAN-internal (loaded only by PAN's own dev sessions)
|
|
73
90
|
'experiment-runner': { planner: 'low', executor: 'low', verifier: 'low', reviewer: 'low' },
|
|
74
91
|
'external-research': { planner: 'medium', executor: 'low', verifier: 'low', reviewer: 'medium' },
|
|
@@ -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
|
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { output } = require('./core.cjs');
|
|
11
|
+
const { output, escapeRegex } = require('./core.cjs');
|
|
12
12
|
const { PLANNING_DIR } = require('./constants.cjs');
|
|
13
13
|
|
|
14
14
|
// ─── Storage layout ──────────────────────────────────────────────────────────
|
|
@@ -1052,7 +1052,7 @@ function unpromotePattern(patternId, opts) {
|
|
|
1052
1052
|
// Strip the pattern's body section. Pattern body is a `## P-<id> — ...` heading
|
|
1053
1053
|
// followed by content until the next `## ` or end-of-file.
|
|
1054
1054
|
const headingRe = new RegExp(
|
|
1055
|
-
`\\n## ${patternId
|
|
1055
|
+
`\\n## ${escapeRegex(patternId)}\\b[^\\n]*[\\s\\S]*?(?=\\n## |$)`,
|
|
1056
1056
|
''
|
|
1057
1057
|
);
|
|
1058
1058
|
const newBody = parsed.body.replace(headingRe, '');
|
|
@@ -47,7 +47,7 @@ function renumberDecimalPhases(phasesDir, baseInt, removedDecimal) {
|
|
|
47
47
|
const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((left, right) => comparePhaseNum(left, right));
|
|
48
48
|
|
|
49
49
|
// Find sibling decimals with higher numbers than the removed one
|
|
50
|
-
const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
|
|
50
|
+
const decPattern = new RegExp(`^${escapeRegex(String(baseInt))}\\.(\\d+)-(.+)$`);
|
|
51
51
|
const toRename = [];
|
|
52
52
|
for (const dir of dirs) {
|
|
53
53
|
const decMatch = dir.match(decPattern);
|
|
@@ -133,7 +133,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
|
133
133
|
const baseExists = dirs.some(dir => dir.startsWith(normalized + '-') || dir === normalized);
|
|
134
134
|
|
|
135
135
|
// Find existing decimal phases for this base
|
|
136
|
-
const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
|
|
136
|
+
const decimalPattern = new RegExp(`^${escapeRegex(normalized)}\\.(\\d+)`);
|
|
137
137
|
const existingDecimals = [];
|
|
138
138
|
|
|
139
139
|
for (const dir of dirs) {
|
|
@@ -444,7 +444,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
444
444
|
// Normalize input then strip leading zeros for flexible matching
|
|
445
445
|
const normalizedAfter = normalizePhaseName(afterPhase);
|
|
446
446
|
const unpadded = normalizedAfter.replace(/^0+/, '');
|
|
447
|
-
const afterPhaseEscaped = unpadded
|
|
447
|
+
const afterPhaseEscaped = escapeRegex(unpadded);
|
|
448
448
|
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
|
449
449
|
if (!targetPattern.test(content)) {
|
|
450
450
|
error(`Phase ${afterPhase} not found in roadmap.md`);
|
|
@@ -458,7 +458,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
458
458
|
try {
|
|
459
459
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
460
460
|
const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name);
|
|
461
|
-
const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
|
|
461
|
+
const decimalPattern = new RegExp(`^${escapeRegex(normalizedBase)}\\.(\\d+)`);
|
|
462
462
|
for (const dir of dirs) {
|
|
463
463
|
const decMatch = dir.match(decimalPattern);
|
|
464
464
|
if (decMatch) existingDecimals.push(parseInt(decMatch[1], 10));
|
|
@@ -484,7 +484,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
484
484
|
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /pan:plan-phase ${decimalPhase} to break down)\n`;
|
|
485
485
|
|
|
486
486
|
// Insert after the target phase section
|
|
487
|
-
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
|
|
487
|
+
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); // afterPhaseEscaped already run through escapeRegex above
|
|
488
488
|
const headerMatch = content.match(headerPattern);
|
|
489
489
|
if (!headerMatch) {
|
|
490
490
|
error(`Could not find Phase ${afterPhase} header`);
|
|
@@ -198,7 +198,9 @@ function writeDeepReview(cwd, phaseNum, payload, opts) {
|
|
|
198
198
|
lines.push('|----------|--------|----------|-------------|------|');
|
|
199
199
|
for (const f of payload.findings) {
|
|
200
200
|
const loc = f.file ? `\`${f.file}${f.line ? `:${f.line}` : ''}\`` : '—';
|
|
201
|
-
|
|
201
|
+
// Neutralize markdown-table-breaking chars: escape pipes and flatten any
|
|
202
|
+
// newlines so a finding description can't corrupt the table structure.
|
|
203
|
+
const desc = String(f.description).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
|
|
202
204
|
lines.push(`| ${f.severity} | ${f.source} | ${f.category} | ${desc} | ${loc} |`);
|
|
203
205
|
}
|
|
204
206
|
lines.push('');
|
|
@@ -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
|
+
};
|