pan-wizard 3.24.0 → 3.26.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/agents/pan-conductor.md +1 -1
- package/bin/install-lib.cjs +283 -1
- package/bin/install.js +127 -0
- package/commands/pan/army.md +14 -0
- package/package.json +3 -2
- package/pan-wizard-core/bin/lib/suggest.cjs +141 -0
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +113 -2
- package/pan-wizard-core/bin/lib/worktree.cjs +98 -0
- package/pan-wizard-core/bin/pan-tools.cjs +42 -4
- package/pan-wizard-core/mcp/native-tools.cjs +159 -0
- package/pan-wizard-core/mcp/orchestrator.cjs +179 -0
- package/{pan-zcode → pan-wizard-core}/mcp/server.cjs +35 -6
- package/{pan-zcode → pan-wizard-core}/mcp/tool-registry.cjs +60 -3
- package/pan-wizard-core/workflows/verify-phase.md +25 -6
- package/pan-zcode/README.md +17 -9
- package/pan-zcode/bin/install-zcode.js +4 -1
- package/scripts/build-plugin.js +35 -3
- package/scripts/deprecate-old-versions.js +225 -0
- package/scripts/plugin-path.js +84 -0
- package/pan-wizard-core/learnings/internal/.gitkeep +0 -2
- package/pan-wizard-core/learnings/internal/experiment-runner.md +0 -81
- package/pan-wizard-core/learnings/internal/external-research.md +0 -105
- package/pan-wizard-core/learnings/internal/loop-design.md +0 -33
- package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +0 -181
- package/pan-zcode/mcp/native-tools.cjs +0 -63
- package/pan-zcode/mcp/orchestrator.cjs +0 -66
- /package/{pan-zcode → pan-wizard-core}/mcp/merge-gate.cjs +0 -0
|
@@ -135,7 +135,13 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
const
|
|
138
|
+
const mcp = validateMcpRegistration(cwd, configDir, runtime);
|
|
139
|
+
if (!mcp.ok) settingsIssues.push(...mcp.issues);
|
|
140
|
+
|
|
141
|
+
const status = missing.length > 0 ? 'broken'
|
|
142
|
+
: modified.length > 0 ? 'modified'
|
|
143
|
+
: !mcp.ok ? 'modified'
|
|
144
|
+
: 'clean';
|
|
139
145
|
|
|
140
146
|
return {
|
|
141
147
|
status,
|
|
@@ -144,11 +150,114 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
|
|
|
144
150
|
missing,
|
|
145
151
|
modified,
|
|
146
152
|
orphaned: [],
|
|
147
|
-
settings_ok: settingsOk,
|
|
153
|
+
settings_ok: settingsOk && mcp.ok,
|
|
148
154
|
settings_issues: settingsIssues,
|
|
155
|
+
mcp,
|
|
149
156
|
};
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Where each runtime's MCP registration lives, relative to the project (claude)
|
|
161
|
+
* or to the runtime's config dir (everything else), and under which container
|
|
162
|
+
* key. This MIRRORS `MCP_REGISTRATION` in bin/install-lib.cjs — the installer
|
|
163
|
+
* writes, this reads, and the two must agree.
|
|
164
|
+
*
|
|
165
|
+
* Duplicated rather than imported on purpose: install-lib.cjs is installer-side
|
|
166
|
+
* and is NOT shipped into an install, while this module runs from inside one. A
|
|
167
|
+
* scenario test pins the two tables against each other so the copy cannot drift
|
|
168
|
+
* silently.
|
|
169
|
+
*
|
|
170
|
+
* `codex` is absent because registration there is `register: false` — PAN prints
|
|
171
|
+
* a TOML snippet rather than writing config, so there is nothing to verify.
|
|
172
|
+
*/
|
|
173
|
+
const MCP_EXPECTED = {
|
|
174
|
+
claude: { rel: '.mcp.json', fromProjectRoot: true, key: 'mcpServers' },
|
|
175
|
+
copilot: { rel: 'mcp.json', fromProjectRoot: false, key: 'mcpServers' },
|
|
176
|
+
gemini: { rel: 'settings.json', fromProjectRoot: false, key: 'mcpServers' },
|
|
177
|
+
opencode: { rel: 'opencode.json', fromProjectRoot: false, key: 'mcp' },
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Verify the MCP registration this install wrote.
|
|
182
|
+
*
|
|
183
|
+
* WHY: `registerMcpServer()` writes up to four config files per install, and this
|
|
184
|
+
* verdict is the only thing most callers check afterwards. Until 2026-08 it had no
|
|
185
|
+
* idea MCP existed, so a fresh install reported `clean` whether registration
|
|
186
|
+
* succeeded, was skipped because the server file was missing, or was refused
|
|
187
|
+
* because the runtime's config was unparseable JSON. The installer recorded those
|
|
188
|
+
* cases as warnings; nothing surfaced them where anyone looks.
|
|
189
|
+
*
|
|
190
|
+
* Every part of this is checkable without launching the bridge: the config is
|
|
191
|
+
* present, it parses, it carries a `pan` entry, and the server path in that entry
|
|
192
|
+
* exists on disk. Whether a RUNTIME then loads it is a separate claim this cannot
|
|
193
|
+
* make, and does not.
|
|
194
|
+
*
|
|
195
|
+
* @returns {{ok:boolean, registered:boolean, path:string|null, issues:string[]}}
|
|
196
|
+
*/
|
|
197
|
+
function validateMcpRegistration(cwd, configDir, runtime) {
|
|
198
|
+
const spec = MCP_EXPECTED[runtime];
|
|
199
|
+
// codex (and any future register:false runtime) has nothing to verify.
|
|
200
|
+
if (!spec) return { ok: true, registered: false, path: null, issues: [], skipped: 'no-registration-by-design' };
|
|
201
|
+
|
|
202
|
+
// ONLY expect a registration when this install actually SHIPS the bridge.
|
|
203
|
+
//
|
|
204
|
+
// An install made before the MCP bridge existed has no `.mcp.json` and never
|
|
205
|
+
// should have — flagging it would be a false alarm on every older deployment,
|
|
206
|
+
// and the first version of this check did exactly that, turning four green
|
|
207
|
+
// fixtures red for lacking a file they were never supposed to have. The
|
|
208
|
+
// installed tree is the authority: if `pan-wizard-core/mcp/server.cjs` is
|
|
209
|
+
// present, registration is expected; if it is not, there is nothing to verify.
|
|
210
|
+
const bridge = path.join(cwd, configDir, 'pan-wizard-core', 'mcp', 'server.cjs');
|
|
211
|
+
try {
|
|
212
|
+
fs.accessSync(bridge);
|
|
213
|
+
} catch (_) {
|
|
214
|
+
return { ok: true, registered: false, path: null, issues: [], skipped: 'bridge-not-in-this-install' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const configPath = spec.fromProjectRoot
|
|
218
|
+
? path.join(cwd, spec.rel)
|
|
219
|
+
: path.join(cwd, configDir, spec.rel);
|
|
220
|
+
const shown = path.relative(cwd, configPath) || spec.rel;
|
|
221
|
+
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// Absent and unparseable are different failures and must read differently:
|
|
227
|
+
// one means registration never happened, the other means PAN deliberately
|
|
228
|
+
// left a file it could not safely rewrite.
|
|
229
|
+
const missingFile = e && e.code === 'ENOENT';
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
registered: false,
|
|
233
|
+
path: shown,
|
|
234
|
+
issues: [missingFile
|
|
235
|
+
? `MCP not registered: ${shown} is missing`
|
|
236
|
+
: `MCP config unreadable (left untouched by design): ${shown} — ${e.message}`],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const bag = parsed && parsed[spec.key];
|
|
241
|
+
const entry = bag && typeof bag === 'object' ? bag.pan : undefined;
|
|
242
|
+
if (!entry) {
|
|
243
|
+
return { ok: false, registered: false, path: shown, issues: [`MCP not registered: no "pan" entry under "${spec.key}" in ${shown}`] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The server path is `args[0]` everywhere except opencode, whose `command` is a
|
|
247
|
+
// single array of [cmd, ...args] — the shape difference that has already caused
|
|
248
|
+
// one bug in this feature.
|
|
249
|
+
const serverPath = Array.isArray(entry.command) ? entry.command[1] : (entry.args && entry.args[0]);
|
|
250
|
+
const issues = [];
|
|
251
|
+
if (!serverPath) {
|
|
252
|
+
issues.push(`MCP entry in ${shown} names no server path`);
|
|
253
|
+
} else {
|
|
254
|
+
try { fs.accessSync(serverPath); } catch (_) {
|
|
255
|
+
issues.push(`MCP server path does not exist: ${serverPath} (from ${shown})`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { ok: issues.length === 0, registered: true, path: shown, server: serverPath || null, issues };
|
|
259
|
+
}
|
|
260
|
+
|
|
152
261
|
/**
|
|
153
262
|
* CLI command: validate deployment
|
|
154
263
|
* Validates PAN installations in the current directory.
|
|
@@ -190,4 +299,6 @@ module.exports = {
|
|
|
190
299
|
detectInstalledRuntimes,
|
|
191
300
|
validateRuntimeInstall,
|
|
192
301
|
cmdValidateDeployment,
|
|
302
|
+
validateMcpRegistration,
|
|
303
|
+
MCP_EXPECTED,
|
|
193
304
|
};
|
|
@@ -89,6 +89,91 @@ function listArmyWorktrees(cwd) {
|
|
|
89
89
|
return out;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Sweep every army worktree and orphaned army/ branch (P-1815, PanLoop
|
|
94
|
+
* finding 13). The what-if subsystem always had this (`whatif cleanup`); the
|
|
95
|
+
* army path created worktrees that nothing removed — sibling `pan-army-*`
|
|
96
|
+
* directories and `army/*` branches accumulated after every campaign.
|
|
97
|
+
*
|
|
98
|
+
* Safety posture (the L3 lesson — never trade clutter for silent data loss):
|
|
99
|
+
* - A DIRTY worktree is kept unless `force` — git refuses, we surface why.
|
|
100
|
+
* - A branch is deleted only when its tip is reachable from HEAD (truly
|
|
101
|
+
* integrated). Squash-merged and aborted branches are NOT reachable and may
|
|
102
|
+
* hold the only copy of real work, so by default they are KEPT and listed
|
|
103
|
+
* with the exact command to delete them; `force` sweeps them too.
|
|
104
|
+
* (The per-task Phase 5 teardown — `worktree remove --branch` right after
|
|
105
|
+
* the merge lands — deletes unconditionally; at that moment the deletion is
|
|
106
|
+
* the documented intent. The sweeper is the abort/orphan tool, so it errs
|
|
107
|
+
* the other way.)
|
|
108
|
+
* @param {string} cwd - main project root
|
|
109
|
+
* @param {Object} [opts] - { force: boolean }
|
|
110
|
+
* @returns {{removed_worktrees, deleted_branches, kept, pruned, clean}|{error}}
|
|
111
|
+
*/
|
|
112
|
+
function cleanupArmyWorktrees(cwd, opts) {
|
|
113
|
+
if (!isGitRepo(cwd)) return { error: 'Not a git repo' };
|
|
114
|
+
const force = opts?.force === true;
|
|
115
|
+
const removedWorktrees = [];
|
|
116
|
+
const deletedBranches = [];
|
|
117
|
+
const kept = [];
|
|
118
|
+
|
|
119
|
+
const branchIsIntegrated = (branch) =>
|
|
120
|
+
execGit(cwd, ['merge-base', '--is-ancestor', branch, 'HEAD']).exitCode === 0;
|
|
121
|
+
|
|
122
|
+
const deleteBranch = (branch, hadWorktree) => {
|
|
123
|
+
if (force || branchIsIntegrated(branch)) {
|
|
124
|
+
const del = execGit(cwd, ['branch', '-D', branch]);
|
|
125
|
+
if (del.exitCode === 0) deletedBranches.push(branch);
|
|
126
|
+
else kept.push({ branch, reason: `branch -D failed: ${del.stderr.trim()}` });
|
|
127
|
+
} else {
|
|
128
|
+
kept.push({
|
|
129
|
+
branch,
|
|
130
|
+
reason: `carries commits not reachable from HEAD (squash-merged or aborted work${hadWorktree ? '' : '; no worktree attached'}) — rerun with --force, or: git branch -D ${branch}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// 1. Registered army worktrees. Track every branch step 1 has already
|
|
136
|
+
// decided on — deleted OR deliberately kept — so the orphan scan below
|
|
137
|
+
// does not re-process (and double-report) it.
|
|
138
|
+
const handledBranches = new Set();
|
|
139
|
+
for (const t of listArmyWorktrees(cwd)) {
|
|
140
|
+
const rmArgs = ['worktree', 'remove'];
|
|
141
|
+
if (force) rmArgs.push('--force');
|
|
142
|
+
rmArgs.push(t.worktree);
|
|
143
|
+
const rm = execGit(cwd, rmArgs);
|
|
144
|
+
if (rm.exitCode !== 0) {
|
|
145
|
+
kept.push({ worktree: t.worktree, branch: t.branch, reason: `worktree remove refused: ${rm.stderr.trim()} — pass --force to discard uncommitted changes` });
|
|
146
|
+
if (t.branch) handledBranches.add(t.branch);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
removedWorktrees.push(t.worktree);
|
|
150
|
+
if (t.branch) {
|
|
151
|
+
handledBranches.add(t.branch);
|
|
152
|
+
deleteBranch(t.branch, true);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2. Drop stale registrations (a manually deleted directory leaves one).
|
|
157
|
+
const pruned = execGit(cwd, ['worktree', 'prune']).exitCode === 0;
|
|
158
|
+
|
|
159
|
+
// 3. Orphaned army/ branches — a worktree removed without its branch.
|
|
160
|
+
const stillAttached = new Set(listArmyWorktrees(cwd).map(t => t.branch));
|
|
161
|
+
const ls = execGit(cwd, ['branch', '--list', `${ARMY_BRANCH_PREFIX}*`, '--format=%(refname:short)']);
|
|
162
|
+
if (ls.exitCode === 0) {
|
|
163
|
+
for (const branch of ls.stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
|
|
164
|
+
if (!stillAttached.has(branch) && !handledBranches.has(branch)) deleteBranch(branch, false);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
removed_worktrees: removedWorktrees,
|
|
170
|
+
deleted_branches: deletedBranches,
|
|
171
|
+
kept,
|
|
172
|
+
pruned,
|
|
173
|
+
clean: kept.length === 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
92
177
|
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
93
178
|
|
|
94
179
|
function cmdWorktreeList(cwd, raw) {
|
|
@@ -112,12 +197,25 @@ function cmdWorktreeRemove(cwd, worktreePath, branch, raw, opts) {
|
|
|
112
197
|
output(r, raw, r.warnings.length ? r.warnings.join('\n') : 'removed');
|
|
113
198
|
}
|
|
114
199
|
|
|
200
|
+
function cmdWorktreeCleanup(cwd, raw, opts) {
|
|
201
|
+
const r = cleanupArmyWorktrees(cwd, opts);
|
|
202
|
+
if (r.error) return error(r.error);
|
|
203
|
+
const lines = [
|
|
204
|
+
...r.removed_worktrees.map(w => `removed worktree ${w}`),
|
|
205
|
+
...r.deleted_branches.map(b => `deleted branch ${b}`),
|
|
206
|
+
...r.kept.map(k => `KEPT ${k.worktree || k.branch}: ${k.reason}`),
|
|
207
|
+
];
|
|
208
|
+
output(r, raw, lines.length ? lines.join('\n') : 'nothing to clean');
|
|
209
|
+
}
|
|
210
|
+
|
|
115
211
|
module.exports = {
|
|
116
212
|
ARMY_BRANCH_PREFIX,
|
|
117
213
|
createTaskWorktree,
|
|
118
214
|
removeTaskWorktree,
|
|
119
215
|
listArmyWorktrees,
|
|
216
|
+
cleanupArmyWorktrees,
|
|
120
217
|
cmdWorktreeList,
|
|
121
218
|
cmdWorktreeCreate,
|
|
122
219
|
cmdWorktreeRemove,
|
|
220
|
+
cmdWorktreeCleanup,
|
|
123
221
|
};
|
|
@@ -1191,8 +1191,10 @@ async function main() {
|
|
|
1191
1191
|
worktree.cmdWorktreeCreate(cwd, args[2], raw, { base: getArgValue(args, '--base') });
|
|
1192
1192
|
} else if (subcommand === 'remove') {
|
|
1193
1193
|
worktree.cmdWorktreeRemove(cwd, args[2], getArgValue(args, '--branch'), raw, { force: args.includes('--force') });
|
|
1194
|
+
} else if (subcommand === 'cleanup') {
|
|
1195
|
+
worktree.cmdWorktreeCleanup(cwd, raw, { force: args.includes('--force') });
|
|
1194
1196
|
} else {
|
|
1195
|
-
error('Unknown worktree subcommand. Available: list, create, remove');
|
|
1197
|
+
error('Unknown worktree subcommand. Available: list, create, remove, cleanup');
|
|
1196
1198
|
}
|
|
1197
1199
|
break;
|
|
1198
1200
|
}
|
|
@@ -1497,7 +1499,24 @@ async function main() {
|
|
|
1497
1499
|
break;
|
|
1498
1500
|
}
|
|
1499
1501
|
|
|
1500
|
-
//
|
|
1502
|
+
// BARE `learn` is the documented convenience alias for `optimize learn`
|
|
1503
|
+
// (docs/CLI-REFERENCE.md lists it as `learn (alias)`), so it is kept.
|
|
1504
|
+
//
|
|
1505
|
+
// An UNKNOWN subcommand, however, used to fall through to that same alias
|
|
1506
|
+
// and silently run trace analysis. A ledger flagged `learn` as colliding
|
|
1507
|
+
// with `optimize learn`; the collision turned out to be a superset rather
|
|
1508
|
+
// than two meanings, but this fallthrough was the real defect underneath —
|
|
1509
|
+
// `pan-tools learn promotee` (a typo) ran the analyser and returned a
|
|
1510
|
+
// trace-session error, so the caller concluded `promote` was broken.
|
|
1511
|
+
//
|
|
1512
|
+
// `learn` was also the ONLY group of the ~33 that never published an
|
|
1513
|
+
// "Available:" list, which made its subcommands invisible both to a user
|
|
1514
|
+
// and to the suggestion index that is now parsed from those strings.
|
|
1515
|
+
// Publishing it fixes the error AND feeds the suggester, with no second
|
|
1516
|
+
// list to maintain.
|
|
1517
|
+
if (subcommand) {
|
|
1518
|
+
error('Unknown learn subcommand. Available: promote, unpromote, list-promoted, build-index, topics-for, lint');
|
|
1519
|
+
}
|
|
1501
1520
|
optimize.cmdOptimizeLearn(cwd, {
|
|
1502
1521
|
sessionId: getArgValue(args, '--session'),
|
|
1503
1522
|
}, raw);
|
|
@@ -1526,8 +1545,27 @@ async function main() {
|
|
|
1526
1545
|
error(`Unknown links subcommand: ${subcommand}. Available: validate`);
|
|
1527
1546
|
}
|
|
1528
1547
|
|
|
1529
|
-
default:
|
|
1530
|
-
|
|
1548
|
+
default: {
|
|
1549
|
+
// A ledger recorded `pan-tools trace` 18 times — the most-repeated agent
|
|
1550
|
+
// behaviour it had seen — and every one got "unknown, go read the list".
|
|
1551
|
+
// But `trace` is a REAL subcommand one namespace away (`optimize trace`),
|
|
1552
|
+
// so naming the right form turns a dead end into a self-correction. The
|
|
1553
|
+
// docs were cleared as the cause, which is exactly why the fix belongs
|
|
1554
|
+
// here: it works whatever led the caller to type it.
|
|
1555
|
+
//
|
|
1556
|
+
// Parsed from this file's own "Available:" strings on the error path only,
|
|
1557
|
+
// so nothing is paid on a healthy call and no second list can drift. Fails
|
|
1558
|
+
// open to the original message.
|
|
1559
|
+
let hint = '';
|
|
1560
|
+
try {
|
|
1561
|
+
const suggest = require('./lib/suggest.cjs');
|
|
1562
|
+
const src = require('fs').readFileSync(__filename, 'utf8');
|
|
1563
|
+
const topLevel = (USAGE.split('Commands: ')[1] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
1564
|
+
hint = suggest.formatSuggestions(
|
|
1565
|
+
suggest.suggestCommand(command, suggest.buildSubcommandIndex(src), topLevel));
|
|
1566
|
+
} catch { /* suggestions are a courtesy — never let them mask the error */ }
|
|
1567
|
+
error(`Unknown command: ${command}.${hint} Run pan-tools --help to see available commands.`);
|
|
1568
|
+
}
|
|
1531
1569
|
}
|
|
1532
1570
|
}
|
|
1533
1571
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PAN-Z M2 — native MCP tools whose logic lives in-process (not a pan-tools spawn).
|
|
5
|
+
*
|
|
6
|
+
* These are the deterministic grafts the review demanded: the orchestrator's
|
|
7
|
+
* `next-action` state machine and the two-step merge gate. A native tool declares a
|
|
8
|
+
* `handler({ cwd, input, env, gitImpl }) -> { json | text, isError? }` instead of a
|
|
9
|
+
* `verb`; a thrown Error is surfaced as JSON-RPC -32602 (invalid params) by the server.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const mergeGate = require('./merge-gate.cjs');
|
|
15
|
+
const orchestrator = require('./orchestrator.cjs');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fill in the `verified` run fact from disk where the caller did not state it.
|
|
19
|
+
*
|
|
20
|
+
* WHY THIS EXISTS. `nextAction` is pure and takes `verified`/`merged` as run
|
|
21
|
+
* facts, which is what makes the human merge gate reachable at all. But nothing
|
|
22
|
+
* asks the caller to remember them, and a phase that is `complete` without
|
|
23
|
+
* `verified` returns `verify` — so a project whose phases were completed in an
|
|
24
|
+
* earlier session would be told to verify phase 1 forever. That is a live-lock of
|
|
25
|
+
* exactly the kind this whole fix set out to remove, just moved one step along.
|
|
26
|
+
*
|
|
27
|
+
* PAN already records verification on disk: `verification.md` (or
|
|
28
|
+
* `*-verification.md`) inside the phase directory. `classifyPhaseStatus()` ignores
|
|
29
|
+
* it — it counts plans against summaries and nothing else — which is precisely why
|
|
30
|
+
* `verified` could not come from the status vocabulary and had to be a run fact.
|
|
31
|
+
*
|
|
32
|
+
* So: derive it here, in the impure layer that has a cwd, and leave `nextAction`
|
|
33
|
+
* pure. An explicit value from the caller always wins. `merged` is NOT derived —
|
|
34
|
+
* no file records it, and a phase resting at `request_merge` until a human acts is
|
|
35
|
+
* the gate working, not a stall.
|
|
36
|
+
*
|
|
37
|
+
* Fail-open: any fs problem leaves the snapshot exactly as the caller sent it.
|
|
38
|
+
*/
|
|
39
|
+
function enrichVerifiedFromDisk(cwd, state) {
|
|
40
|
+
if (!state || !Array.isArray(state.phases)) return state;
|
|
41
|
+
let phaseDirs;
|
|
42
|
+
try {
|
|
43
|
+
const root = path.join(cwd, '.planning', 'phases');
|
|
44
|
+
phaseDirs = fs.readdirSync(root, { withFileTypes: true })
|
|
45
|
+
.filter((e) => e.isDirectory())
|
|
46
|
+
.map((e) => ({ name: e.name, full: path.join(root, e.name) }));
|
|
47
|
+
} catch {
|
|
48
|
+
return state; // no .planning/phases — nothing to derive
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hasVerification = (dir) => {
|
|
52
|
+
try {
|
|
53
|
+
return fs.readdirSync(dir).some((f) => f === 'verification.md' || f.endsWith('-verification.md'));
|
|
54
|
+
} catch { return false; }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const phases = state.phases.map((p) => {
|
|
58
|
+
if (!p || typeof p !== 'object' || p.verified !== undefined) return p;
|
|
59
|
+
const num = String(p.number == null ? '' : p.number).trim();
|
|
60
|
+
if (!num) return p;
|
|
61
|
+
// Phase dirs are `NN-slug`; match on the leading number with or without
|
|
62
|
+
// zero padding, so both "3" and "03" find `03-foo`.
|
|
63
|
+
const padded = num.padStart(2, '0');
|
|
64
|
+
const match = phaseDirs.find((d) => d.name === num || d.name.startsWith(`${num}-`) || d.name.startsWith(`${padded}-`));
|
|
65
|
+
if (!match) return p;
|
|
66
|
+
return hasVerification(match.full) ? Object.assign({}, p, { verified: true }) : p;
|
|
67
|
+
});
|
|
68
|
+
return Object.assign({}, state, { phases });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const NATIVE_TOOLS = [
|
|
72
|
+
{
|
|
73
|
+
name: 'pan_next_action',
|
|
74
|
+
title: 'Next deterministic action',
|
|
75
|
+
// The description is the ONLY thing an LLM caller reads, so it names the
|
|
76
|
+
// correct source explicitly. An audit found callers following the old prose
|
|
77
|
+
// ("assemble from the pan-mcp resources") straight into an infinite plan
|
|
78
|
+
// loop: `pan://progress` reports Title Case and `pan://phases` returns
|
|
79
|
+
// directory names with no status at all. `pan_roadmap_analyze` is the source
|
|
80
|
+
// whose shape actually matches — say so here rather than in a comment.
|
|
81
|
+
description: 'Given a phase snapshot, return the next step the primary agent should take (plan/execute/verify/request_merge/await_approval/stop), enforcing the safety caps, the regression circuit-breaker and the human merge gate. Build `state.phases` from `pan_roadmap_analyze` (its `phases[].disk_status` matches this contract); `pan://phases` carries no status and is NOT a substitute. Set `verified`/`merged` on a phase once you have performed those steps — they are run facts no file records, and without them the merge gate is never reached.',
|
|
82
|
+
readOnly: true, destructive: false,
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: 'object', additionalProperties: false, required: ['state'],
|
|
85
|
+
properties: {
|
|
86
|
+
state: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
// `phases` is REQUIRED. It previously was not, so `{}` — or any object
|
|
89
|
+
// with a misspelled key — collapsed to an empty list and reported
|
|
90
|
+
// "all_complete / done:true", i.e. a malformed request read as success.
|
|
91
|
+
required: ['phases'],
|
|
92
|
+
properties: {
|
|
93
|
+
phases: {
|
|
94
|
+
type: 'array',
|
|
95
|
+
description: 'Ordered phases. status is the lowercase disk vocabulary (empty|discussed|researched|planned|partial|complete); case is folded, so Title Case from pan://progress is accepted.',
|
|
96
|
+
items: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
required: ['status'],
|
|
99
|
+
properties: {
|
|
100
|
+
number: { type: ['number', 'string'] },
|
|
101
|
+
status: { type: 'string' },
|
|
102
|
+
verified: { type: 'boolean', description: 'Run fact: verification passed for this phase.' },
|
|
103
|
+
merged: { type: 'boolean', description: 'Run fact: this phase has been merged.' },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
cycles: { type: 'number' },
|
|
108
|
+
points_used: { type: 'number' },
|
|
109
|
+
tests_before: { type: 'number' },
|
|
110
|
+
tests_after: { type: 'number' },
|
|
111
|
+
awaiting_approval: { type: 'boolean' },
|
|
112
|
+
aborted: { type: 'boolean' },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
caps: { type: 'object' },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
handler: ({ cwd, input }) => {
|
|
119
|
+
if (!input.state || typeof input.state !== 'object') throw new Error('Invalid "state": an object snapshot is required');
|
|
120
|
+
// Derive `verified` from disk for any phase the caller left unset, so the
|
|
121
|
+
// verify step terminates on a real project instead of looping. Explicit
|
|
122
|
+
// caller values are never overwritten.
|
|
123
|
+
return { json: orchestrator.nextAction(enrichVerifiedFromDisk(cwd, input.state), input.caps) };
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'pan_request_merge',
|
|
128
|
+
title: 'Request a gated merge',
|
|
129
|
+
description: 'Stage a squash-merge request for a branch and mark it awaiting human approval. Records intent only — does NOT merge.',
|
|
130
|
+
readOnly: false, destructive: false,
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object', additionalProperties: false, required: ['branch'],
|
|
133
|
+
properties: { branch: { type: 'string' }, ci_green: { type: 'boolean' }, verify_pass: { type: 'boolean' } },
|
|
134
|
+
},
|
|
135
|
+
handler: ({ cwd, input }) => ({
|
|
136
|
+
json: mergeGate.requestMerge(cwd, { branch: input.branch, ci_green: input.ci_green, verify_pass: input.verify_pass }),
|
|
137
|
+
}),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'pan_confirm_merge',
|
|
141
|
+
title: 'Confirm a human-approved merge',
|
|
142
|
+
description: 'Perform a squash-merge ONLY if CI is green, verify passed, and a human-origin approval token (env PAN_MERGE_APPROVAL equal to the request\'s approval_token) is present. Any agent-supplied approval is ignored; never force-pushes or rewrites history.',
|
|
143
|
+
readOnly: false, destructive: true,
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object', additionalProperties: false, required: ['branch'],
|
|
146
|
+
properties: { branch: { type: 'string' } },
|
|
147
|
+
},
|
|
148
|
+
handler: ({ cwd, input, env, gitImpl }) => {
|
|
149
|
+
const res = mergeGate.confirmMerge(cwd, { branch: input.branch }, env, gitImpl);
|
|
150
|
+
// A refused gate (missing approval / CI / verify) is a normal, non-error result the
|
|
151
|
+
// agent should read; only a real git failure is flagged isError.
|
|
152
|
+
const gitFailed = !res.merged && Array.isArray(res.reasons)
|
|
153
|
+
&& res.reasons.some((r) => r === 'git_merge_failed' || r === 'git_commit_failed');
|
|
154
|
+
return { json: res, isError: gitFailed };
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
module.exports = { NATIVE_TOOLS };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The deterministic orchestrator ("next-action" state machine).
|
|
5
|
+
*
|
|
6
|
+
* A host with no workflow engine cannot machine-intercept a subagent spawn, so
|
|
7
|
+
* PAN's sequencing + safety harness (waves, regression circuit-breaker,
|
|
8
|
+
* spawn/budget caps, the human merge gate) cannot live in agent prose or a
|
|
9
|
+
* pre-spawn hook. The fix: keep the state machine here and expose ONE
|
|
10
|
+
* `next-action` tool the primary Agent polls before each step. Enforcement then
|
|
11
|
+
* happens at the (gateable) MCP-tool-call boundary, not at the (un-gateable)
|
|
12
|
+
* spawn event.
|
|
13
|
+
*
|
|
14
|
+
* `nextAction` is a PURE function of a snapshot, so its decisions are
|
|
15
|
+
* reproducible and unit-testable.
|
|
16
|
+
*
|
|
17
|
+
* ─── THE VOCABULARY, and why it is what it is ──────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* An external audit (2026-08-15) found this machine keyed on statuses that
|
|
20
|
+
* NOTHING IN PAN EMITS. It expected `executed` and `verified`; the only producer
|
|
21
|
+
* of a phase status is `classifyPhaseStatus()` (bin/lib/utils.cjs), which emits
|
|
22
|
+
*
|
|
23
|
+
* complete · partial · planned · researched · discussed · empty
|
|
24
|
+
*
|
|
25
|
+
* The consequences were severe and all three were reproduced before this rewrite:
|
|
26
|
+
*
|
|
27
|
+
* 1. `verify` and `request_merge` were UNREACHABLE. A phase ran
|
|
28
|
+
* planned → execute → complete and the machine moved to the next one, so
|
|
29
|
+
* **the human merge gate — the safety centrepiece — was skipped entirely.**
|
|
30
|
+
* 2. `partial` had no entry and fell through to `plan`, re-planning a
|
|
31
|
+
* half-executed phase forever (a live-lock).
|
|
32
|
+
* 3. `pan://progress` reports Title Case (`"Planned"`), so a caller following
|
|
33
|
+
* this file's own instruction to assemble the snapshot from the MCP
|
|
34
|
+
* resources got `undefined` → `plan` on every cycle, forever.
|
|
35
|
+
*
|
|
36
|
+
* The suite was green throughout because its fixtures spoke `executed`/`verified`
|
|
37
|
+
* — a vocabulary no producer emits. The fixture was MORE capable than reality and
|
|
38
|
+
* therefore produced false confidence. Fixtures here must use the real vocabulary.
|
|
39
|
+
*
|
|
40
|
+
* ─── The model now ─────────────────────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* Disk status answers "how far did the artifacts get?". It cannot answer "was
|
|
43
|
+
* this verified?" or "was it merged?" — those are RUN facts, known to the caller
|
|
44
|
+
* that just performed them, and no file on disk records them. So a phase carries
|
|
45
|
+
* its disk status plus two optional booleans:
|
|
46
|
+
*
|
|
47
|
+
* { number, status, verified?: boolean, merged?: boolean }
|
|
48
|
+
*
|
|
49
|
+
* which makes the full ladder reachable from the vocabulary PAN actually emits:
|
|
50
|
+
*
|
|
51
|
+
* planned → execute
|
|
52
|
+
* partial → execute (RESUME, never re-plan)
|
|
53
|
+
* empty | discussed | researched → plan
|
|
54
|
+
* complete & !verified → verify
|
|
55
|
+
* complete & verified & !merged → request_merge
|
|
56
|
+
* complete & verified & merged → (skip; this phase is finished)
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
// Budget is advisory by default (enforceBudget:false) — it never stops the loop
|
|
60
|
+
// unless the caller opts in. maxCycles remains a hard safety stop.
|
|
61
|
+
const DEFAULT_CAPS = { maxCycles: 25, budget: Infinity, enforceBudget: false };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Disk status → next action, for statuses that precede execution.
|
|
65
|
+
* Keys are the REAL `classifyPhaseStatus()` vocabulary. `complete` is absent on
|
|
66
|
+
* purpose: what follows completion depends on the run facts, not on disk.
|
|
67
|
+
*
|
|
68
|
+
* The two legacy keys are retained as aliases so a caller written against the
|
|
69
|
+
* old contract keeps working, but nothing in PAN produces them.
|
|
70
|
+
*/
|
|
71
|
+
const PHASE_NEXT = {
|
|
72
|
+
// real disk vocabulary
|
|
73
|
+
empty: 'plan',
|
|
74
|
+
discussed: 'plan',
|
|
75
|
+
researched: 'plan',
|
|
76
|
+
planned: 'execute',
|
|
77
|
+
partial: 'execute',
|
|
78
|
+
// legacy aliases — no producer emits these; kept for callers written against
|
|
79
|
+
// the pre-2026-08 contract. Do NOT use them in new fixtures.
|
|
80
|
+
none: 'plan',
|
|
81
|
+
executed: 'verify',
|
|
82
|
+
verified: 'request_merge',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Statuses meaning "the artifacts are all there". */
|
|
86
|
+
const COMPLETE_STATUS = 'complete';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalise a status to the lowercase vocabulary the table keys on.
|
|
90
|
+
*
|
|
91
|
+
* `pan://progress` emits Title Case (`"Planned"`) while `roadmap analyze` emits
|
|
92
|
+
* lowercase (`"planned"`); this file's docs point callers at the resources, so
|
|
93
|
+
* the Title Case form is the one a compliant caller will actually send. Folding
|
|
94
|
+
* case here is what makes the documented assembly path work at all.
|
|
95
|
+
*/
|
|
96
|
+
function normalizeStatus(status) {
|
|
97
|
+
return typeof status === 'string' ? status.trim().toLowerCase() : '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True when a phase still needs work of any kind. */
|
|
101
|
+
function isPhaseOpen(phase) {
|
|
102
|
+
if (!phase || typeof phase !== 'object') return false;
|
|
103
|
+
const status = normalizeStatus(phase.status);
|
|
104
|
+
if (status !== COMPLETE_STATUS) return true;
|
|
105
|
+
// Complete on disk, but the run facts decide whether it is actually finished.
|
|
106
|
+
return !(phase.verified === true && phase.merged === true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decide the next deterministic action.
|
|
111
|
+
*
|
|
112
|
+
* @param {Object} state snapshot:
|
|
113
|
+
* { phases:[{number, status, verified?, merged?}], cycles?, points_used?,
|
|
114
|
+
* tests_before?, tests_after?, awaiting_approval?, aborted? }
|
|
115
|
+
* `phases` is REQUIRED. A snapshot without a usable one is reported as
|
|
116
|
+
* `no_phases` / `done:false` — never as success (see below).
|
|
117
|
+
* @param {Object} [caps] { maxCycles, budget, enforceBudget }
|
|
118
|
+
* @returns {{action:string, args?:Object, reason:string, done:boolean}}
|
|
119
|
+
* action ∈ plan | execute | verify | request_merge | await_approval | stop
|
|
120
|
+
*/
|
|
121
|
+
function nextAction(state, caps) {
|
|
122
|
+
const c = Object.assign({}, DEFAULT_CAPS, caps || {});
|
|
123
|
+
state = state || {};
|
|
124
|
+
|
|
125
|
+
// Hard stops first — safety caps and the circuit-breaker outrank all progress.
|
|
126
|
+
if (state.aborted) return { action: 'stop', reason: 'aborted', done: true };
|
|
127
|
+
if (
|
|
128
|
+
typeof state.tests_before === 'number' &&
|
|
129
|
+
typeof state.tests_after === 'number' &&
|
|
130
|
+
state.tests_after < state.tests_before
|
|
131
|
+
) {
|
|
132
|
+
return { action: 'stop', reason: 'regression', done: true };
|
|
133
|
+
}
|
|
134
|
+
if ((state.cycles || 0) >= c.maxCycles) return { action: 'stop', reason: 'max_cycles', done: true };
|
|
135
|
+
if (c.enforceBudget && (state.points_used || 0) >= c.budget) return { action: 'stop', reason: 'budget_cap', done: true };
|
|
136
|
+
|
|
137
|
+
// The human merge gate is a barrier: while a merge awaits approval, do nothing else.
|
|
138
|
+
if (state.awaiting_approval) return { action: 'await_approval', reason: 'human_gate', done: false };
|
|
139
|
+
|
|
140
|
+
// A MISSING OR MISSHAPEN `phases` IS NOT SUCCESS.
|
|
141
|
+
//
|
|
142
|
+
// This previously collapsed to `[]`, and `[].find(...)` is indistinguishable
|
|
143
|
+
// from "every phase is complete" — so `{}`, a wrong key, or a non-array all
|
|
144
|
+
// returned all_complete/done:true. An orchestrator wrong in the "keep working"
|
|
145
|
+
// direction wastes tokens and gets noticed; one wrong in the `done:true`
|
|
146
|
+
// direction silently stops the loop and reports success. `no_phases` carries
|
|
147
|
+
// `done:false` precisely so a caller cannot mistake "I could not read this"
|
|
148
|
+
// for "the work is finished".
|
|
149
|
+
const hasPhases = Array.isArray(state.phases)
|
|
150
|
+
&& state.phases.some((p) => p && typeof p === 'object' && (p.status !== undefined || p.number !== undefined));
|
|
151
|
+
if (!hasPhases) {
|
|
152
|
+
return { action: 'stop', reason: 'no_phases', done: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Advance the first phase that is not finished.
|
|
156
|
+
const phase = state.phases.find(isPhaseOpen);
|
|
157
|
+
if (!phase) return { action: 'stop', reason: 'all_complete', done: true };
|
|
158
|
+
|
|
159
|
+
const status = normalizeStatus(phase.status);
|
|
160
|
+
const args = { phase: phase.number };
|
|
161
|
+
|
|
162
|
+
// Complete on disk: the run facts decide. This is the branch that makes the
|
|
163
|
+
// merge gate reachable — without it a completed phase was simply skipped.
|
|
164
|
+
if (status === COMPLETE_STATUS) {
|
|
165
|
+
if (phase.verified !== true) return { action: 'verify', args, reason: 'phase_complete_unverified', done: false };
|
|
166
|
+
return { action: 'request_merge', args, reason: 'phase_verified_unmerged', done: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const action = PHASE_NEXT[status];
|
|
170
|
+
if (!action) {
|
|
171
|
+
// An unknown status is a caller/producer mismatch, not a licence to re-plan.
|
|
172
|
+
// Say so, rather than silently defaulting the way the old `|| 'plan'` did —
|
|
173
|
+
// that default is what turned Title Case into an infinite plan loop.
|
|
174
|
+
return { action: 'plan', args, reason: `phase_unknown_status_${status || 'missing'}`, done: false };
|
|
175
|
+
}
|
|
176
|
+
return { action, args, reason: `phase_${status}`, done: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { nextAction, DEFAULT_CAPS, PHASE_NEXT, normalizeStatus, isPhaseOpen };
|