pan-wizard 3.34.0 → 3.36.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 +16 -10
- package/agents/pan-conductor.md +2 -2
- package/agents/pan-debugger.md +3 -1
- package/agents/pan-design-checker.md +1 -0
- package/agents/pan-designer.md +1 -1
- package/agents/pan-executor.md +43 -1
- package/agents/pan-experiment-runner.md +1 -1
- package/agents/pan-hardener.md +3 -1
- package/agents/pan-meta-reviewer.md +1 -1
- package/agents/pan-phase-researcher.md +16 -2
- package/agents/pan-plan-checker.md +37 -9
- package/agents/pan-planner.md +14 -2
- package/agents/pan-previewer.md +2 -0
- package/agents/pan-project-researcher.md +9 -1
- package/agents/pan-research-synthesizer.md +1 -1
- package/agents/pan-reviewer.md +8 -2
- package/agents/pan-verifier.md +3 -3
- package/bin/install-lib.cjs +119 -16
- package/bin/install.js +34 -62
- package/commands/pan/cost.md +21 -5
- package/commands/pan/dashboard.md +0 -3
- package/commands/pan/design-phase.md +2 -2
- package/commands/pan/hud.md +1 -5
- package/commands/pan/knowledge.md +0 -1
- package/commands/pan/mcp-bridge.md +1 -3
- package/commands/pan/optimize.md +2 -0
- package/commands/pan/phase-tests.md +1 -1
- package/commands/pan/preview.md +2 -3
- package/commands/pan/report.md +1 -5
- package/commands/pan/research-phase.md +4 -4
- package/commands/pan/review-deep.md +0 -2
- package/commands/pan/what-if.md +1 -2
- package/hooks/dist/pan-context-monitor.js +12 -3
- package/hooks/dist/pan-guard.js +244 -0
- package/hooks/dist/pan-statusline.js +5 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +24 -2
- package/pan-wizard-core/bin/lib/config.cjs +13 -1
- package/pan-wizard-core/bin/lib/core.cjs +9 -4
- package/pan-wizard-core/bin/lib/cost.cjs +146 -0
- package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
- package/pan-wizard-core/bin/lib/findings.cjs +239 -23
- package/pan-wizard-core/bin/lib/git.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +11 -5
- package/pan-wizard-core/bin/lib/init.cjs +6 -0
- package/pan-wizard-core/bin/lib/mutate.cjs +257 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
- package/pan-wizard-core/bin/lib/phase-gate.cjs +180 -0
- package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
- package/pan-wizard-core/bin/lib/phase.cjs +61 -9
- package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
- package/pan-wizard-core/bin/lib/preview.cjs +35 -31
- package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
- package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
- package/pan-wizard-core/bin/lib/usage.cjs +104 -0
- package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
- package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
- package/pan-wizard-core/bin/lib/verify-red.cjs +138 -0
- package/pan-wizard-core/bin/lib/verify-scope.cjs +148 -6
- package/pan-wizard-core/bin/lib/verify.cjs +27 -27
- package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
- package/pan-wizard-core/bin/pan-tools.cjs +106 -9
- package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
- package/pan-wizard-core/mcp/native-tools.cjs +3 -3
- package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
- package/pan-wizard-core/opencode/pan-wizard.js +36 -8
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/handoff-decisions.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +1 -1
- package/pan-wizard-core/references/planning-config.md +3 -3
- package/pan-wizard-core/references/verdict-contract.md +6 -1
- package/pan-wizard-core/templates/config.json +6 -6
- package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
- package/pan-wizard-core/templates/design.md +5 -4
- package/pan-wizard-core/templates/phase-prompt.md +9 -0
- package/pan-wizard-core/templates/planner-subagent-prompt.md +2 -0
- package/pan-wizard-core/templates/preview-report.md +1 -1
- package/pan-wizard-core/templates/research-project/architecture.md +6 -0
- package/pan-wizard-core/templates/research-project/features.md +6 -0
- package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
- package/pan-wizard-core/templates/research-project/stack.md +6 -0
- package/pan-wizard-core/templates/research.md +10 -0
- package/pan-wizard-core/templates/state.md +4 -4
- package/pan-wizard-core/templates/summary-complex.md +5 -1
- package/pan-wizard-core/templates/summary-minimal.md +4 -0
- package/pan-wizard-core/templates/summary-standard.md +5 -1
- package/pan-wizard-core/templates/summary.md +8 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
- package/pan-wizard-core/workflows/discuss-phase.md +2 -1
- package/pan-wizard-core/workflows/exec-phase.md +92 -45
- package/pan-wizard-core/workflows/execute-plan.md +3 -1
- package/pan-wizard-core/workflows/help.md +103 -44
- package/pan-wizard-core/workflows/milestone-gaps.md +2 -2
- package/pan-wizard-core/workflows/new-project.md +8 -16
- package/pan-wizard-core/workflows/plan-phase.md +40 -8
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/quick.md +2 -2
- package/pan-wizard-core/workflows/remove-phase.md +11 -1
- package/pan-wizard-core/workflows/settings.md +17 -0
- package/pan-wizard-core/workflows/todo-check.md +3 -2
- package/pan-wizard-core/workflows/transition.md +4 -0
- package/pan-wizard-core/workflows/verify-phase.md +40 -47
- package/scripts/build-help.cjs +106 -0
- package/scripts/build-hooks.js +1 -0
- package/scripts/generate-skills-docs.py +2 -2
- package/scripts/mutation-probe.cjs +7 -80
- package/scripts/prompt-lint.cjs +167 -0
- package/scripts/test-surface.cjs +169 -6
|
@@ -51,86 +51,13 @@ const TARGETS = [
|
|
|
51
51
|
{ file: 'pan-wizard-core/bin/pan-tools.cjs', tests: ['tests/dispatcher.test.cjs', 'tests/dispatcher-arms.test.cjs'] },
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
{ id: 'gte→gt', find: ' >= ', replace: ' > ' },
|
|
62
|
-
{ id: 'gt→gte', find: ' > ', replace: ' >= ' },
|
|
63
|
-
{ id: 'lte→lt', find: ' <= ', replace: ' < ' },
|
|
64
|
-
{ id: 'lt→lte', find: ' < ', replace: ' <= ' },
|
|
65
|
-
{ id: 'eq→neq', find: ' === ', replace: ' !== ' },
|
|
66
|
-
{ id: 'neq→eq', find: ' !== ', replace: ' === ' },
|
|
67
|
-
{ id: 'and→or', find: ' && ', replace: ' || ' },
|
|
68
|
-
{ id: 'or→and', find: ' || ', replace: ' && ' },
|
|
69
|
-
{ id: 'true→false', find: 'return true', replace: 'return false' },
|
|
70
|
-
{ id: 'false→true', find: 'return false', replace: 'return true' },
|
|
71
|
-
{ id: 'plus→minus', find: ' + 1', replace: ' - 1' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Is this line worth mutating? Skips comments, and skips a line whose only occurrence of
|
|
76
|
-
* the token is inside a string literal — mutating a message changes nothing a test should
|
|
77
|
-
* be pinning, and a survivor there would be noise.
|
|
78
|
-
*
|
|
79
|
-
* Crude but conservative: a line containing a quote is only mutated when the token also
|
|
80
|
-
* occurs before the first quote.
|
|
81
|
-
*/
|
|
82
|
-
function mutableLine(line, token) {
|
|
83
|
-
const trimmed = line.trim();
|
|
84
|
-
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return false;
|
|
85
|
-
const at = line.indexOf(token);
|
|
86
|
-
if (at === -1) return false;
|
|
87
|
-
const firstQuote = Math.min(
|
|
88
|
-
...["'", '"', '`'].map((q) => { const i = line.indexOf(q); return i === -1 ? Infinity : i; }),
|
|
89
|
-
);
|
|
90
|
-
return at < firstQuote;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Every mutation available in a source file: one per (line, operator) pair, first
|
|
95
|
-
* occurrence on the line.
|
|
96
|
-
*
|
|
97
|
-
* @returns {Array<{line: number, op: string, before: string, after: string}>}
|
|
98
|
-
*/
|
|
99
|
-
function mutationsFor(src) {
|
|
100
|
-
const lines = src.split(/\r?\n/);
|
|
101
|
-
const out = [];
|
|
102
|
-
lines.forEach((line, i) => {
|
|
103
|
-
for (const op of OPERATORS) {
|
|
104
|
-
if (!mutableLine(line, op.find)) continue;
|
|
105
|
-
out.push({ line: i + 1, op: op.id, before: line, after: line.replace(op.find, op.replace) });
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
return out;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Apply one mutation to a source string, by line number. Throws if the line moved. */
|
|
112
|
-
function applyMutation(src, mutation) {
|
|
113
|
-
const eol = src.includes('\r\n') ? '\r\n' : '\n';
|
|
114
|
-
const lines = src.split(/\r?\n/);
|
|
115
|
-
const idx = mutation.line - 1;
|
|
116
|
-
if (lines[idx] !== mutation.before) {
|
|
117
|
-
throw new Error(`line ${mutation.line} is not what the mutation was built from`);
|
|
118
|
-
}
|
|
119
|
-
lines[idx] = mutation.after;
|
|
120
|
-
return lines.join(eol);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Deterministic shuffle, so a run is reproducible from its seed. */
|
|
124
|
-
function sample(items, n, seed) {
|
|
125
|
-
let s = seed >>> 0;
|
|
126
|
-
const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0x100000000; };
|
|
127
|
-
const copy = items.slice();
|
|
128
|
-
for (let i = copy.length - 1; i > 0; i--) {
|
|
129
|
-
const j = Math.floor(rand() * (i + 1));
|
|
130
|
-
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
131
|
-
}
|
|
132
|
-
return copy.slice(0, n);
|
|
133
|
-
}
|
|
54
|
+
// The operators, the line filter, mutation and the seeded sample live in the shipped
|
|
55
|
+
// mutation report (pan-wizard-core/bin/lib/mutate.cjs, program V5), which a user's phase
|
|
56
|
+
// diff is measured with; this probe applies the JavaScript table to PAN itself.
|
|
57
|
+
const M = require('../pan-wizard-core/bin/lib/mutate.cjs');
|
|
58
|
+
const OPERATORS = M.OPERATORS.js;
|
|
59
|
+
const { mutableLine, applyMutation, sample } = M;
|
|
60
|
+
const mutationsFor = (src) => M.mutationsFor(src, { family: 'js' });
|
|
134
61
|
|
|
135
62
|
function parseArgs(argv) {
|
|
136
63
|
const a = { max: 20, seed: 1, json: false, targets: [], timeoutMs: 600000, help: false };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* Prompt-contract lint (program L2): the contract a shipped prompt has with the
|
|
5
|
+
* engine and the host, checked in code. Applied to commands/pan, agents and
|
|
6
|
+
* pan-wizard-core/workflows by tests/prompt-lint.test.cjs, with
|
|
7
|
+
* tests/fixtures/prompt-lint-allowlist.json (every entry names its reason, usually
|
|
8
|
+
* the plan item that fixes it).
|
|
9
|
+
*
|
|
10
|
+
* unknown-verb a `pan-tools <verb> [<sub>]` call naming a verb or a subcommand
|
|
11
|
+
* the engine does not have (tests/fixtures/surface.json)
|
|
12
|
+
* tool-not-granted an agent told to use a tool its frontmatter `tools:` does not grant
|
|
13
|
+
* pasted-source an @-include of engine source (`*.cjs` / `*.js`) into a prompt
|
|
14
|
+
* oversized-question an AskUserQuestion block offering more than four options (the
|
|
15
|
+
* host's limit: two to four)
|
|
16
|
+
* untyped-spawn a Task( spawn without `model=`, or a general-purpose spawn told
|
|
17
|
+
* to read a role file instead of naming the agent type
|
|
18
|
+
* dev-skill a shipped prompt sending the user to a dev-only skill of this
|
|
19
|
+
* repository (.claude/commands/*.md), which no install carries
|
|
20
|
+
*
|
|
21
|
+
* node scripts/prompt-lint.cjs # report the current tree, exit 1 on a finding
|
|
22
|
+
*/
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const ROOT = path.join(__dirname, '..');
|
|
27
|
+
const TOOLS = ['Write', 'Edit', 'WebSearch', 'WebFetch', 'Task'];
|
|
28
|
+
|
|
29
|
+
function surface(root = ROOT) {
|
|
30
|
+
const s = JSON.parse(fs.readFileSync(path.join(root, 'tests', 'fixtures', 'surface.json'), 'utf8'));
|
|
31
|
+
const subs = new Map();
|
|
32
|
+
for (const entry of s.subcommands) {
|
|
33
|
+
const [verb, sub] = entry.split(' ');
|
|
34
|
+
if (!subs.has(verb)) subs.set(verb, new Set());
|
|
35
|
+
subs.get(verb).add(sub);
|
|
36
|
+
}
|
|
37
|
+
return { verbs: new Set(s.verbs), subs };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function devSkills(root = ROOT) {
|
|
41
|
+
try {
|
|
42
|
+
return fs.readdirSync(path.join(root, '.claude', 'commands')).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
|
|
43
|
+
} catch { return []; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
47
|
+
|
|
48
|
+
function frontmatterTools(text) {
|
|
49
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
50
|
+
if (!fm) return null;
|
|
51
|
+
const m = fm[1].match(/^tools:[ \t]*(.+)$/m);
|
|
52
|
+
return m ? new Set(m[1].split(',').map((t) => t.trim().replace(/\*$/, '')).filter(Boolean)) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lint one file's text.
|
|
57
|
+
* @param {string} rel - repository-relative path (decides which rules apply)
|
|
58
|
+
* @param {string} raw - file content
|
|
59
|
+
* @param {{verbs: Set, subs: Map, dev: string[]}} ctx
|
|
60
|
+
* @returns {Array<{rule, file, line, text}>}
|
|
61
|
+
*/
|
|
62
|
+
function lintText(rel, raw, ctx) {
|
|
63
|
+
const text = raw.replace(/\r\n/g, '\n');
|
|
64
|
+
const out = [];
|
|
65
|
+
const hit = (rule, index, snippet) => out.push({ rule, file: rel, line: lineOf(text, index), text: String(snippet).trim().slice(0, 140) });
|
|
66
|
+
|
|
67
|
+
// unknown-verb
|
|
68
|
+
// An invocation, not prose: the script path (`pan-tools.cjs <verb>`) or a backticked `pan-tools <verb>`.
|
|
69
|
+
const callRe = /(?:pan-tools\.cjs"?|`pan-tools)[ \t]+([a-z][a-z0-9-]*)(?:[ \t]+([a-z][a-z0-9-]*))?/g;
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = callRe.exec(text))) {
|
|
72
|
+
const [, verb, sub] = m;
|
|
73
|
+
if (!ctx.verbs.has(verb)) { hit('unknown-verb', m.index, m[0]); continue; }
|
|
74
|
+
const known = ctx.subs.get(verb);
|
|
75
|
+
if (known && sub && !known.has(sub) && !ctx.verbs.has(sub)) hit('unknown-verb', m.index, m[0]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// pasted-source
|
|
79
|
+
const incRe = /^@\S+\.(?:cjs|mjs|js)\s*$/gm;
|
|
80
|
+
while ((m = incRe.exec(text))) hit('pasted-source', m.index, m[0]);
|
|
81
|
+
|
|
82
|
+
// tool-not-granted (agents only)
|
|
83
|
+
if (rel.startsWith('agents/')) {
|
|
84
|
+
const granted = frontmatterTools(text);
|
|
85
|
+
if (granted) {
|
|
86
|
+
for (const tool of TOOLS) {
|
|
87
|
+
if (granted.has(tool)) continue;
|
|
88
|
+
const re = new RegExp(`\\b(?:use|using|with|via|call|run)\\s+(?:the\\s+)?\`?${tool}\`?(?:\\s+tool)?\\b|\\bthe\\s+\`?${tool}\`?\\s+tool\\b`, 'g');
|
|
89
|
+
let mm;
|
|
90
|
+
while ((mm = re.exec(text))) {
|
|
91
|
+
// "Never use Edit or Write tools" is the rule being kept, not broken.
|
|
92
|
+
const before = text.slice(Math.max(0, mm.index - 24), mm.index).toLowerCase();
|
|
93
|
+
if (/\b(never|not|don't|do not|no)\s*$/.test(before)) continue;
|
|
94
|
+
hit('tool-not-granted', mm.index, `${tool} (granted: ${[...granted].join(', ')}): ${mm[0]}`);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// oversized-question
|
|
102
|
+
const askRe = /AskUserQuestion/g;
|
|
103
|
+
while ((m = askRe.exec(text))) {
|
|
104
|
+
const window = text.slice(m.index, m.index + 6000);
|
|
105
|
+
const optRe = /options\s*:\s*\[/g;
|
|
106
|
+
let o;
|
|
107
|
+
while ((o = optRe.exec(window))) {
|
|
108
|
+
let depth = 0;
|
|
109
|
+
let end = -1;
|
|
110
|
+
for (let i = o.index + o[0].length - 1; i < window.length; i++) {
|
|
111
|
+
if (window[i] === '[') depth++;
|
|
112
|
+
else if (window[i] === ']') { depth--; if (depth === 0) { end = i; break; } }
|
|
113
|
+
}
|
|
114
|
+
if (end < 0) break;
|
|
115
|
+
const labels = (window.slice(o.index, end).match(/\blabel\s*:/g) || []).length;
|
|
116
|
+
if (labels > 4) hit('oversized-question', m.index + o.index, `${labels} options in one question`);
|
|
117
|
+
optRe.lastIndex = end;
|
|
118
|
+
}
|
|
119
|
+
askRe.lastIndex = m.index + 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// untyped-spawn (workflows and commands)
|
|
123
|
+
if (!rel.startsWith('agents/')) {
|
|
124
|
+
const lines = text.split('\n');
|
|
125
|
+
lines.forEach((l, i) => {
|
|
126
|
+
if (!/^\s*Task\(/.test(l)) return;
|
|
127
|
+
let j = i;
|
|
128
|
+
while (j < lines.length - 1 && !/^\s*\)\s*$/.test(lines[j]) && !/description="[^"]*"\s*\)\s*$/.test(lines[j])) j++;
|
|
129
|
+
const block = lines.slice(i, j + 1).join('\n');
|
|
130
|
+
const at = lines.slice(0, i).join('\n').length + 1;
|
|
131
|
+
if (!/\bmodel\s*=/.test(block)) hit('untyped-spawn', at, 'a Task( spawn without model=');
|
|
132
|
+
else if (/subagent_type="general-purpose"/.test(block) && /agents\/pan-[a-z-]+\.md for your role/.test(block)) hit('untyped-spawn', at, 'general-purpose spawn told to read a role file');
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// dev-skill
|
|
137
|
+
if (ctx.dev.length) {
|
|
138
|
+
// Every regex metacharacter escaped, backslash included (inline: CodeQL does not follow a helper).
|
|
139
|
+
const names = ctx.dev.map((d) => d.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&'));
|
|
140
|
+
const devRe = new RegExp(`(^|[\\s\`'"(])/(${names.join('|')})(?=[\\s\`'".,;:)]|$)`, 'gm');
|
|
141
|
+
while ((m = devRe.exec(text))) hit('dev-skill', m.index + m[1].length, m[0]);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The shipped prompt files, repository-relative. */
|
|
147
|
+
function shippedPrompts(root = ROOT) {
|
|
148
|
+
const list = [];
|
|
149
|
+
for (const dir of ['commands/pan', 'agents', 'pan-wizard-core/workflows']) {
|
|
150
|
+
for (const f of fs.readdirSync(path.join(root, dir)).filter((x) => x.endsWith('.md')).sort()) list.push(`${dir}/${f}`);
|
|
151
|
+
}
|
|
152
|
+
return list;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function lintTree(root = ROOT) {
|
|
156
|
+
const ctx = { ...surface(root), dev: devSkills(root) };
|
|
157
|
+
return shippedPrompts(root).flatMap((rel) => lintText(rel, fs.readFileSync(path.join(root, rel), 'utf8'), ctx));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (require.main === module) {
|
|
161
|
+
const findings = lintTree();
|
|
162
|
+
for (const f of findings) process.stdout.write(`${f.rule} ${f.file}:${f.line} — ${f.text}\n`);
|
|
163
|
+
process.stdout.write(`${findings.length} finding(s)\n`);
|
|
164
|
+
process.exitCode = findings.length ? 1 : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { lintText, lintTree, shippedPrompts, surface, devSkills, frontmatterTools };
|
package/scripts/test-surface.cjs
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* Modes:
|
|
20
20
|
* node scripts/test-surface.cjs summary
|
|
21
21
|
* node scripts/test-surface.cjs --write write tests/fixtures/surface.json (the committed registry)
|
|
22
|
-
* node scripts/test-surface.cjs --check exit 1 when the committed registry differs from the code
|
|
22
|
+
* node scripts/test-surface.cjs --check exit 1 when the committed registry differs from the code, or
|
|
23
|
+
* a command, agent, verb or config key has no user (liveness, L1)
|
|
24
|
+
* node scripts/test-surface.cjs --liveness who uses each command, agent, verb, subcommand and config key
|
|
23
25
|
* node scripts/test-surface.cjs --map which test files reference each surface row; lists the misses
|
|
24
26
|
* node scripts/test-surface.cjs --scaffold <dir> one todo stub per unreferenced row (for a suite rebuilt from scratch)
|
|
25
27
|
*
|
|
@@ -65,6 +67,7 @@ const EXTRA_HOOK_ROWS = Object.freeze([
|
|
|
65
67
|
]);
|
|
66
68
|
const EVENT_HOOKS = Object.freeze({
|
|
67
69
|
sessionStart: ['pan-check-update.js'],
|
|
70
|
+
preToolUse: ['pan-guard.js'],
|
|
68
71
|
postToolUse: ['pan-context-monitor.js'],
|
|
69
72
|
subagentStop: ['pan-cost-logger.js', 'pan-trace-logger.js'],
|
|
70
73
|
stop: ['pan-stop-guard.js'],
|
|
@@ -296,6 +299,153 @@ function scaffold(missingRows, outDir) {
|
|
|
296
299
|
|
|
297
300
|
function readJson(p) { return JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
298
301
|
|
|
302
|
+
// ─── Liveness (program L1) ──────────────────────────────────────────────────
|
|
303
|
+
//
|
|
304
|
+
// The map above asks "does a test name it?". Liveness asks "does anything USE it?":
|
|
305
|
+
// - every shipped command and agent is named by a harness scenario, so some behavioural
|
|
306
|
+
// run exercises it as a user would;
|
|
307
|
+
// - every pan-tools verb and subcommand has a caller in what PAN ships: a prompt
|
|
308
|
+
// (commands, agents, workflows, references, templates), a native workflow's prompt
|
|
309
|
+
// (bin/install-lib.cjs), or an MCP tool or resource (the bridge registry);
|
|
310
|
+
// - every config default has a reader: code under the core, hooks, installer or MCP
|
|
311
|
+
// bridge that reads the key, or a shipped prompt that runs `config-get <key>`.
|
|
312
|
+
// An orphan either gains a user or sits in tests/fixtures/liveness-allowlist.json with a
|
|
313
|
+
// reason; `--check` fails on an orphan without one, and on an entry no longer needed.
|
|
314
|
+
|
|
315
|
+
const LIVENESS_ALLOWLIST_REL = path.join('tests', 'fixtures', 'liveness-allowlist.json');
|
|
316
|
+
const PROMPT_DIRS = Object.freeze(['commands/pan', 'agents', 'pan-wizard-core/workflows', 'pan-wizard-core/references', 'pan-wizard-core/templates']);
|
|
317
|
+
const NATIVE_WORKFLOW_SOURCES = Object.freeze(['bin/install-lib.cjs']);
|
|
318
|
+
const READER_DIRS = Object.freeze(['pan-wizard-core/bin', 'pan-wizard-core/mcp', 'pan-wizard-core/opencode', 'hooks', 'bin']);
|
|
319
|
+
const SCENARIO_DIR = 'harness/scenarios';
|
|
320
|
+
|
|
321
|
+
/** Every file under rel (recursively) whose name matches re, as posix paths; skips dist/ and node_modules/. */
|
|
322
|
+
function walkFiles(root, rel, re) {
|
|
323
|
+
const out = [];
|
|
324
|
+
const walk = (r) => {
|
|
325
|
+
let ents;
|
|
326
|
+
try { ents = fs.readdirSync(path.join(root, r), { withFileTypes: true }); } catch { return; }
|
|
327
|
+
for (const e of ents) {
|
|
328
|
+
const p = path.posix.join(r, e.name);
|
|
329
|
+
if (e.isDirectory()) { if (e.name !== 'dist' && e.name !== 'node_modules') walk(p); } else if (re.test(e.name)) out.push(p);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
walk(rel);
|
|
333
|
+
return out.sort();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The verbs and `verb sub` pairs a text invokes: the script path (`pan-tools.cjs <verb>`),
|
|
338
|
+
* a backticked `pan-tools <verb>`, or `pan-tools <verb>` as a shell command (opening a
|
|
339
|
+
* line, or after `|`, `;`, `&` or `(`), then the verb and an optional subcommand. Prose
|
|
340
|
+
* that mentions pan-tools is not a call.
|
|
341
|
+
*/
|
|
342
|
+
function pantoolsCalls(text) {
|
|
343
|
+
const out = new Set();
|
|
344
|
+
const re = /(?:pan-tools\.cjs["']?|`pan-tools|(?:^|[|;&(])[ \t]*pan-tools)[ \t]+([a-z][a-z0-9-]*)(?:[ \t]+([a-z][a-z0-9-]*))?/gm;
|
|
345
|
+
let m;
|
|
346
|
+
while ((m = re.exec(text))) { out.add(m[1]); if (m[2]) out.add(`${m[1]} ${m[2]}`); }
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The text a liveness check reads, from the tree. Tests pass a hand-built object instead. */
|
|
351
|
+
function loadLivenessInputs(root = ROOT) {
|
|
352
|
+
const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
|
|
353
|
+
const prompts = PROMPT_DIRS.flatMap((d) => walkFiles(root, d, /\.(md|js)$/)).map((file) => ({ file, src: read(file) }));
|
|
354
|
+
const native = NATIVE_WORKFLOW_SOURCES.filter((f) => fs.existsSync(path.join(root, f))).map((file) => ({ file, src: read(file) }));
|
|
355
|
+
const registry = require(path.join(root, SOURCES.mcpRegistry));
|
|
356
|
+
const mcp = [...(registry.TOOLS || []), ...(registry.RESOURCES || [])].map((t) => ({ name: t.name || t.uri, verb: t.verb, sub: (t.args && t.args[0]) || null }));
|
|
357
|
+
const scenarios = walkFiles(root, SCENARIO_DIR, /\.json$/).map((file) => ({ file, src: read(file) }));
|
|
358
|
+
const configSrc = path.posix.normalize(SOURCES.config);
|
|
359
|
+
const readers = READER_DIRS.flatMap((d) => walkFiles(root, d, /\.(c?js|mjs)$/)).map((file) => ({ file, src: read(file), isConfig: file === configSrc }));
|
|
360
|
+
return { prompts, native, mcp, scenarios, readers };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The body of buildConfigDefaults in config.cjs: where defaults are written, which is not a read. */
|
|
364
|
+
function stripDefaultsBuilder(src) {
|
|
365
|
+
const start = src.indexOf('function buildConfigDefaults');
|
|
366
|
+
if (start === -1) return src;
|
|
367
|
+
const open = src.indexOf('{', start);
|
|
368
|
+
let depth = 0;
|
|
369
|
+
for (let i = open; i < src.length; i++) {
|
|
370
|
+
if (src[i] === '{') depth++;
|
|
371
|
+
else if (src[i] === '}' && --depth === 0) return src.slice(0, start) + src.slice(i + 1);
|
|
372
|
+
}
|
|
373
|
+
return src;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Who uses each piece of the surface. Pure over `surface` (extractSurface) and `inputs`
|
|
378
|
+
* (loadLivenessInputs). Returns one row per command, agent, verb, subcommand and config
|
|
379
|
+
* key: `{id, kind, users: string[]}`; a row with no users is an orphan.
|
|
380
|
+
*/
|
|
381
|
+
function liveness(surface, inputs) {
|
|
382
|
+
const rows = [];
|
|
383
|
+
const scen = inputs.scenarios;
|
|
384
|
+
const named = (re) => scen.filter((s) => re.test(s.src)).map((s) => s.file);
|
|
385
|
+
for (const f of surface.content['commands/pan'] || []) {
|
|
386
|
+
const name = f.replace(/\.md$/, '');
|
|
387
|
+
// /pan:<name> (Claude, Gemini), /pan-<name> or pan-<name> as a skill (Codex, Copilot, OpenCode), or the file itself.
|
|
388
|
+
rows.push({ id: `command:${name}`, kind: 'command', users: named(new RegExp(`(?:/pan[:-]|commands/pan/|\\bpan-)${esc(name)}(?![a-z0-9-])`)) });
|
|
389
|
+
}
|
|
390
|
+
for (const f of surface.content.agents || []) {
|
|
391
|
+
const name = f.replace(/\.md$/, '');
|
|
392
|
+
rows.push({ id: `agent:${name}`, kind: 'agent', users: named(new RegExp(`(?<![a-z0-9-])${esc(name)}(?![a-z0-9-])`)) });
|
|
393
|
+
}
|
|
394
|
+
const callers = new Map();
|
|
395
|
+
const add = (key, who) => { if (!callers.has(key)) callers.set(key, new Set()); callers.get(key).add(who); };
|
|
396
|
+
for (const t of [...inputs.prompts, ...inputs.native]) for (const c of pantoolsCalls(t.src)) add(c, t.file);
|
|
397
|
+
// A registry entry with no verb (one served by the bridge itself) calls nothing.
|
|
398
|
+
for (const t of inputs.mcp.filter((x) => x.verb)) { add(t.verb, `mcp:${t.name}`); if (t.sub) add(`${t.verb} ${t.sub}`, `mcp:${t.name}`); }
|
|
399
|
+
for (const v of surface.verbs) {
|
|
400
|
+
const direct = callers.get(v) || new Set();
|
|
401
|
+
// A verb is alive when it is called, bare or through any of its subcommands.
|
|
402
|
+
const viaSub = [...callers.entries()].filter(([k]) => k.startsWith(`${v} `)).flatMap(([, s]) => [...s]);
|
|
403
|
+
rows.push({ id: `caller:verb:${v}`, kind: 'verb', users: [...new Set([...direct, ...viaSub])].sort() });
|
|
404
|
+
}
|
|
405
|
+
for (const s of surface.subcommands) rows.push({ id: `caller:sub:${s}`, kind: 'sub', users: [...(callers.get(s) || [])].sort() });
|
|
406
|
+
const readerText = inputs.readers.map((r) => ({ file: r.file, src: r.isConfig ? stripDefaultsBuilder(r.src) : r.src }));
|
|
407
|
+
const promptText = [...inputs.prompts, ...inputs.native];
|
|
408
|
+
for (const key of surface.config_keys) {
|
|
409
|
+
const last = esc(key.split('.').pop());
|
|
410
|
+
const codeRe = new RegExp(`['"\`]${esc(key)}['"\`]|\\.${last}\\b|\\[['"]${last}['"]\\]|['"]${last}['"]`);
|
|
411
|
+
// A prompt reads a key with `config-get <key>`, or names the full dotted key in backticks
|
|
412
|
+
// for the agent to check in the config or the init payload (`workflow.x: true`).
|
|
413
|
+
const promptRe = new RegExp(`config-get[ \\t]+["']?${esc(key)}\\b|\`${esc(key)}[\`:= ]`);
|
|
414
|
+
const users = [...readerText.filter((r) => codeRe.test(r.src)).map((r) => r.file), ...promptText.filter((p) => promptRe.test(p.src)).map((p) => p.file)];
|
|
415
|
+
rows.push({ id: `reader:config:${key}`, kind: 'config', users: [...new Set(users)].sort() });
|
|
416
|
+
}
|
|
417
|
+
return rows;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Orphans not allowlisted, and allowlist entries that are wrong (no reason, no such row, or
|
|
422
|
+
* a row that has users now). `--check` fails when either list is non-empty.
|
|
423
|
+
*/
|
|
424
|
+
function livenessProblems(rows, allowlist) {
|
|
425
|
+
const allow = new Map(allowlist.map((a) => [a.id, a]));
|
|
426
|
+
const problems = [];
|
|
427
|
+
for (const a of allowlist) {
|
|
428
|
+
if (!a.reason || !String(a.reason).trim()) problems.push(`liveness allowlist entry ${a.id} has no reason`);
|
|
429
|
+
const row = rows.find((r) => r.id === a.id);
|
|
430
|
+
if (!row) problems.push(`liveness allowlist entry ${a.id} names nothing that exists — remove it`);
|
|
431
|
+
else if (row.users.length) problems.push(`liveness allowlist entry ${a.id} has a user now (${row.users[0]}) — remove the entry`);
|
|
432
|
+
}
|
|
433
|
+
const hint = { command: 'name it in a harness scenario', agent: 'name it in a harness scenario', verb: 'call it from a shipped prompt', sub: 'call it from a shipped prompt', config: 'read it in code, or drop the default' };
|
|
434
|
+
for (const r of rows) if (!r.users.length && !allow.has(r.id)) problems.push(`orphan ${r.id} — ${hint[r.kind]}, or allowlist it in ${LIVENESS_ALLOWLIST_REL} with a reason`);
|
|
435
|
+
return problems;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** What `--check` decides: registry drift plus liveness problems. Pure. */
|
|
439
|
+
function checkReport(committed, surface, inputs, allowlist) {
|
|
440
|
+
const drift = diffSurface(committed, surface);
|
|
441
|
+
const live = livenessProblems(liveness(surface, inputs), allowlist);
|
|
442
|
+
return { drift, live, ok: !drift.added.length && !drift.removed.length && !live.length };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function readLivenessAllowlist(root = ROOT) {
|
|
446
|
+
try { return readJson(path.join(root, LIVENESS_ALLOWLIST_REL)); } catch { return []; }
|
|
447
|
+
}
|
|
448
|
+
|
|
299
449
|
// ─── CLI ────────────────────────────────────────────────────────────────────
|
|
300
450
|
|
|
301
451
|
function main(argv) {
|
|
@@ -309,13 +459,25 @@ function main(argv) {
|
|
|
309
459
|
if (argv.includes('--check')) {
|
|
310
460
|
let committed;
|
|
311
461
|
try { committed = readJson(registryPath); } catch { console.error(`no committed registry at ${REGISTRY_REL} — run --write`); return 1; }
|
|
312
|
-
const d =
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
462
|
+
const { drift: d, live, ok } = checkReport(committed, surface, loadLivenessInputs(ROOT), readLivenessAllowlist(ROOT));
|
|
463
|
+
if (ok) { console.log('surface registry matches the code; every command, agent, verb and config key is alive'); return 0; }
|
|
464
|
+
if (d.added.length || d.removed.length) {
|
|
465
|
+
console.error(`surface registry is stale — run \`node scripts/test-surface.cjs --write\` and commit it`);
|
|
466
|
+
for (const x of d.added) console.error(` + ${x}`);
|
|
467
|
+
for (const x of d.removed) console.error(` - ${x}`);
|
|
468
|
+
}
|
|
469
|
+
if (live.length) {
|
|
470
|
+
console.error('liveness: orphans without a user or an allowlist reason');
|
|
471
|
+
for (const x of live) console.error(` ${x}`);
|
|
472
|
+
}
|
|
317
473
|
return 1;
|
|
318
474
|
}
|
|
475
|
+
if (argv.includes('--liveness')) {
|
|
476
|
+
const rows = liveness(surface, loadLivenessInputs(ROOT));
|
|
477
|
+
for (const r of rows) console.log(`${r.users.length ? 'ok ' : 'ORPHAN'} ${r.id.padEnd(48)} ${r.users.slice(0, 2).join(', ')}${r.users.length > 2 ? ` +${r.users.length - 2}` : ''}`);
|
|
478
|
+
console.log(`liveness rows: ${rows.length} · orphans: ${rows.filter((r) => !r.users.length).length}`);
|
|
479
|
+
return 0;
|
|
480
|
+
}
|
|
319
481
|
const rows = mapSurface(surfaceRows(surface), loadTestSources(ROOT));
|
|
320
482
|
const missing = rows.filter((r) => !r.hits.length);
|
|
321
483
|
if (argv.includes('--scaffold')) {
|
|
@@ -338,4 +500,5 @@ module.exports = {
|
|
|
338
500
|
ROOT, REGISTRY_REL, ALLOWLIST_REL, SOURCES, EXTRA_HOOK_ROWS,
|
|
339
501
|
parseTopLevelCommands, parseGroupSubcommands, parseCaseArms, parseInstallerFlags, flattenKeys, hookMatrix,
|
|
340
502
|
extractSurface, surfaceRows, referencePattern, mapSurface, listTestFiles, loadTestSources, diffSurface, scaffold,
|
|
503
|
+
LIVENESS_ALLOWLIST_REL, pantoolsCalls, loadLivenessInputs, stripDefaultsBuilder, liveness, livenessProblems, checkReport, readLivenessAllowlist,
|
|
341
504
|
};
|