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 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:
@@ -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 (same 1M context at half the cost).`);
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 {
@@ -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 ceiling. |
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 ceiling for a scheduled campaign; the day's run stops when reached, resumes next day. |
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:** the per-day budget caps each day's run; the per-run `--total-budget` and the conductor caps still bound each cycle. A scheduled campaign runs the backlog down to staged, reviewed, green PRs over days — and still waits for a human at every merge.
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 · `--total-budget` exhausted · backlog empty · abort file present · context < 25% · a mission cannot pass Quality and can't be cleanly reverted (HARD STOP — preserve state, report). Always run `--clean-seal` (unless omitted) after the last item.
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, default 200)
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 points cap (5-5000) |
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.17.0",
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: 'mid', budget: 'mid' },
54
- 'pan-executor': { quality: 'reasoning', balanced: 'mid', budget: 'mid' },
55
- 'pan-phase-researcher': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
56
- 'pan-project-researcher': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
57
- 'pan-research-synthesizer': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
58
- 'pan-debugger': { quality: 'reasoning', balanced: 'mid', budget: 'mid' },
59
- 'pan-document_code': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
60
- 'pan-verifier': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
61
- 'pan-plan-checker': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
62
- 'pan-integration-checker': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
63
- 'pan-reviewer': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
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: 'mid', budget: 'mid' },
67
- 'pan-hardener': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
68
- 'pan-meta-reviewer': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
69
- 'pan-knowledge': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
70
- 'pan-previewer': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
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: 'mid', budget: 'fast' },
73
- 'pan-distiller': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
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: 'fast', budget: 'fast' },
85
+ 'pan-experiment-runner': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
76
86
  // ADR-0033 bot-army — Release squad
77
- 'pan-release': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
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
- if (run.totals.points_used >= run.total_budget) return 'budget_cap';
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
+ };