pan-wizard 3.17.0 → 3.19.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 +23 -0
- package/bin/install-lib.cjs +10 -1
- package/bin/install.js +1 -1
- package/commands/pan/army.md +5 -4
- package/commands/pan/focus-auto.md +3 -2
- package/package.json +3 -2
- package/pan-wizard-core/bin/lib/campaign.cjs +4 -1
- package/pan-wizard-core/bin/lib/config.cjs +4 -0
- package/pan-wizard-core/bin/lib/core.cjs +31 -21
- package/pan-wizard-core/bin/lib/focus.cjs +8 -1
- package/pan-zcode/KNOWN-BETA-RISKS.md +36 -0
- package/pan-zcode/README.md +58 -0
- package/pan-zcode/bin/install-zcode.js +149 -0
- package/pan-zcode/lib/convert-agent.cjs +90 -0
- package/pan-zcode/mcp/merge-gate.cjs +126 -0
- package/pan-zcode/mcp/native-tools.cjs +63 -0
- package/pan-zcode/mcp/orchestrator.cjs +66 -0
- package/pan-zcode/mcp/server.cjs +229 -0
- package/pan-zcode/mcp/tool-registry.cjs +94 -0
package/README.md
CHANGED
|
@@ -201,6 +201,29 @@ npm run test:all # All tests (unit + scenario)
|
|
|
201
201
|
|
|
202
202
|
</details>
|
|
203
203
|
|
|
204
|
+
<details>
|
|
205
|
+
<summary><strong>Experimental: ZCode support (preview) — ZCode is beta</strong></summary>
|
|
206
|
+
|
|
207
|
+
**PAN-Z** is an experimental, separate subsystem that brings the PAN workflow to
|
|
208
|
+
[ZCode](https://zcode.z.ai), z.ai's GLM coding-agent harness. ZCode has no
|
|
209
|
+
slash-commands or hooks to host PAN directly, so PAN-Z instead exposes PAN's engine to
|
|
210
|
+
ZCode over **MCP**: PAN's agents become ZCode subagents, and the deterministic engine —
|
|
211
|
+
including a model-proof human merge gate — is reached as MCP tools.
|
|
212
|
+
|
|
213
|
+
> **ZCode is beta**, and its on-disk formats change frequently. PAN-Z is a **preview**:
|
|
214
|
+
> two facts (whether a subagent can call MCP tools, and whether local MCP calls are
|
|
215
|
+
> metered) can only be confirmed on a live ZCode install. See
|
|
216
|
+
> [`pan-zcode/README.md`](pan-zcode/README.md) and
|
|
217
|
+
> [`pan-zcode/KNOWN-BETA-RISKS.md`](pan-zcode/KNOWN-BETA-RISKS.md).
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
# From an installed pan-wizard package, build the ZCode bundle into a target dir
|
|
221
|
+
node "$(npm root -g)/pan-wizard/pan-zcode/bin/install-zcode.js" --target ./zcode-bundle
|
|
222
|
+
# then finish setup inside ZCode per the generated INSTALL-ZCODE.md
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
</details>
|
|
226
|
+
|
|
204
227
|
### Recommended: Skip Permissions Mode
|
|
205
228
|
|
|
206
229
|
PAN is designed for frictionless automation. Run Claude Code with:
|
package/bin/install-lib.cjs
CHANGED
|
@@ -1016,9 +1016,18 @@ function detectModelCapabilities(modelName) {
|
|
|
1016
1016
|
const n = modelName.toLowerCase();
|
|
1017
1017
|
|
|
1018
1018
|
// Anthropic Claude family
|
|
1019
|
-
if (n.includes('fable')) {
|
|
1019
|
+
if (n.includes('fable') || n.includes('mythos')) {
|
|
1020
1020
|
return { has_1m_ctx: true, has_thinking: true, has_cache: true, tier: 'reasoning' };
|
|
1021
1021
|
}
|
|
1022
|
+
// Claude 5 family (Opus 5, Sonnet 5) — 1M context, extended thinking, prompt caching.
|
|
1023
|
+
// Without this, `claude-opus-5` falls through to `unknown` and the installer prints
|
|
1024
|
+
// a FALSE "your model lacks 1M context / extended thinking" warning on the flagship.
|
|
1025
|
+
if (n.includes('opus-5')) {
|
|
1026
|
+
return { has_1m_ctx: true, has_thinking: true, has_cache: true, tier: 'reasoning' };
|
|
1027
|
+
}
|
|
1028
|
+
if (n.includes('sonnet-5')) {
|
|
1029
|
+
return { has_1m_ctx: true, has_thinking: true, has_cache: true, tier: 'mid' };
|
|
1030
|
+
}
|
|
1022
1031
|
if (n.includes('opus-4-8') || n.includes('opus-4.8')
|
|
1023
1032
|
|| n.includes('opus-4-7') || n.includes('opus-4.7')
|
|
1024
1033
|
|| n.includes('opus-4-6') || n.includes('opus-4.6')) {
|
package/bin/install.js
CHANGED
|
@@ -2492,7 +2492,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
2492
2492
|
console.log(`
|
|
2493
2493
|
${yellow}ℹ${reset} PAN's multi-agent workflows are tuned for frontier reasoning models. Default model "${modelField}" lacks: ${missing}.
|
|
2494
2494
|
Features degrade gracefully, but for best results select claude-fable-5 (PAN's recommended flagship — deepest
|
|
2495
|
-
long-horizon reasoning for the bot army) or claude-opus-4-8
|
|
2495
|
+
long-horizon reasoning for the bot army), or an Opus-tier model (claude-opus-5 / claude-opus-4-8) at lower cost.`);
|
|
2496
2496
|
}
|
|
2497
2497
|
}
|
|
2498
2498
|
} catch {
|
package/commands/pan/army.md
CHANGED
|
@@ -78,13 +78,14 @@ Every cap the conductor enforces applies to the campaign, scaled up:
|
|
|
78
78
|
|------|---------|--------|
|
|
79
79
|
| `--source` | `backlog` | Work selection (delegates to focus-auto): `backlog` = ranked roadmap/requirements items; `scan` = category code-scan. |
|
|
80
80
|
| `--max-cycles` | 5 | Mission items landed before stopping. |
|
|
81
|
-
| `--total-budget` | 300 | Cumulative point
|
|
81
|
+
| `--total-budget` | 300 | Cumulative point budget. **Advisory by default** — tracked/surfaced but not a hard stop unless `--enforce-budget` / config `budget.enforce: true`. |
|
|
82
|
+
| `--enforce-budget` | off | Make the point budgets hard stops again (also settable via config `budget.enforce: true`). |
|
|
82
83
|
| `--squads` | all | Restrict to a subset, e.g. `--squads architecture,build,quality`. |
|
|
83
84
|
| `--no-build-worktrees` | off | Build in the main tree instead of branch-per-agent worktrees (small/serial projects). |
|
|
84
85
|
| `--push` | off | Push approved merges to origin (still human-gated). |
|
|
85
86
|
| `--clean-seal` | off | One clean build + full verification after the last item (commands from config). |
|
|
86
87
|
| `--schedule` | off | Arm a self-resuming campaign at this cadence (`hourly`/`daily`/`weekly`/`Nh`/`Nd`) instead of running once — writes the schedule descriptor (ADR-0034). Pair with `--daily-budget`. |
|
|
87
|
-
| `--daily-budget` | 300 | Per-day point
|
|
88
|
+
| `--daily-budget` | 300 | Per-day point budget for a scheduled campaign. Advisory by default (an indicator of the day's spend); it only pauses the day's run when `budget.enforce`/`enforce_budget` is set. |
|
|
88
89
|
| `--dry-run` | off | Plan + squad delegation preview only; STOP. |
|
|
89
90
|
| `--continue` / `--stop` / `--status` | — | Resume / halt / report from `.planning/orchestration/` + focus-auto state. |
|
|
90
91
|
|
|
@@ -141,14 +142,14 @@ PAN is not a daemon — it cannot wake itself while the session is closed. `--sc
|
|
|
141
142
|
- **Arm:** `/pan:army "<goal>" --schedule daily --daily-budget 200` writes `.planning/orchestration/schedule.json` (cadence, daily budget, next-due) instead of running once.
|
|
142
143
|
- **The trigger (you wire one):** a host scheduler (Claude Code routines / cron / scheduled-tasks) or a `/loop` runs `pan-tools campaign due` and, when it reports due, invokes `/pan:army --continue`. On next session open, a due campaign is surfaced as a nudge.
|
|
143
144
|
- **Resume (`--continue`):** read the schedule + `.planning/orchestration/` + focus-auto state. If `campaign due` is true and the day's `--daily-budget` isn't spent, run the next mission(s), then `campaign record-run` (advances next-due, accrues the day's spend). If not due or budget-spent, report next-due and STOP.
|
|
144
|
-
- **Bounded spend:**
|
|
145
|
+
- **Bounded spend:** point budgets (`--total-budget`, `--daily-budget`) are **advisory indicators by default** — they're tracked and surfaced, not hard stops, unless `budget.enforce` / `--enforce-budget` is set. The real bounds are `--max-cycles`, the conductor caps, the abort file, and the human merge gate at every integrate. A scheduled campaign runs the backlog down to staged, reviewed, green PRs over days.
|
|
145
146
|
|
|
146
147
|
Manage it: `pan-tools campaign status` (active/paused, spent today, next-due), `campaign schedule --pause` / `--resume` / `--disable`.
|
|
147
148
|
|
|
148
149
|
---
|
|
149
150
|
|
|
150
151
|
## Completion contract
|
|
151
|
-
The campaign is complete when ANY holds: `--max-cycles` reached ·
|
|
152
|
+
The campaign is complete when ANY holds: `--max-cycles` reached · backlog empty · abort file present · context < 25% · a mission cannot pass Quality and can't be cleanly reverted (HARD STOP — preserve state, report). (Budget exhaustion is advisory by default — a stop condition only when `budget.enforce`/`--enforce-budget` is set.) Always run `--clean-seal` (unless omitted) after the last item.
|
|
152
153
|
|
|
153
154
|
## NEVER DO
|
|
154
155
|
- Let Mission Control write code, or let a squad agent spawn further agents (depth cap).
|
|
@@ -33,7 +33,7 @@ This command runs improvement campaigns on the **host project's source code**
|
|
|
33
33
|
<completion_contract>
|
|
34
34
|
A campaign is complete when ANY stop condition is met:
|
|
35
35
|
1. Max cycles reached (--max-cycles, default 10)
|
|
36
|
-
2. Total budget exhausted (--total-budget
|
|
36
|
+
2. Total budget exhausted (--total-budget) — **advisory by default**: tracked and surfaced but does NOT stop the run unless `--enforce-budget` (or config `budget.enforce: true`) is set
|
|
37
37
|
3. Scan returns zero items for the selected category
|
|
38
38
|
4. Context window drops below 25% (CRITICAL threshold)
|
|
39
39
|
5. User sends /pan:focus-auto --stop
|
|
@@ -100,7 +100,8 @@ Wait for the user's reply before proceeding. Do not guess or pick a default cate
|
|
|
100
100
|
| `--mode` | category-dependent | bugfix, balanced, features, full |
|
|
101
101
|
| `--budget` | category-dependent | Points per cycle (5-100) |
|
|
102
102
|
| `--max-cycles` | 10 | Maximum iterations (1-50) |
|
|
103
|
-
| `--total-budget` | 500 | Cumulative
|
|
103
|
+
| `--total-budget` | 500 | Cumulative point budget (5-5000). Advisory by default (tracked/surfaced, not a stop). |
|
|
104
|
+
| `--enforce-budget` | off | Make `--total-budget` a hard stop again (also settable via config `budget.enforce: true`). |
|
|
104
105
|
| `--continue` | — | Resume stopped/interrupted run |
|
|
105
106
|
| `--stop` | — | Gracefully stop active run |
|
|
106
107
|
| `--status` | — | Show current campaign progress |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "Command a bot army for your codebase: an Opus Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pan-wizard": "bin/install.js"
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"agents",
|
|
13
13
|
"hooks/dist",
|
|
14
14
|
"scripts",
|
|
15
|
-
"assets"
|
|
15
|
+
"assets",
|
|
16
|
+
"pan-zcode"
|
|
16
17
|
],
|
|
17
18
|
"keywords": [
|
|
18
19
|
"claude",
|
|
@@ -76,6 +76,9 @@ function writeSchedule(cwd, opts, now) {
|
|
|
76
76
|
source: opts.source ?? existing.source ?? 'backlog',
|
|
77
77
|
cadence,
|
|
78
78
|
daily_budget: opts.daily_budget != null ? Number(opts.daily_budget) : (existing.daily_budget ?? 300),
|
|
79
|
+
// Advisory by default: the daily budget is tracked + surfaced but does not block
|
|
80
|
+
// a due run unless explicitly enforced.
|
|
81
|
+
enforce_budget: opts.enforce_budget != null ? Boolean(opts.enforce_budget) : (existing.enforce_budget ?? false),
|
|
79
82
|
enabled: opts.enabled != null ? Boolean(opts.enabled) : (existing.enabled ?? true),
|
|
80
83
|
paused: opts.paused != null ? Boolean(opts.paused) : (existing.paused ?? false),
|
|
81
84
|
next_due: existing.next_due ?? at.toISOString(),
|
|
@@ -108,7 +111,7 @@ function isRunDue(schedule, now) {
|
|
|
108
111
|
const spent = spentToday(schedule, at);
|
|
109
112
|
if (!schedule.enabled) return { due: false, reason: 'disabled', next_due: schedule.next_due, spent_today: spent };
|
|
110
113
|
if (schedule.paused) return { due: false, reason: 'paused', next_due: schedule.next_due, spent_today: spent };
|
|
111
|
-
if (schedule.daily_budget != null && spent >= schedule.daily_budget) {
|
|
114
|
+
if (schedule.enforce_budget && schedule.daily_budget != null && spent >= schedule.daily_budget) {
|
|
112
115
|
return { due: false, reason: 'budget_exhausted_today', next_due: schedule.next_due, spent_today: spent };
|
|
113
116
|
}
|
|
114
117
|
const due = new Date(schedule.next_due);
|
|
@@ -62,6 +62,10 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
|
|
|
62
62
|
default_points: 50,
|
|
63
63
|
micro_threshold_tasks: 3,
|
|
64
64
|
micro_threshold_files: 2,
|
|
65
|
+
// Budget is ADVISORY by default: point/spend caps are tracked and surfaced
|
|
66
|
+
// (HUD, telemetry) but never STOP a run. Set enforce:true (or pass
|
|
67
|
+
// --enforce-budget) to make the cap a hard stop again.
|
|
68
|
+
enforce: false,
|
|
65
69
|
},
|
|
66
70
|
commit: {
|
|
67
71
|
safety_checks: true,
|
|
@@ -47,34 +47,44 @@ const COST_MULTIPLIERS = { reasoning: 15, mid: 3, fast: 1 };
|
|
|
47
47
|
|
|
48
48
|
// ─── Model Profile Table ─────────────────────────────────────────────────────
|
|
49
49
|
|
|
50
|
+
// COST RESET (2026-07): quality + balanced (the default) both resolve to the
|
|
51
|
+
// `reasoning` tier for EVERY agent — i.e. the DEFAULT model you launched with
|
|
52
|
+
// (`inherit`). PAN no longer silently demotes agents to cheaper models; context
|
|
53
|
+
// isolation (each subagent runs in its own window), not a cheaper model, is what
|
|
54
|
+
// keeps the main conversation clean. Cheapness is now OPT-IN: choose the `budget`
|
|
55
|
+
// profile (the only column that still down-tiers) or pin a specific agent via
|
|
56
|
+
// config `model_overrides`. The 3 security agents additionally pin `model: opus`
|
|
57
|
+
// in their own frontmatter (a native, deliberate exception). resolve-model /
|
|
58
|
+
// MODEL_PROFILES is advisory + cost-estimation; native Claude Code delegation
|
|
59
|
+
// reads each agent file's static `model:` (unset → inherit).
|
|
50
60
|
const MODEL_PROFILES = {
|
|
51
61
|
// Original planning/execution agents (pre-v3.0)
|
|
52
62
|
'pan-planner': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
53
|
-
'pan-roadmapper': { quality: 'reasoning', balanced: '
|
|
54
|
-
'pan-executor': { quality: 'reasoning', balanced: '
|
|
55
|
-
'pan-phase-researcher': { quality: 'reasoning', balanced: '
|
|
56
|
-
'pan-project-researcher': { quality: 'reasoning', balanced: '
|
|
57
|
-
'pan-research-synthesizer': { quality: 'reasoning', balanced: '
|
|
58
|
-
'pan-debugger': { quality: 'reasoning', balanced: '
|
|
59
|
-
'pan-document_code': { quality: 'reasoning', balanced: '
|
|
60
|
-
'pan-verifier': { quality: 'reasoning', balanced: '
|
|
61
|
-
'pan-plan-checker': { quality: 'reasoning', balanced: '
|
|
62
|
-
'pan-integration-checker': { quality: 'reasoning', balanced: '
|
|
63
|
-
'pan-reviewer': { quality: 'reasoning', balanced: '
|
|
63
|
+
'pan-roadmapper': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
64
|
+
'pan-executor': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
65
|
+
'pan-phase-researcher': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
66
|
+
'pan-project-researcher': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
67
|
+
'pan-research-synthesizer': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
68
|
+
'pan-debugger': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
69
|
+
'pan-document_code': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
70
|
+
'pan-verifier': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
71
|
+
'pan-plan-checker': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
72
|
+
'pan-integration-checker': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
73
|
+
'pan-reviewer': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
64
74
|
// Spec B v2 agents (v3.0–v3.4) — added v3.7.5 to close MODEL_PROFILES drift
|
|
65
75
|
'pan-conductor': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
66
|
-
'pan-counterfactual': { quality: 'reasoning', balanced: '
|
|
67
|
-
'pan-hardener': { quality: 'reasoning', balanced: '
|
|
68
|
-
'pan-meta-reviewer': { quality: 'reasoning', balanced: '
|
|
69
|
-
'pan-knowledge': { quality: 'reasoning', balanced: '
|
|
70
|
-
'pan-previewer': { quality: 'reasoning', balanced: '
|
|
76
|
+
'pan-counterfactual': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
77
|
+
'pan-hardener': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
78
|
+
'pan-meta-reviewer': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
79
|
+
'pan-knowledge': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
80
|
+
'pan-previewer': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
71
81
|
// v3.5 agents
|
|
72
|
-
'pan-optimizer': { quality: 'reasoning', balanced: '
|
|
73
|
-
'pan-distiller': { quality: 'reasoning', balanced: '
|
|
82
|
+
'pan-optimizer': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
83
|
+
'pan-distiller': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
74
84
|
// v3.7.0 self-improvement loop — observation-only watchdog
|
|
75
|
-
'pan-experiment-runner': { quality: 'reasoning', balanced: '
|
|
85
|
+
'pan-experiment-runner': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
76
86
|
// ADR-0033 bot-army — Release squad
|
|
77
|
-
'pan-release': { quality: 'reasoning', balanced: '
|
|
87
|
+
'pan-release': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
78
88
|
};
|
|
79
89
|
|
|
80
90
|
// ─── Effort Profiles (2026-06, adaptive-thinking era) ───────────────────────
|
|
@@ -279,7 +289,7 @@ function loadConfig(cwd) {
|
|
|
279
289
|
verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
|
|
280
290
|
parallelization,
|
|
281
291
|
brave_search: get('brave_search') ?? defaults.brave_search,
|
|
282
|
-
budget: parsed.budget || { default_points: 50, micro_threshold_tasks: 3, micro_threshold_files: 2 },
|
|
292
|
+
budget: parsed.budget || { default_points: 50, micro_threshold_tasks: 3, micro_threshold_files: 2, enforce: false },
|
|
283
293
|
commit: parsed.commit || { safety_checks: true, conventional_types: true, sensitive_patterns: ['\\.env$', '\\.pem$', '\\.key$', 'credentials', 'secret', 'password', 'token'] },
|
|
284
294
|
execution: parsed.execution || { default_mode: 'wave_order', rollback_snapshots: true, error_pattern_learning: true },
|
|
285
295
|
focus: parsed.focus || { auto_commit: true },
|
|
@@ -863,7 +863,9 @@ function focusAutoCheckpointCommit(cwd, cycle, run) {
|
|
|
863
863
|
|
|
864
864
|
function determineStopReason(cycle, run) {
|
|
865
865
|
if (cycle.tests_after < cycle.tests_before) return 'regression';
|
|
866
|
-
|
|
866
|
+
// Budget is advisory by default — it only STOPS the run when explicitly enforced.
|
|
867
|
+
// Otherwise the overage is tracked/surfaced (indication) and the loop continues.
|
|
868
|
+
if (run.budget_enforce && run.totals.points_used >= run.total_budget) return 'budget_cap';
|
|
867
869
|
if (run.totals.cycles_completed >= run.max_cycles) return 'max_cycles';
|
|
868
870
|
if (cycle.items_completed === 0) {
|
|
869
871
|
// Security category gets a descriptive stop reason rather than generic zero_completed
|
|
@@ -929,6 +931,10 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
|
|
|
929
931
|
const budget = Number(getVal('--budget', String(defaults.budget)));
|
|
930
932
|
const maxCycles = Number(getVal('--max-cycles', String(DEFAULT_MAX_CYCLES)));
|
|
931
933
|
const totalBudget = Number(getVal('--total-budget', String(DEFAULT_TOTAL_BUDGET)));
|
|
934
|
+
// Budget is advisory by default (tracked + surfaced, never a hard stop). Enforce
|
|
935
|
+
// only when the user opts in via config `budget.enforce` or `--enforce-budget`.
|
|
936
|
+
const budgetConfig = loadConfig(cwd).budget || {};
|
|
937
|
+
const budgetEnforce = hasFlag('--enforce-budget') || budgetConfig.enforce === true;
|
|
932
938
|
|
|
933
939
|
if (!FOCUS_MODES.includes(mode)) return error(`Mode must be one of: ${FOCUS_MODES.join(', ')}`);
|
|
934
940
|
if (budget < BUDGET_MIN || budget > BUDGET_MAX) return error(`Budget must be between ${BUDGET_MIN} and ${BUDGET_MAX}`);
|
|
@@ -947,6 +953,7 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
|
|
|
947
953
|
budget_per_cycle: budget,
|
|
948
954
|
max_cycles: maxCycles,
|
|
949
955
|
total_budget: totalBudget,
|
|
956
|
+
budget_enforce: budgetEnforce,
|
|
950
957
|
priority_range: category ? CATEGORY_PRIORITY_RANGE[category] : { min: 0, max: 6 },
|
|
951
958
|
deep_review_enabled: hasFlag('--deep-review'),
|
|
952
959
|
tests_baseline: null,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# PAN-Z — Known Beta Risks & M0 Open Questions
|
|
2
|
+
|
|
3
|
+
ZCode is a fast-moving, closed Beta: three subsystems PAN-Z touches (subagent
|
|
4
|
+
frontmatter, plugin/skill layout, MCP config path) changed within a single ~12-day
|
|
5
|
+
window on ZCode's changelog. This ledger tracks what the design deliberately does **not**
|
|
6
|
+
hard-depend on, and the go/no-go facts that can only be settled on a real ZCode install
|
|
7
|
+
(the **M0 verify spike**). Re-check these before trusting any on-disk format.
|
|
8
|
+
|
|
9
|
+
## M0 — go/no-go (settle empirically on a real ZCode)
|
|
10
|
+
|
|
11
|
+
| # | Question | If NO → fallback (already in the design) |
|
|
12
|
+
|---|----------|------------------------------------------|
|
|
13
|
+
| 1 | Can a **subagent call MCP tools**? | Primary Agent makes every `pan-mcp` call and feeds results into subagent prompts. Determinism unaffected. |
|
|
14
|
+
| 2 | Are **local stdio MCP calls metered** against the MCP-Calls quota? | Read verbs are MCP *resources*, not tools; batch/cache; never make a hosted z.ai MCP load-bearing. |
|
|
15
|
+
| 3 | Exact **subagent frontmatter schema** / plugin layout / MCP config path? | Drive ZCode's own Import / Settings; treat `pan-zcode/lib/convert-agent.cjs` output as a labelled fallback, not a contract. |
|
|
16
|
+
| 4 | Any **file-based / headless** way to register an MCP server, or GUI-only? | `install-zcode.js` emits a bundle + `INSTALL-ZCODE.md`; the human finishes in ZCode's UI. |
|
|
17
|
+
| 5 | Any **lifecycle hook** that can hard-gate a spawn? | Enforce caps at the MCP-tool-call boundary (the orchestrator), not pre-spawn. |
|
|
18
|
+
|
|
19
|
+
## Standing constraints (confirmed by the review)
|
|
20
|
+
|
|
21
|
+
- **The merge gate is not self-sufficient.** Under ZCode Full Access, a raw Bash
|
|
22
|
+
`git push` bypasses `pan_confirm_merge`. Non-bypassability rests on **server-side
|
|
23
|
+
branch protection**; the MCP gate is the second, in-process lock. `INSTALL-ZCODE.md`
|
|
24
|
+
mandates branch protection and "never Full Access during install."
|
|
25
|
+
- **No subagent nesting.** The army flattens to one delegation layer (PAN already caps
|
|
26
|
+
nesting at 2, so this is tolerable). `Task` is dropped from ported subagents.
|
|
27
|
+
- **User-global subagents only.** No repo-scoped rosters or per-project model profiles.
|
|
28
|
+
- **Genuinely lost:** scheduled self-resuming multi-day campaigns (no headless/daemon),
|
|
29
|
+
background execution, committable permissions, and custom slash-commands.
|
|
30
|
+
|
|
31
|
+
## Format-drift policy
|
|
32
|
+
|
|
33
|
+
Do **not** rely on a passive "write the file and hope" strategy. All three churning
|
|
34
|
+
formats are GUI-owned implementation details: prefer ZCode's authoring surfaces, keep the
|
|
35
|
+
converter output clearly labelled best-effort, and re-verify against this ledger on every
|
|
36
|
+
ZCode version bump.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# PAN-Z — a ZCode-native PAN subsystem
|
|
2
|
+
|
|
3
|
+
PAN-Z gives [ZCode](https://zcode.z.ai) (z.ai's GLM-5.2 coding harness) the PAN Wizard
|
|
4
|
+
workflow — the multi-phase lifecycle, the bot-army with a human merge gate, deterministic
|
|
5
|
+
state tracking — **without** cloning PAN's slash-commands or hooks (ZCode has neither).
|
|
6
|
+
|
|
7
|
+
Instead of porting the command surface, PAN-Z **reuses PAN's engine in place** and reaches
|
|
8
|
+
ZCode through the one interface it speaks: **MCP**.
|
|
9
|
+
|
|
10
|
+
> Design + verified feasibility: [`docs/specs/pan_zcode_mcp_bridge_featureai.md`](../docs/specs/pan_zcode_mcp_bridge_featureai.md).
|
|
11
|
+
> The architecture was confirmed by a 38-agent adversarial review; four optimistic
|
|
12
|
+
> assumptions were refuted and their fixes folded into the design.
|
|
13
|
+
|
|
14
|
+
## How it fits
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
ZCode harness (GLM-5.2) primary Agent drives everything; ported subagents fan out
|
|
18
|
+
│ MCP · local stdio
|
|
19
|
+
pan-zcode/mcp (this subsystem) a thin, zero-dep bridge — verbs → MCP tools/resources
|
|
20
|
+
│ spawn: node pan-tools.cjs <verb> --raw --cwd <root>
|
|
21
|
+
pan-wizard-core (reused as-is) the deterministic engine; .planning/ stays the state store
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Status — M1–M5 built (M0 is the human verify spike)
|
|
25
|
+
|
|
26
|
+
- **M1 — bridge core.** `mcp/tool-registry.cjs` (pure verb→tool/resource map, with a hard
|
|
27
|
+
guardrail against exposing a force/reset/rebase/push verb) + `mcp/server.cjs` (a
|
|
28
|
+
**zero-dependency** JSON-RPC 2.0 stdio MCP server; reads → resources, actions → tools with
|
|
29
|
+
accurate hints; shell-less `execFile` spawn; `@file:` overflow protocol; protocol-version
|
|
30
|
+
negotiation; strict per-arg validation).
|
|
31
|
+
- **M2 — determinism grafts.** `mcp/merge-gate.cjs` (two-step, model-proof merge: a human-origin
|
|
32
|
+
env token that ignores agent-supplied approval; never force/reset/push) + `mcp/orchestrator.cjs`
|
|
33
|
+
(the deterministic `next-action` state machine with safety caps + regression circuit-breaker),
|
|
34
|
+
exposed as native MCP tools via `mcp/native-tools.cjs`.
|
|
35
|
+
- **M3 — content port.** `lib/convert-agent.cjs` — Claude agents → ZCode subagents (reusing the
|
|
36
|
+
installer's frontmatter helpers): drops `Task` (no nesting), maps PAN tiers → `inherit`,
|
|
37
|
+
preserves the body; plus a command → skill wrapper.
|
|
38
|
+
- **M4 — bundle + install.** `bin/install-zcode.js` — assembles `agents/` + `pan-mcp.json` +
|
|
39
|
+
manifest + `INSTALL-ZCODE.md` into a `--target` dir; refuses to write inside the source repo;
|
|
40
|
+
drives ZCode's own Import to finish.
|
|
41
|
+
- **M5 — hardening + docs.** Full test matrix + [`KNOWN-BETA-RISKS.md`](KNOWN-BETA-RISKS.md)
|
|
42
|
+
(the beta-churn ledger + the M0 go/no-go questions).
|
|
43
|
+
|
|
44
|
+
Tests: `tests/pan-zcode-mcp.test.cjs`, `tests/pan-zcode-orchestration.test.cjs`,
|
|
45
|
+
`tests/pan-zcode-install.test.cjs`.
|
|
46
|
+
|
|
47
|
+
## Still pending — M0 (needs a real ZCode install)
|
|
48
|
+
|
|
49
|
+
Two go/no-go facts can only be settled empirically: **can a subagent call MCP tools?** and **are
|
|
50
|
+
local stdio MCP calls metered?** Both have folded-in fallbacks (see `KNOWN-BETA-RISKS.md`), so the
|
|
51
|
+
design holds either way — but confirm them before relying on the richer paths.
|
|
52
|
+
|
|
53
|
+
## Zero dependencies
|
|
54
|
+
|
|
55
|
+
Like the rest of PAN, this subsystem ships **no runtime dependencies**. The MCP protocol is
|
|
56
|
+
implemented directly rather than via an SDK. If protocol drift ever makes that costly, the
|
|
57
|
+
escape hatch is to vendor an MCP SDK **inside this package only**, leaving `pan-wizard-core`
|
|
58
|
+
untouched.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PAN-Z M4 — assemble the ZCode bundle and emit a pull-based install.
|
|
6
|
+
*
|
|
7
|
+
* Produces, into an explicit --target directory:
|
|
8
|
+
* agents/*.md PAN agents converted to ZCode subagents (best-effort)
|
|
9
|
+
* pan-mcp.json the MCP server registration (command/args/env)
|
|
10
|
+
* pan-zcode-manifest.json what was generated
|
|
11
|
+
* INSTALL-ZCODE.md how to finish setup inside ZCode
|
|
12
|
+
*
|
|
13
|
+
* It NEVER writes to a real ~/.zcode on its own and NEVER writes inside the PAN
|
|
14
|
+
* source repo — ZCode's on-disk formats churn weekly, so the human finishes setup by
|
|
15
|
+
* driving ZCode's own "Import from Claude Code" / Settings surfaces using this bundle.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { convertAgentsDir } = require('../lib/convert-agent.cjs');
|
|
21
|
+
|
|
22
|
+
/** The MCP registration ZCode reads (Claude-compatible: command/args/env). */
|
|
23
|
+
function mcpServerConfig(panToolsPath, serverPath, projectRoot) {
|
|
24
|
+
return {
|
|
25
|
+
mcpServers: {
|
|
26
|
+
'pan-mcp': {
|
|
27
|
+
command: 'node',
|
|
28
|
+
args: [serverPath],
|
|
29
|
+
env: { PAN_TOOLS_PATH: panToolsPath, PAN_PROJECT_ROOT: projectRoot || '.' },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const INSTRUCTIONS = `# Install PAN-Z into ZCode
|
|
36
|
+
|
|
37
|
+
PAN-Z bridges PAN's engine to ZCode over MCP. Finish setup inside ZCode:
|
|
38
|
+
|
|
39
|
+
1. **Register the MCP server.** Add the contents of \`pan-mcp.json\` to ZCode's MCP
|
|
40
|
+
configuration (Settings → MCP), or point ZCode's MCP config at this file. This
|
|
41
|
+
exposes the \`pan_*\` tools (state, plan, verify, report, next-action, merge gate).
|
|
42
|
+
|
|
43
|
+
2. **Import the subagents.** The \`agents/\` folder holds PAN agents in ZCode subagent
|
|
44
|
+
form. Prefer ZCode's **Import from Claude Code** / Settings → Subagents to register
|
|
45
|
+
them, since ZCode owns the on-disk format (it is a fast-moving Beta). These files are
|
|
46
|
+
a fallback, not a contract.
|
|
47
|
+
|
|
48
|
+
3. **Protect your branches.** The merge gate (\`pan_confirm_merge\`) is one lock; the
|
|
49
|
+
real, non-bypassable one is **server-side branch protection** on your remote. Enable
|
|
50
|
+
required reviews so a raw push under Full Access cannot merge. Approve a staged merge
|
|
51
|
+
by setting \`PAN_MERGE_APPROVAL=<request-id>\` in the MCP server's environment.
|
|
52
|
+
|
|
53
|
+
4. **Never run the install/import step in ZCode Full Access mode.**
|
|
54
|
+
|
|
55
|
+
Not yet resolved (the M0 verify spike, on your real ZCode): whether a subagent can call
|
|
56
|
+
MCP tools, and whether local stdio MCP calls are metered. Until confirmed, the primary
|
|
57
|
+
Agent makes every pan-mcp call.
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
/** Real path of the nearest EXISTING ancestor of p, with the not-yet-created tail re-attached. */
|
|
61
|
+
function realpathNearest(p) {
|
|
62
|
+
const tail = [];
|
|
63
|
+
let cur = path.resolve(p);
|
|
64
|
+
for (;;) {
|
|
65
|
+
try {
|
|
66
|
+
const real = fs.realpathSync(cur);
|
|
67
|
+
return tail.length ? path.join(real, ...tail) : real;
|
|
68
|
+
} catch {
|
|
69
|
+
const parent = path.dirname(cur);
|
|
70
|
+
if (parent === cur) return path.resolve(p); // hit the root; nothing existed
|
|
71
|
+
tail.unshift(path.basename(cur));
|
|
72
|
+
cur = parent;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertNotInSourceRepo(destDir, repoRoot) {
|
|
78
|
+
// Resolve symlinks/junctions (a --target under a junction into the repo would else
|
|
79
|
+
// slip past) and case-fold on case-insensitive filesystems (NTFS/APFS) so a
|
|
80
|
+
// case-variant path can't bypass the guard.
|
|
81
|
+
const ci = process.platform === 'win32' || process.platform === 'darwin';
|
|
82
|
+
const norm = (p) => { const r = realpathNearest(p); return ci ? r.toLowerCase() : r; };
|
|
83
|
+
const d = norm(destDir);
|
|
84
|
+
const r = norm(repoRoot);
|
|
85
|
+
if (d === r || d.startsWith(r + path.sep)) {
|
|
86
|
+
throw new Error('Refusing to write the PAN-Z bundle inside the PAN source repo. Choose a --target outside it.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the bundle.
|
|
92
|
+
* @param {{repoRoot:string, destDir:string, projectRoot?:string}} o
|
|
93
|
+
* @returns {{agents:number, destDir:string, mcp:Object, files:string[]}}
|
|
94
|
+
*/
|
|
95
|
+
function buildBundle(o) {
|
|
96
|
+
const repoRoot = path.resolve(o.repoRoot);
|
|
97
|
+
const destDir = path.resolve(o.destDir);
|
|
98
|
+
assertNotInSourceRepo(destDir, repoRoot);
|
|
99
|
+
|
|
100
|
+
const agentsSrc = path.join(repoRoot, 'agents');
|
|
101
|
+
const serverPath = path.join(repoRoot, 'pan-zcode', 'mcp', 'server.cjs');
|
|
102
|
+
const panToolsPath = path.join(repoRoot, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
|
|
103
|
+
|
|
104
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
105
|
+
const agents = fs.existsSync(agentsSrc) ? convertAgentsDir(agentsSrc, path.join(destDir, 'agents')) : [];
|
|
106
|
+
|
|
107
|
+
const mcp = mcpServerConfig(panToolsPath, serverPath, o.projectRoot);
|
|
108
|
+
fs.writeFileSync(path.join(destDir, 'pan-mcp.json'), JSON.stringify(mcp, null, 2), 'utf8');
|
|
109
|
+
|
|
110
|
+
const manifest = {
|
|
111
|
+
subsystem: 'pan-zcode',
|
|
112
|
+
agents: agents.map((a) => a.name),
|
|
113
|
+
mcp_config: 'pan-mcp.json',
|
|
114
|
+
server: serverPath,
|
|
115
|
+
pan_tools: panToolsPath,
|
|
116
|
+
};
|
|
117
|
+
fs.writeFileSync(path.join(destDir, 'pan-zcode-manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
118
|
+
fs.writeFileSync(path.join(destDir, 'INSTALL-ZCODE.md'), INSTRUCTIONS, 'utf8');
|
|
119
|
+
|
|
120
|
+
return { agents: agents.length, destDir, mcp, files: ['agents/', 'pan-mcp.json', 'pan-zcode-manifest.json', 'INSTALL-ZCODE.md'] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getArg(args, name) {
|
|
124
|
+
const i = args.indexOf(name);
|
|
125
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function main() {
|
|
129
|
+
const args = process.argv.slice(2);
|
|
130
|
+
const target = getArg(args, '--target');
|
|
131
|
+
if (!target) {
|
|
132
|
+
console.error('Usage: install-zcode.js --target <dir> [--project-root <dir>]');
|
|
133
|
+
console.error('Writes the PAN-Z ZCode bundle to <dir> (outside the PAN source repo).');
|
|
134
|
+
process.exit(2);
|
|
135
|
+
}
|
|
136
|
+
const repoRoot = path.resolve(__dirname, '..', '..');
|
|
137
|
+
try {
|
|
138
|
+
const res = buildBundle({ repoRoot, destDir: target, projectRoot: getArg(args, '--project-root') });
|
|
139
|
+
console.log(`pan-zcode bundle written to ${res.destDir} (${res.agents} subagents + MCP config).`);
|
|
140
|
+
console.log('Next: open INSTALL-ZCODE.md and finish setup inside ZCode.');
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(`install-zcode: ${e.message}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (require.main === module) main();
|
|
148
|
+
|
|
149
|
+
module.exports = { buildBundle, mcpServerConfig, assertNotInSourceRepo };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PAN-Z M3 — content port: Claude agent/command markdown → ZCode subagent/skill.
|
|
5
|
+
*
|
|
6
|
+
* Reuses the installer's frontmatter helpers (bin/install-lib.cjs) so the parsing
|
|
7
|
+
* matches every other runtime converter. ZCode stores subagents as Markdown at
|
|
8
|
+
* ~/.zcode/agents/<name>.md; the exact frontmatter schema is docs-silent on a
|
|
9
|
+
* weekly-churning Beta, so this emits a conservative, clearly-labelled best-effort
|
|
10
|
+
* shape. The installer PREFERS ZCode's own "Import from Claude Code" surface where
|
|
11
|
+
* available (see install-zcode.js / the M0 verify spike) and treats these files as a
|
|
12
|
+
* fallback, not a contract.
|
|
13
|
+
*
|
|
14
|
+
* Two structural facts from the review are honored here:
|
|
15
|
+
* - No subagent nesting → the `Task` tool (PAN's delegation primitive) is dropped;
|
|
16
|
+
* a ported subagent is a leaf worker, and the primary Agent orchestrates.
|
|
17
|
+
* - Per-subagent model selection exists but PAN's tier aliases (opus/sonnet/haiku)
|
|
18
|
+
* are not ZCode model ids → model maps to "inherit" (ZCode's "Inherit default"),
|
|
19
|
+
* preserving the original tier as a hint comment.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { extractFrontmatterAndBody, extractFrontmatterField } = require('../../bin/install-lib.cjs');
|
|
25
|
+
|
|
26
|
+
/** Tools that must not survive the port (no nesting; MCP names are host-specific). */
|
|
27
|
+
function remapTools(toolsCsv) {
|
|
28
|
+
if (!toolsCsv) return null;
|
|
29
|
+
const kept = toolsCsv.split(',').map((t) => t.trim()).filter(Boolean)
|
|
30
|
+
.filter((t) => t !== 'Task' && !t.startsWith('mcp__'));
|
|
31
|
+
return kept.length ? kept.join(', ') : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Convert one Claude agent markdown string → a ZCode subagent markdown string. */
|
|
35
|
+
function convertClaudeToZcodeAgent(content) {
|
|
36
|
+
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
|
37
|
+
const name = (frontmatter && extractFrontmatterField(frontmatter, 'name')) || 'pan-agent';
|
|
38
|
+
const description = (frontmatter && extractFrontmatterField(frontmatter, 'description')) || '';
|
|
39
|
+
const color = frontmatter && extractFrontmatterField(frontmatter, 'color');
|
|
40
|
+
const tierHint = frontmatter && extractFrontmatterField(frontmatter, 'model');
|
|
41
|
+
const tools = remapTools(frontmatter && extractFrontmatterField(frontmatter, 'tools'));
|
|
42
|
+
|
|
43
|
+
const fm = ['---', `name: ${name}`, `description: ${description}`];
|
|
44
|
+
// PAN tiers aren't ZCode model ids → inherit the primary Agent's model.
|
|
45
|
+
fm.push('model: inherit');
|
|
46
|
+
if (tierHint) fm.push(`# pan-tier: ${tierHint} (original PAN model tier; mapped to inherit)`);
|
|
47
|
+
if (color) fm.push(`color: ${color}`);
|
|
48
|
+
if (tools) fm.push(`tools: ${tools}`);
|
|
49
|
+
fm.push('---');
|
|
50
|
+
|
|
51
|
+
return `${fm.join('\n')}\n${String(body).trimStart()}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Wrap a Claude /pan command markdown as a ZCode skill doc (best-effort). */
|
|
55
|
+
function convertClaudeCommandToZcodeSkill(content, commandName) {
|
|
56
|
+
const { body } = extractFrontmatterAndBody(content);
|
|
57
|
+
const header = [
|
|
58
|
+
'---',
|
|
59
|
+
`name: pan-${commandName}`,
|
|
60
|
+
`description: PAN ${commandName} workflow (invoke via the pan-mcp tools + this playbook).`,
|
|
61
|
+
'---',
|
|
62
|
+
'',
|
|
63
|
+
`> ZCode has no custom slash-commands. This skill carries the ${commandName} playbook;`,
|
|
64
|
+
'> its deterministic steps run through the pan-mcp tools (see the pan_* tool list).',
|
|
65
|
+
'',
|
|
66
|
+
].join('\n');
|
|
67
|
+
return header + String(body).trimStart();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convert every agents/*.md under srcDir into ZCode subagent files under destDir.
|
|
72
|
+
* @returns {Array<{name:string, dest:string}>}
|
|
73
|
+
*/
|
|
74
|
+
function convertAgentsDir(srcDir, destDir) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const files = fs.readdirSync(srcDir).filter((f) => f.endsWith('.md'));
|
|
77
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
78
|
+
for (const f of files) {
|
|
79
|
+
const content = fs.readFileSync(path.join(srcDir, f), 'utf8');
|
|
80
|
+
const converted = convertClaudeToZcodeAgent(content);
|
|
81
|
+
const dest = path.join(destDir, f);
|
|
82
|
+
fs.writeFileSync(dest, converted, 'utf8');
|
|
83
|
+
out.push({ name: f.replace(/\.md$/, ''), dest });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
convertClaudeToZcodeAgent, convertClaudeCommandToZcodeSkill, convertAgentsDir, remapTools,
|
|
90
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PAN-Z M2 — the deterministic, model-proof merge gate.
|
|
5
|
+
*
|
|
6
|
+
* PAN's non-negotiable rule: nothing reaches a protected branch without a human.
|
|
7
|
+
* Enforcing this in agent PROSE is not enough (under ZCode Full Access a raw
|
|
8
|
+
* `git push` sidesteps any advisory check), so the gate lives here, in code, on TWO
|
|
9
|
+
* locks:
|
|
10
|
+
*
|
|
11
|
+
* Lock 1 (this module): `confirmMerge` refuses unless CI green + verify PASS + a
|
|
12
|
+
* HUMAN-ORIGIN approval token all hold. The token is a PER-REQUEST NONCE returned
|
|
13
|
+
* by `requestMerge`; the operator, after reviewing, sets PAN_MERGE_APPROVAL to it
|
|
14
|
+
* in the MCP server's environment (which the agent's tools cannot write). Because
|
|
15
|
+
* the token is fresh per request, it can't be replayed against a later re-request;
|
|
16
|
+
* approval is bound to the EXACT branch that was staged, and merging uses the
|
|
17
|
+
* recorded branch, not the caller's argument. The token is consumed on success.
|
|
18
|
+
* Lock 2 (out of process, documented): server-side branch protection — the truly
|
|
19
|
+
* non-bypassable anchor. See docs/specs/pan_zcode_mcp_bridge_featureai.md.
|
|
20
|
+
*
|
|
21
|
+
* NEVER exposes force-push / reset / rebase / history-rewrite. Recovery is revert-only.
|
|
22
|
+
* Git execution is injected so the decision logic is unit-testable.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
const APPROVALS_SUBPATH = ['.planning', 'orchestration', 'approvals'];
|
|
30
|
+
const APPROVAL_ENV = 'PAN_MERGE_APPROVAL';
|
|
31
|
+
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$/;
|
|
32
|
+
|
|
33
|
+
function approvalsDir(cwd) { return path.join(cwd, ...APPROVALS_SUBPATH); }
|
|
34
|
+
|
|
35
|
+
/** Filesystem-safe slug used only to LOCATE a branch's request file (not as the token). */
|
|
36
|
+
function slugify(branch) {
|
|
37
|
+
return String(branch).replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase().slice(0, 80) || 'merge';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateBranch(branch) {
|
|
41
|
+
if (typeof branch !== 'string' || !BRANCH_RE.test(branch)) {
|
|
42
|
+
throw new Error(`Invalid branch: must match ${BRANCH_RE}`);
|
|
43
|
+
}
|
|
44
|
+
return branch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recordPath(cwd, branch) { return path.join(approvalsDir(cwd), `${slugify(branch)}.json`); }
|
|
48
|
+
function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
|
|
49
|
+
function writeJson(p, obj) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8'); }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stage a merge request. Mints a fresh per-request approval token (nonce), records the
|
|
53
|
+
* exact branch + the (caller-attested) CI/verify flags, marks it awaiting_approval, and
|
|
54
|
+
* returns the record. Does NOT merge. The operator sets PAN_MERGE_APPROVAL to
|
|
55
|
+
* `record.approval_token` after reviewing. (ci_green/verify_pass are agent-attested
|
|
56
|
+
* advisory context for the human's decision; the token is the sole unfakeable anchor.)
|
|
57
|
+
*/
|
|
58
|
+
function requestMerge(cwd, opts = {}) {
|
|
59
|
+
const branch = validateBranch(opts.branch);
|
|
60
|
+
const record = {
|
|
61
|
+
id: slugify(branch),
|
|
62
|
+
branch,
|
|
63
|
+
approval_token: crypto.randomBytes(9).toString('hex'),
|
|
64
|
+
ci_green: !!opts.ci_green,
|
|
65
|
+
verify_pass: !!opts.verify_pass,
|
|
66
|
+
status: 'awaiting_approval',
|
|
67
|
+
requested_at: opts.now || null,
|
|
68
|
+
};
|
|
69
|
+
writeJson(recordPath(cwd, branch), record);
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Evaluate whether a merge may proceed — pure decision logic, never merges.
|
|
75
|
+
* The only human signal is env[PAN_MERGE_APPROVAL] === the request's fresh token; any
|
|
76
|
+
* agent-supplied approval in `opts` is deliberately not consulted. Approval is bound to
|
|
77
|
+
* the exact staged branch and refused once the request has been consumed.
|
|
78
|
+
* @returns {{allowed:boolean, reasons:string[], branch:string, id:string}}
|
|
79
|
+
*/
|
|
80
|
+
function evaluateMerge(cwd, opts = {}, env = process.env) {
|
|
81
|
+
const branch = validateBranch(opts.branch);
|
|
82
|
+
const record = readJson(recordPath(cwd, branch));
|
|
83
|
+
const reasons = [];
|
|
84
|
+
if (!record) reasons.push('no_merge_request');
|
|
85
|
+
if (record && record.branch !== branch) reasons.push('branch_mismatch');
|
|
86
|
+
if (record && record.status !== 'awaiting_approval') reasons.push('already_consumed');
|
|
87
|
+
if (record && !record.ci_green) reasons.push('ci_not_green');
|
|
88
|
+
if (record && !record.verify_pass) reasons.push('verify_not_passed');
|
|
89
|
+
const token = env[APPROVAL_ENV];
|
|
90
|
+
if (!record || !token || token !== record.approval_token) reasons.push('no_human_approval');
|
|
91
|
+
return { allowed: reasons.length === 0, reasons, branch: record ? record.branch : branch, id: record ? record.id : slugify(branch) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Confirm + perform the squash-merge, but only if evaluateMerge allows it. Merges the
|
|
96
|
+
* RECORDED (approved) branch — never the caller's argument. Git is injected:
|
|
97
|
+
* gitImpl(argvArray)->{ok,stdout,stderr}. No force/reset/rebase/push verb is ever issued.
|
|
98
|
+
* On success the request is marked consumed and the human token is cleared from `env`, so
|
|
99
|
+
* it cannot be replayed without a fresh human approval.
|
|
100
|
+
*/
|
|
101
|
+
function confirmMerge(cwd, opts = {}, env = process.env, gitImpl = null) {
|
|
102
|
+
const verdict = evaluateMerge(cwd, opts, env);
|
|
103
|
+
if (!verdict.allowed) return { merged: false, reasons: verdict.reasons, branch: verdict.branch, id: verdict.id };
|
|
104
|
+
if (typeof gitImpl !== 'function') return { merged: false, reasons: ['no_git_executor'], branch: verdict.branch, id: verdict.id };
|
|
105
|
+
|
|
106
|
+
const approvedBranch = verdict.branch; // from the record, not opts
|
|
107
|
+
const squash = gitImpl(['merge', '--squash', approvedBranch]);
|
|
108
|
+
if (!squash.ok) return { merged: false, reasons: ['git_merge_failed'], detail: squash.stderr, branch: approvedBranch, id: verdict.id };
|
|
109
|
+
const commit = gitImpl(['commit', '--no-verify', '-m', `merge: ${approvedBranch} (human-approved)`]);
|
|
110
|
+
if (!commit.ok) return { merged: false, reasons: ['git_commit_failed'], detail: commit.stderr, branch: approvedBranch, id: verdict.id };
|
|
111
|
+
|
|
112
|
+
// Consume: mark the request merged AND clear the one-time token so it can't be reused.
|
|
113
|
+
const p = recordPath(cwd, approvedBranch);
|
|
114
|
+
const rec = readJson(p) || { id: verdict.id, branch: approvedBranch };
|
|
115
|
+
rec.status = 'merged';
|
|
116
|
+
writeJson(p, rec);
|
|
117
|
+
if (env && Object.prototype.hasOwnProperty.call(env, APPROVAL_ENV)) delete env[APPROVAL_ENV];
|
|
118
|
+
|
|
119
|
+
return { merged: true, branch: approvedBranch, id: verdict.id };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
requestMerge, evaluateMerge, confirmMerge,
|
|
124
|
+
slugify, validateBranch, approvalsDir,
|
|
125
|
+
APPROVAL_ENV, BRANCH_RE,
|
|
126
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
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 mergeGate = require('./merge-gate.cjs');
|
|
13
|
+
const orchestrator = require('./orchestrator.cjs');
|
|
14
|
+
|
|
15
|
+
const NATIVE_TOOLS = [
|
|
16
|
+
{
|
|
17
|
+
name: 'pan_next_action',
|
|
18
|
+
title: 'Next deterministic action',
|
|
19
|
+
description: 'Given the current phase/run snapshot, return the next step the primary agent should take (plan/execute/verify/request_merge/await_approval/stop). Enforces the safety caps and the regression circuit-breaker.',
|
|
20
|
+
readOnly: true, destructive: false,
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object', additionalProperties: false, required: ['state'],
|
|
23
|
+
properties: { state: { type: 'object' }, caps: { type: 'object' } },
|
|
24
|
+
},
|
|
25
|
+
handler: ({ input }) => {
|
|
26
|
+
if (!input.state || typeof input.state !== 'object') throw new Error('Invalid "state": an object snapshot is required');
|
|
27
|
+
return { json: orchestrator.nextAction(input.state, input.caps) };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'pan_request_merge',
|
|
32
|
+
title: 'Request a gated merge',
|
|
33
|
+
description: 'Stage a squash-merge request for a branch and mark it awaiting human approval. Records intent only — does NOT merge.',
|
|
34
|
+
readOnly: false, destructive: false,
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: 'object', additionalProperties: false, required: ['branch'],
|
|
37
|
+
properties: { branch: { type: 'string' }, ci_green: { type: 'boolean' }, verify_pass: { type: 'boolean' } },
|
|
38
|
+
},
|
|
39
|
+
handler: ({ cwd, input }) => ({
|
|
40
|
+
json: mergeGate.requestMerge(cwd, { branch: input.branch, ci_green: input.ci_green, verify_pass: input.verify_pass }),
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'pan_confirm_merge',
|
|
45
|
+
title: 'Confirm a human-approved merge',
|
|
46
|
+
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 id) is present. Any agent-supplied approval is ignored; never force-pushes or rewrites history.',
|
|
47
|
+
readOnly: false, destructive: true,
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object', additionalProperties: false, required: ['branch'],
|
|
50
|
+
properties: { branch: { type: 'string' } },
|
|
51
|
+
},
|
|
52
|
+
handler: ({ cwd, input, env, gitImpl }) => {
|
|
53
|
+
const res = mergeGate.confirmMerge(cwd, { branch: input.branch }, env, gitImpl);
|
|
54
|
+
// A refused gate (missing approval / CI / verify) is a normal, non-error result the
|
|
55
|
+
// agent should read; only a real git failure is flagged isError.
|
|
56
|
+
const gitFailed = !res.merged && Array.isArray(res.reasons)
|
|
57
|
+
&& res.reasons.some((r) => r === 'git_merge_failed' || r === 'git_commit_failed');
|
|
58
|
+
return { json: res, isError: gitFailed };
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
module.exports = { NATIVE_TOOLS };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PAN-Z M2 — the deterministic orchestrator ("next-action" state machine).
|
|
5
|
+
*
|
|
6
|
+
* ZCode has no workflow engine and cannot machine-intercept a subagent spawn, so
|
|
7
|
+
* PAN's sequencing + safety harness (waves, regression circuit-breaker, spawn/budget
|
|
8
|
+
* caps, the human merge gate) cannot live in agent prose or a pre-spawn hook. The
|
|
9
|
+
* review's fix: relocate the state machine here and expose ONE `next-action` tool the
|
|
10
|
+
* primary Agent polls before each step. Enforcement then happens at the (gateable)
|
|
11
|
+
* MCP-tool-call boundary, not at the (un-gateable) spawn event.
|
|
12
|
+
*
|
|
13
|
+
* `nextAction` is a PURE function of a snapshot the caller assembles from PAN's own
|
|
14
|
+
* state (via the pan-mcp resources) — so it is fully unit-testable and its decisions
|
|
15
|
+
* are reproducible.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Budget is advisory by default (enforceBudget:false) — it never stops the loop
|
|
19
|
+
// unless the caller opts in. maxCycles remains a hard safety stop.
|
|
20
|
+
const DEFAULT_CAPS = { maxCycles: 25, budget: Infinity, enforceBudget: false };
|
|
21
|
+
const PHASE_NEXT = {
|
|
22
|
+
none: 'plan',
|
|
23
|
+
researched: 'plan',
|
|
24
|
+
planned: 'execute',
|
|
25
|
+
executed: 'verify',
|
|
26
|
+
verified: 'request_merge',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decide the next deterministic action.
|
|
31
|
+
* @param {Object} state snapshot:
|
|
32
|
+
* { phases:[{number,status}], cycles?, points_used?, tests_before?, tests_after?,
|
|
33
|
+
* awaiting_approval?:boolean, aborted?:boolean }
|
|
34
|
+
* @param {Object} [caps] { maxCycles, budget }
|
|
35
|
+
* @returns {{action:string, args?:Object, reason:string, done:boolean}}
|
|
36
|
+
* action ∈ plan | execute | verify | request_merge | await_approval | stop
|
|
37
|
+
*/
|
|
38
|
+
function nextAction(state, caps) {
|
|
39
|
+
const c = Object.assign({}, DEFAULT_CAPS, caps || {});
|
|
40
|
+
state = state || {};
|
|
41
|
+
|
|
42
|
+
// Hard stops first — safety caps and the circuit-breaker outrank all progress.
|
|
43
|
+
if (state.aborted) return { action: 'stop', reason: 'aborted', done: true };
|
|
44
|
+
if (
|
|
45
|
+
typeof state.tests_before === 'number' &&
|
|
46
|
+
typeof state.tests_after === 'number' &&
|
|
47
|
+
state.tests_after < state.tests_before
|
|
48
|
+
) {
|
|
49
|
+
return { action: 'stop', reason: 'regression', done: true };
|
|
50
|
+
}
|
|
51
|
+
if ((state.cycles || 0) >= c.maxCycles) return { action: 'stop', reason: 'max_cycles', done: true };
|
|
52
|
+
if (c.enforceBudget && (state.points_used || 0) >= c.budget) return { action: 'stop', reason: 'budget_cap', done: true };
|
|
53
|
+
|
|
54
|
+
// The human merge gate is a barrier: while a merge awaits approval, do nothing else.
|
|
55
|
+
if (state.awaiting_approval) return { action: 'await_approval', reason: 'human_gate', done: false };
|
|
56
|
+
|
|
57
|
+
// Advance the first phase that isn't complete.
|
|
58
|
+
const phases = Array.isArray(state.phases) ? state.phases : [];
|
|
59
|
+
const phase = phases.find((p) => p && p.status !== 'complete');
|
|
60
|
+
if (!phase) return { action: 'stop', reason: 'all_complete', done: true };
|
|
61
|
+
|
|
62
|
+
const action = PHASE_NEXT[phase.status] || 'plan';
|
|
63
|
+
return { action, args: { phase: phase.number }, reason: `phase_${phase.status}`, done: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { nextAction, DEFAULT_CAPS, PHASE_NEXT };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PAN-Z MCP bridge server (M1).
|
|
5
|
+
*
|
|
6
|
+
* A dependency-free JSON-RPC 2.0 server over stdio implementing the small MCP
|
|
7
|
+
* surface ZCode needs: initialize / tools/list / tools/call / resources/list /
|
|
8
|
+
* resources/read / ping. Each pan-tools verb is reached by spawning
|
|
9
|
+
* node <pan-tools.cjs> <verb> [args] --raw --cwd <root>
|
|
10
|
+
* and returning its JSON — the CLI's JSON contract IS the tool contract, so the
|
|
11
|
+
* PAN engine (pan-wizard-core) is reused byte-for-byte with no refactor.
|
|
12
|
+
*
|
|
13
|
+
* Zero runtime dependencies: PAN is a zero-dep project, so the MCP protocol is
|
|
14
|
+
* hand-rolled rather than pulled from @modelcontextprotocol/sdk. `handle()` is a
|
|
15
|
+
* pure function of the request given an injected spawn impl, which makes the whole
|
|
16
|
+
* protocol layer unit-testable without stdio or a child process.
|
|
17
|
+
*
|
|
18
|
+
* Security: the child is launched with execFile (argv array, NO shell), the verb
|
|
19
|
+
* is always chosen from the registry allowlist, and every tool argument is
|
|
20
|
+
* validated to a strict shape by the registry before it becomes an argv element.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { execFileSync } = require('child_process');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const reg = require('./tool-registry.cjs');
|
|
28
|
+
|
|
29
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
30
|
+
// Versions whose method shapes this server actually implements. During
|
|
31
|
+
// `initialize` we echo the client's requested version only if it's one of these,
|
|
32
|
+
// otherwise we answer with our latest — never claim to speak a version we don't.
|
|
33
|
+
const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']);
|
|
34
|
+
const SERVER_INFO = { name: 'pan-mcp', version: '0.1.0' };
|
|
35
|
+
|
|
36
|
+
/** Default engine location: pan-wizard-core is a sibling of pan-zcode/. */
|
|
37
|
+
function defaultPanToolsPath() {
|
|
38
|
+
return path.join(__dirname, '..', '..', 'pan-wizard-core', 'bin', 'pan-tools.cjs');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Real spawn: shell-less execFile of `node <argv...>`. */
|
|
42
|
+
function defaultSpawn(nodeArgs) {
|
|
43
|
+
try {
|
|
44
|
+
const stdout = execFileSync('node', nodeArgs, {
|
|
45
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024,
|
|
46
|
+
});
|
|
47
|
+
return { ok: true, stdout: String(stdout).trim(), stderr: '' };
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
stdout: e.stdout ? String(e.stdout).trim() : '',
|
|
52
|
+
stderr: e.stderr ? String(e.stderr).trim() : (e.message || 'spawn failed'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve pan-tools' large-payload overflow protocol. When a JSON result exceeds
|
|
59
|
+
* ~50KB, `output()` (core.cjs) writes it to a private tmpfile and prints
|
|
60
|
+
* `@file:<path>` instead. The bridge reads that file back so the MCP client always
|
|
61
|
+
* receives the actual JSON. Only engine-written files under the system tmpdir named
|
|
62
|
+
* out.json are honored (the path comes from our own engine, not from tool input,
|
|
63
|
+
* but this keeps the read narrowly scoped); the tmp dir is cleaned up after read.
|
|
64
|
+
*/
|
|
65
|
+
function resolveOverflow(stdout) {
|
|
66
|
+
if (typeof stdout !== 'string' || !stdout.startsWith('@file:')) return stdout;
|
|
67
|
+
const real = path.resolve(stdout.slice(6).trim());
|
|
68
|
+
const tmpRoot = path.resolve(os.tmpdir());
|
|
69
|
+
if (!real.startsWith(tmpRoot + path.sep) || path.basename(real) !== 'out.json') return stdout;
|
|
70
|
+
try {
|
|
71
|
+
const text = fs.readFileSync(real, 'utf8');
|
|
72
|
+
try { fs.rmSync(path.dirname(real), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
73
|
+
return text.trim();
|
|
74
|
+
} catch {
|
|
75
|
+
return stdout;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rpcResult(id, result) { return { jsonrpc: '2.0', id, result }; }
|
|
80
|
+
function rpcError(id, code, message) { return { jsonrpc: '2.0', id, error: { code, message } }; }
|
|
81
|
+
|
|
82
|
+
function toMcpTool(t) {
|
|
83
|
+
return {
|
|
84
|
+
name: t.name, description: t.description, inputSchema: t.inputSchema,
|
|
85
|
+
annotations: { title: t.title, readOnlyHint: !!t.readOnly, destructiveHint: !!t.destructive },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function toMcpResource(r) {
|
|
89
|
+
return { uri: r.uri, name: r.name, description: r.description, mimeType: 'application/json' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a server instance.
|
|
94
|
+
* @param {{panToolsPath?:string, cwd?:string, spawnImpl?:Function}} opts
|
|
95
|
+
* spawnImpl(nodeArgs)->{ok,stdout,stderr} is injectable for tests.
|
|
96
|
+
*/
|
|
97
|
+
/** Shell-less git executor bound to a cwd, for native merge-gate tools. */
|
|
98
|
+
function makeDefaultGit(cwd) {
|
|
99
|
+
return function git(gitArgs) {
|
|
100
|
+
try {
|
|
101
|
+
const stdout = execFileSync('git', gitArgs, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
102
|
+
return { ok: true, stdout: String(stdout).trim(), stderr: '' };
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { ok: false, stdout: '', stderr: e.stderr ? String(e.stderr).trim() : (e.message || 'git failed') };
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createServer(opts = {}) {
|
|
110
|
+
const panToolsPath = opts.panToolsPath || process.env.PAN_TOOLS_PATH || defaultPanToolsPath();
|
|
111
|
+
const cwd = opts.cwd || process.env.PAN_PROJECT_ROOT || process.cwd();
|
|
112
|
+
const spawn = opts.spawnImpl || defaultSpawn;
|
|
113
|
+
const gitImpl = opts.gitImpl || makeDefaultGit(cwd);
|
|
114
|
+
const env = opts.env || process.env;
|
|
115
|
+
|
|
116
|
+
function runVerb(verb, extraArgs) {
|
|
117
|
+
// Defense in depth: the verb always comes from the registry, but re-check the
|
|
118
|
+
// forbidden pattern here so no future caller can smuggle a force/reset op past it.
|
|
119
|
+
if (reg.FORBIDDEN_VERB.test(verb)) {
|
|
120
|
+
return { ok: false, stdout: '', stderr: `Refused: verb "${verb}" is not permitted` };
|
|
121
|
+
}
|
|
122
|
+
// No --raw: pan-tools' default output is structured JSON (which is what the MCP
|
|
123
|
+
// client wants); --raw would instead emit a bare human scalar. Large results
|
|
124
|
+
// arrive via the @file: overflow protocol, resolved here.
|
|
125
|
+
const r = spawn([panToolsPath, verb, ...extraArgs, '--cwd', cwd]);
|
|
126
|
+
if (r && r.ok) r.stdout = resolveOverflow(r.stdout);
|
|
127
|
+
return r;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Returns { error:{code,message} } for JSON-RPC protocol errors (unknown tool /
|
|
131
|
+
// invalid arguments — a bad *request*), or { result:{content,isError} } where
|
|
132
|
+
// isError:true signals a genuine tool *execution* failure (the verb ran and failed).
|
|
133
|
+
function callTool(name, input) {
|
|
134
|
+
const tool = reg.byToolName[name];
|
|
135
|
+
if (!tool) return { error: { code: -32602, message: `Unknown tool: ${name}` } };
|
|
136
|
+
// Native, in-process tools (orchestrator / merge gate) run a handler; a thrown
|
|
137
|
+
// Error means bad params (-32602), matching the spawn-tool validation path.
|
|
138
|
+
if (typeof tool.handler === 'function') {
|
|
139
|
+
try {
|
|
140
|
+
const out = tool.handler({ cwd, input: input || {}, env, gitImpl });
|
|
141
|
+
const text = (out && out.text != null) ? out.text : JSON.stringify(out && out.json);
|
|
142
|
+
return { result: { content: [{ type: 'text', text }], isError: !!(out && out.isError) } };
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return { error: { code: -32602, message: String((e && e.message) || e) } };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
let extra;
|
|
148
|
+
try { extra = tool.args ? tool.args(input || {}) : []; }
|
|
149
|
+
catch (e) { return { error: { code: -32602, message: String((e && e.message) || e) } }; }
|
|
150
|
+
const r = runVerb(tool.verb, extra);
|
|
151
|
+
return { result: { content: [{ type: 'text', text: r.ok ? r.stdout : (r.stderr || 'error') }], isError: !r.ok } };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Returns { unknown:true } for an unknown uri, { error:{code,message} } for an
|
|
155
|
+
// engine failure, or { result:{contents} } on success — so handle() can emit a
|
|
156
|
+
// real JSON-RPC error instead of a success frame carrying a stderr string
|
|
157
|
+
// mislabeled as application/json.
|
|
158
|
+
function readResource(uri) {
|
|
159
|
+
const res = reg.byResourceUri[uri];
|
|
160
|
+
if (!res) return { unknown: true };
|
|
161
|
+
const r = runVerb(res.verb, []);
|
|
162
|
+
if (!r.ok) return { error: { code: -32603, message: r.stderr || 'resource read failed' } };
|
|
163
|
+
return { result: { contents: [{ uri, mimeType: 'application/json', text: r.stdout }] } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handle(req) {
|
|
167
|
+
if (!req || req.jsonrpc !== '2.0' || typeof req.method !== 'string') {
|
|
168
|
+
return rpcError(req && req.id != null ? req.id : null, -32600, 'Invalid Request');
|
|
169
|
+
}
|
|
170
|
+
const { id, method, params } = req;
|
|
171
|
+
// A JSON-RPC notification has no id (id=0 is a VALID request id, not a
|
|
172
|
+
// notification). The server must never reply to a notification, so bail before
|
|
173
|
+
// the dispatch — this also stops any request-method sent id-less from emitting
|
|
174
|
+
// an id-less response frame.
|
|
175
|
+
if (id === undefined || id === null) return null;
|
|
176
|
+
switch (method) {
|
|
177
|
+
case 'initialize': {
|
|
178
|
+
const requested = params && params.protocolVersion;
|
|
179
|
+
const negotiated = SUPPORTED_PROTOCOL_VERSIONS.has(requested) ? requested : PROTOCOL_VERSION;
|
|
180
|
+
return rpcResult(id, { protocolVersion: negotiated, capabilities: { tools: {}, resources: {} }, serverInfo: SERVER_INFO });
|
|
181
|
+
}
|
|
182
|
+
case 'ping':
|
|
183
|
+
return rpcResult(id, {});
|
|
184
|
+
case 'tools/list':
|
|
185
|
+
return rpcResult(id, { tools: reg.TOOLS.map(toMcpTool) });
|
|
186
|
+
case 'resources/list':
|
|
187
|
+
return rpcResult(id, { resources: reg.RESOURCES.map(toMcpResource) });
|
|
188
|
+
case 'tools/call': {
|
|
189
|
+
const out = callTool(params && params.name, params && params.arguments);
|
|
190
|
+
return out.error ? rpcError(id, out.error.code, out.error.message) : rpcResult(id, out.result);
|
|
191
|
+
}
|
|
192
|
+
case 'resources/read': {
|
|
193
|
+
const out = readResource(params && params.uri);
|
|
194
|
+
if (out.unknown) return rpcError(id, -32602, `Unknown resource: ${params && params.uri}`);
|
|
195
|
+
return out.error ? rpcError(id, out.error.code, out.error.message) : rpcResult(id, out.result);
|
|
196
|
+
}
|
|
197
|
+
default:
|
|
198
|
+
return rpcError(id, -32601, `Method not found: ${method}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { handle, panToolsPath, cwd, runVerb };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Wire the server to stdin/stdout as newline-delimited JSON-RPC (MCP stdio). */
|
|
206
|
+
function main() {
|
|
207
|
+
const server = createServer();
|
|
208
|
+
let buf = '';
|
|
209
|
+
process.stdin.setEncoding('utf8');
|
|
210
|
+
process.stdin.on('data', (chunk) => {
|
|
211
|
+
buf += chunk;
|
|
212
|
+
let nl;
|
|
213
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
214
|
+
const line = buf.slice(0, nl).trim();
|
|
215
|
+
buf = buf.slice(nl + 1);
|
|
216
|
+
if (!line) continue;
|
|
217
|
+
let req;
|
|
218
|
+
try { req = JSON.parse(line); }
|
|
219
|
+
catch { process.stdout.write(JSON.stringify(rpcError(null, -32700, 'Parse error')) + '\n'); continue; }
|
|
220
|
+
const resp = server.handle(req);
|
|
221
|
+
if (resp) process.stdout.write(JSON.stringify(resp) + '\n');
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
process.stdin.on('end', () => process.exit(0));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (require.main === module) main();
|
|
228
|
+
|
|
229
|
+
module.exports = { createServer, defaultPanToolsPath, defaultSpawn, PROTOCOL_VERSION, SERVER_INFO, toMcpTool, toMcpResource };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PAN-Z MCP tool/resource registry (M1).
|
|
5
|
+
*
|
|
6
|
+
* Maps a curated, SAFE subset of `pan-tools` verbs onto MCP tools and resources.
|
|
7
|
+
* This module is PURE — no I/O, no spawning — so the mapping and its guardrails
|
|
8
|
+
* are unit-testable in isolation. The server (server.cjs) consumes it.
|
|
9
|
+
*
|
|
10
|
+
* Read-only aggregators are exposed as MCP *resources* (cheaper, side-effect-free,
|
|
11
|
+
* quota-friendly); anything that can act is a *tool* carrying accurate hints.
|
|
12
|
+
*
|
|
13
|
+
* Inputs to a tool originate from an LLM tool-call, so each arg is validated to a
|
|
14
|
+
* strict shape before it becomes a process argument. The spawn is shell-less
|
|
15
|
+
* (execFile with an argv array — no shell, so no metacharacter risk), but we still
|
|
16
|
+
* validate early for clear errors and defense in depth.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Validate a string arg against a whitelist regex + length bound, else throw. */
|
|
20
|
+
function str(name, value, re, max = 200) {
|
|
21
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > max || !re.test(value)) {
|
|
22
|
+
throw new Error(`Invalid "${name}": must match ${re} and be 1-${max} chars`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const AGENT_RE = /^[a-z][a-z0-9_-]{1,60}$/; // agent type, e.g. pan-planner
|
|
28
|
+
const PHASE_RE = /^[0-9]{1,3}$/; // phase number, e.g. 03
|
|
29
|
+
const QUERY_RE = /^[\w .,:/&()-]{1,120}$/; // find-phase query fragment
|
|
30
|
+
|
|
31
|
+
/** Read-only aggregators → MCP resources (no side effects). */
|
|
32
|
+
const RESOURCES = [
|
|
33
|
+
{ uri: 'pan://state', name: 'Project state', verb: 'state', description: 'Current PAN project state snapshot derived from .planning/.' },
|
|
34
|
+
{ uri: 'pan://roadmap', name: 'Roadmap', verb: 'roadmap', description: 'The project roadmap: phases, goals, success criteria.' },
|
|
35
|
+
{ uri: 'pan://phases', name: 'Phases', verb: 'phases', description: 'Phase inventory with per-phase status.' },
|
|
36
|
+
{ uri: 'pan://progress', name: 'Progress', verb: 'progress', description: 'Requirement and plan completion progress.' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Actionable pan-tools verbs → MCP tools (each spawns `node pan-tools.cjs <verb>`). */
|
|
40
|
+
const SPAWN_TOOLS = [
|
|
41
|
+
{
|
|
42
|
+
name: 'pan_resolve_model', title: 'Resolve model for an agent', verb: 'resolve-model',
|
|
43
|
+
description: 'Resolve the model tier/id PAN would use for an agent under the active profile.',
|
|
44
|
+
readOnly: true, destructive: false,
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: 'object', additionalProperties: false, required: ['agent'],
|
|
47
|
+
properties: { agent: { type: 'string', description: 'Agent type, e.g. pan-planner' } },
|
|
48
|
+
},
|
|
49
|
+
args: (i) => [str('agent', i && i.agent, AGENT_RE, 64)],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'pan_find_phase', title: 'Find a phase', verb: 'find-phase',
|
|
53
|
+
description: 'Locate a phase directory by number or slug fragment.',
|
|
54
|
+
readOnly: true, destructive: false,
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object', additionalProperties: false, required: ['query'],
|
|
57
|
+
properties: { query: { type: 'string', description: 'Phase number or slug fragment' } },
|
|
58
|
+
},
|
|
59
|
+
args: (i) => [str('query', i && i.query, QUERY_RE, 120)],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'pan_report_phase', title: 'Generate a phase HTML report', verb: 'report',
|
|
63
|
+
description: 'Render the self-contained HTML report for one phase (a build deliverable). Writes only its own file; non-destructive and idempotent.',
|
|
64
|
+
readOnly: false, destructive: false,
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object', additionalProperties: false, required: ['phase'],
|
|
67
|
+
properties: { phase: { type: 'string', description: 'Phase number, e.g. 03' } },
|
|
68
|
+
},
|
|
69
|
+
args: (i) => ['phase', str('phase', i && i.phase, PHASE_RE, 3)],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Guardrail: this bridge must NEVER expose a history-rewriting or force git op.
|
|
75
|
+
* merge/commit verbs arrive in M2 behind the deterministic human-token gate, and
|
|
76
|
+
* force-push/reset/rebase are never exposed at all (recovery is revert-only).
|
|
77
|
+
*/
|
|
78
|
+
const FORBIDDEN_VERB = /(^|-)(push|reset|rebase|force)($|-)/;
|
|
79
|
+
|
|
80
|
+
// Native, in-process tools (M2: orchestrator + merge gate) join the spawn-backed
|
|
81
|
+
// tools into one advertised list. Required after SPAWN_TOOLS/FORBIDDEN_VERB so the
|
|
82
|
+
// native module (which imports nothing back from here) composes cleanly — no cycle.
|
|
83
|
+
const { NATIVE_TOOLS } = require('./native-tools.cjs');
|
|
84
|
+
const TOOLS = [...SPAWN_TOOLS, ...NATIVE_TOOLS];
|
|
85
|
+
|
|
86
|
+
const byToolName = Object.create(null);
|
|
87
|
+
for (const t of TOOLS) byToolName[t.name] = t;
|
|
88
|
+
const byResourceUri = Object.create(null);
|
|
89
|
+
for (const r of RESOURCES) byResourceUri[r.uri] = r;
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
TOOLS, SPAWN_TOOLS, NATIVE_TOOLS, RESOURCES, byToolName, byResourceUri, FORBIDDEN_VERB,
|
|
93
|
+
AGENT_RE, PHASE_RE, QUERY_RE, str,
|
|
94
|
+
};
|