@pugi/cli 0.1.0-beta.35 → 0.1.0-beta.37

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.
@@ -1,102 +1,174 @@
1
1
  /**
2
- * Permission modes — canonical 4-mode taxonomy (Leak L6).
2
+ * Permission modes — Wave 7 canonical 6-mode taxonomy (Claude Code parity).
3
3
  *
4
- * Pugi historically shipped a 6-mode taxonomy in `@pugi/sdk`
5
- * (`plan | ask | acceptEdits | auto | dontAsk | bypassPermissions`)
6
- * which the legacy `core/permission.ts` engine maps tools onto. Claude
7
- * Code, Codex, and the openclaude / openwork leaks all converge on a
8
- * smaller, sharper 4-mode set:
4
+ * Pugi α6 shipped a 4-mode taxonomy (`plan | ask | allow | bypass`) that
5
+ * proved fine для daily use but diverged from Claude Code's 6-mode
6
+ * surface (`default | acceptEdits | plan | auto | dontAsk |
7
+ * bypassPermissions`). Wave 7 Sprint 1 epic #2 closes the parity gap so
8
+ * operators coming from Claude Code see the same mode names + same
9
+ * Shift+Tab cycle.
9
10
  *
10
- * - `plan` — read-only proposal mode. Write/dispatch tools refused
11
- * with a deterministic sentinel; the model is expected
12
- * to surface a plan, not execute it.
13
- * - `ask` — every tool execution prompts the operator. Default
14
- * mode for new operators; the safe ground state.
15
- * - `allow` — every tool executes without per-call prompts, BUT
16
- * the policy hook layer (skill-steering, denial audit,
17
- * destructive deny-list) still fires.
18
- * - `bypass` — same as allow but ALSO skips policy hooks. Power-user
19
- * mode for trusted scripted runs; surface a banner on
20
- * entry so an operator who flips here by accident sees
21
- * they have disengaged the audit layer.
11
+ * Canonical 6 modes (Wave 7):
22
12
  *
23
- * This module owns the union type, the canonical default, and the
24
- * mode-resolution helper. The runtime gate (`gate.ts`) consumes it; the
25
- * legacy 6-mode SDK enum remains the system-of-record for bash-class
26
- * decisions inside `core/permission.ts` the canonical 4-mode layer
27
- * sits in front and short-circuits the dispatch decision before bash
28
- * classification ever runs.
13
+ * - `default` — every tool call asks the operator. Safe
14
+ * ground state, replaces α6 `ask`.
15
+ * - `acceptEdits` — auto-allow write/edit on workspace files,
16
+ * ask for everything else (bash, dispatch).
17
+ * Matches CC's "trust file edits только".
18
+ * - `plan` — read-only proposal mode. Write/dispatch
19
+ * refused with deterministic sentinel; the
20
+ * model surfaces a plan, не executes.
21
+ * - `auto` — classifier decides per-call. Phase 1
22
+ * (this PR) ships regex allowlist (safe
23
+ * commands) + regex denylist (destructive).
24
+ * Anything else falls back to ask.
25
+ * - `dontAsk` — auto-allow everything except `permissions.deny`
26
+ * list. Replaces α6 `allow`.
27
+ * - `bypassPermissions` — skip ALL checks including deny list +
28
+ * policy hooks. Has a circuit-breaker for
29
+ * catastrophic patterns (rm -rf /, fork bomb,
30
+ * dd if=/) that refuses regardless of mode.
31
+ * Replaces α6 `bypass`.
32
+ *
33
+ * Backwards-compat aliases: the α6 short names (`ask`, `allow`, `bypass`)
34
+ * map to the new canonical names via `MODE_ALIASES`. `parsePermissionMode`
35
+ * resolves aliases so existing session.json files keep working.
36
+ *
37
+ * Rename mapping (α6 → α7):
38
+ * ask → default
39
+ * allow → dontAsk
40
+ * bypass → bypassPermissions
41
+ * (plan, acceptEdits, auto unchanged / new)
29
42
  */
30
43
  /**
31
- * Closed list — useful for input validation and slash-command help.
44
+ * Closed list — used by Shift+Tab cycle (in order), input validation,
45
+ * and slash-command help. Order matches Claude Code's documented
46
+ * Shift+Tab progression: default → acceptEdits → plan → auto → dontAsk
47
+ * → bypassPermissions → wrap к default.
32
48
  */
33
49
  export const PERMISSION_MODES = Object.freeze([
50
+ 'default',
51
+ 'acceptEdits',
34
52
  'plan',
35
- 'ask',
36
- 'allow',
37
- 'bypass',
53
+ 'auto',
54
+ 'dontAsk',
55
+ 'bypassPermissions',
38
56
  ]);
39
57
  /**
40
58
  * Default mode applied when no `--mode` flag, no per-workspace session
41
- * state, and no `defaultPermissionMode` in `~/.pugi/config.json`. We
42
- * default cautious (`ask`) an operator who has not configured anything
43
- * is treated as a new operator who deserves visibility into every tool
44
- * call.
59
+ * state, and no `defaultPermissionMode` в `~/.pugi/config.json`. We
60
+ * default cautious (`default` mode = prompt every call) an operator
61
+ * who has not configured anything is treated as a new operator who
62
+ * deserves visibility into every tool call.
45
63
  */
46
- export const DEFAULT_PERMISSION_MODE = 'ask';
64
+ export const DEFAULT_PERMISSION_MODE = 'default';
47
65
  /**
48
- * Type guard for arbitrary string input (CLI flag, session.json
66
+ * Backwards-compat aliases: α6 short names map to α7 canonical names.
67
+ * `parsePermissionMode` consults this table так existing session.json
68
+ * files + scripts that pass `--mode ask` keep working without breaking.
69
+ *
70
+ * Aliases are one-way: persistence writes the canonical name, so a
71
+ * session that started on `ask` is migrated to `default` on next save.
72
+ */
73
+ const MODE_ALIASES = Object.freeze({
74
+ ask: 'default',
75
+ allow: 'dontAsk',
76
+ bypass: 'bypassPermissions',
77
+ });
78
+ /**
79
+ * Type guard для arbitrary string input (CLI flag, session.json
49
80
  * deserialization). Returns false for casing variants — caller is
50
- * expected to lowercase before testing.
81
+ * expected to lowercase before testing. Aliases are NOT accepted by
82
+ * this predicate; use `parsePermissionMode` for alias resolution.
51
83
  */
52
84
  export function isPermissionMode(value) {
53
85
  return typeof value === 'string' && PERMISSION_MODES.includes(value);
54
86
  }
55
87
  /**
56
- * Parse + validate a mode string. Returns null for invalid input so the
88
+ * Parse + validate a mode string. Returns null для invalid input so the
57
89
  * caller can surface a typed error (`unknown mode: <value>`) instead of
58
90
  * throwing from a parse helper.
91
+ *
92
+ * Resolves α6 aliases (`ask` → `default`, `allow` → `dontAsk`,
93
+ * `bypass` → `bypassPermissions`) for backwards compatibility.
94
+ *
95
+ * Case-handling: lowercases for canonical names but matches camelCase
96
+ * names case-insensitively too (так `acceptedits` resolves to
97
+ * `acceptEdits`). The aliases table covers the legacy lowercase tokens.
59
98
  */
60
99
  export function parsePermissionMode(value) {
61
- const lower = value.trim().toLowerCase();
62
- return isPermissionMode(lower) ? lower : null;
100
+ const trimmed = value.trim();
101
+ if (trimmed.length === 0)
102
+ return null;
103
+ // Direct canonical match first (preserves camelCase capitalisation).
104
+ if (isPermissionMode(trimmed))
105
+ return trimmed;
106
+ // Case-insensitive canonical match — operator typed `acceptedits`.
107
+ const lower = trimmed.toLowerCase();
108
+ const canonical = PERMISSION_MODES.find((m) => m.toLowerCase() === lower);
109
+ if (canonical)
110
+ return canonical;
111
+ // α6 alias fallthrough.
112
+ const aliased = MODE_ALIASES[lower];
113
+ if (aliased)
114
+ return aliased;
115
+ return null;
63
116
  }
64
117
  /**
65
- * Map the canonical 4-mode taxonomy to the legacy 6-mode SDK enum used
66
- * by `core/permission.ts::evaluateBashPermission` and friends. The map
67
- * is intentionally surjective on a narrower target the canonical
68
- * layer is the new public contract, the legacy layer is plumbing.
69
- *
70
- * plan -> 'plan' (read-only)
71
- * ask -> 'ask' (prompt every action)
72
- * allow -> 'auto' (allow non-destructive; deny destructive)
73
- * bypass -> 'bypassPermissions' (allow everything except destructive override)
118
+ * Wave 7 Shift+Tab cycle advance to the next mode in the canonical
119
+ * order, wrapping from the last back to the first. Pure helper so the
120
+ * TUI binding can call it without re-implementing the cycle logic.
121
+ */
122
+ export function nextPermissionMode(current) {
123
+ const idx = PERMISSION_MODES.indexOf(current);
124
+ if (idx === -1)
125
+ return DEFAULT_PERMISSION_MODE;
126
+ const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length];
127
+ return next ?? DEFAULT_PERMISSION_MODE;
128
+ }
129
+ /**
130
+ * Map the canonical 6-mode taxonomy to the legacy SDK enum used by
131
+ * `@pugi/sdk::permissionModeSchema`. The SDK enum already contains all
132
+ * 6 names so the map is identity для the modes that align; the two
133
+ * α6-only legacy names (`ask`, `allow`) are not part of the canonical
134
+ * set anymore — `default` and `dontAsk` are их replacements.
74
135
  *
75
136
  * Callers that need the legacy enum (existing bash classifier, settings
76
- * persistence) should funnel through this helper so the mapping is in
77
- * one place.
137
+ * persistence) should funnel through this helper so the mapping stays
138
+ * в one place.
78
139
  */
79
140
  export function toLegacyMode(mode) {
80
141
  switch (mode) {
142
+ case 'default':
143
+ // SDK enum doesn't carry `default`; the closest legacy semantic is
144
+ // `ask` (prompt-every-call). Persistence layers that round-trip
145
+ // через the SDK enum get back `ask`, which `parsePermissionMode`
146
+ // re-maps to `default` via the alias table. Round-trip safe.
147
+ return 'ask';
148
+ case 'acceptEdits':
149
+ return 'acceptEdits';
81
150
  case 'plan':
82
151
  return 'plan';
83
- case 'ask':
84
- return 'ask';
85
- case 'allow':
152
+ case 'auto':
86
153
  return 'auto';
87
- case 'bypass':
154
+ case 'dontAsk':
155
+ return 'dontAsk';
156
+ case 'bypassPermissions':
88
157
  return 'bypassPermissions';
89
158
  }
90
159
  }
91
160
  /**
92
- * One-line human-readable summary surfaced by the `/permissions` table
93
- * and `pugi --help` text. Kept inline so the strings stay localizable
94
- * via a single edit point.
161
+ * One-line human-readable summary surfaced by the `/permissions` table,
162
+ * the Ink picker, and `pugi --help` text. Each line carries a safety
163
+ * hint ("safe-by-default" / "use carefully" / "power-user only") so an
164
+ * operator скимming the picker knows the risk profile at a glance.
95
165
  */
96
166
  export const PERMISSION_MODE_GLOSS = Object.freeze({
97
- plan: 'Read-only propose, never execute. Write + dispatch tools refused.',
98
- ask: 'Prompt before every tool call. Default for new operators.',
99
- allow: 'Execute tools without prompts. Policy hooks still fire.',
100
- bypass: 'Execute tools without prompts AND skip policy hooks. Power-user only.',
167
+ default: 'Prompt before every tool call. Safe-by-default for new operators.',
168
+ acceptEdits: 'Auto-allow file edit/write; ask for bash + dispatch. Safe-by-default.',
169
+ plan: 'Read-only propose, never execute. Write + dispatch refused.',
170
+ auto: 'Classifier decides per call (safe regex allowlist; falls back к ask). Use carefully.',
171
+ dontAsk: 'Execute tools without prompts; deny-list still applies. Use carefully.',
172
+ bypassPermissions: 'Skip ALL checks AND policy hooks; circuit-breaker on catastrophic patterns. Power-user only.',
101
173
  });
102
174
  //# sourceMappingURL=mode.js.map
@@ -22,8 +22,27 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
22
22
  import { dirname, resolve } from 'node:path';
23
23
  import { homedir } from 'node:os';
24
24
  import { z } from 'zod';
25
- import { DEFAULT_PERMISSION_MODE, isPermissionMode, parsePermissionMode, } from './mode.js';
26
- const permissionModeEnum = z.enum(['plan', 'ask', 'allow', 'bypass']);
25
+ import { DEFAULT_PERMISSION_MODE, parsePermissionMode, } from './mode.js';
26
+ /**
27
+ * Wave 7: zod enum for the canonical 6-mode taxonomy. Includes α6
28
+ * aliases (`ask`, `allow`, `bypass`) as accepted input — Zod parses
29
+ * them, the helpers below remap к canonical names before returning к
30
+ * the caller. Persistence always writes the canonical name so the file
31
+ * migrates forward on next save.
32
+ */
33
+ const permissionModeEnum = z.enum([
34
+ // Canonical Wave 7 names.
35
+ 'default',
36
+ 'acceptEdits',
37
+ 'plan',
38
+ 'auto',
39
+ 'dontAsk',
40
+ 'bypassPermissions',
41
+ // α6 backwards-compat aliases — resolved via parsePermissionMode.
42
+ 'ask',
43
+ 'allow',
44
+ 'bypass',
45
+ ]);
27
46
  const sessionStateSchema = z
28
47
  .object({
29
48
  permissionMode: permissionModeEnum.optional(),
@@ -74,7 +93,12 @@ export function getCurrentMode(workspaceRoot) {
74
93
  try {
75
94
  const raw = readFileSync(path, 'utf8');
76
95
  const parsed = sessionStateSchema.parse(JSON.parse(raw));
77
- return isPermissionMode(parsed.permissionMode) ? parsed.permissionMode : null;
96
+ if (typeof parsed.permissionMode !== 'string')
97
+ return null;
98
+ // Wave 7: parsePermissionMode resolves α6 aliases (`ask`, `allow`,
99
+ // `bypass`) to their canonical Wave 7 names. A session file written
100
+ // by α6.x is silently upgraded on read.
101
+ return parsePermissionMode(parsed.permissionMode);
78
102
  }
79
103
  catch {
80
104
  return null;
@@ -112,9 +136,9 @@ export function getPreviousMode(workspaceRoot) {
112
136
  try {
113
137
  const raw = readFileSync(path, 'utf8');
114
138
  const parsed = sessionStateSchema.parse(JSON.parse(raw));
115
- return isPermissionMode(parsed.previousPermissionMode)
116
- ? parsed.previousPermissionMode
117
- : null;
139
+ if (typeof parsed.previousPermissionMode !== 'string')
140
+ return null;
141
+ return parsePermissionMode(parsed.previousPermissionMode);
118
142
  }
119
143
  catch {
120
144
  return null;
@@ -157,7 +181,9 @@ export function getGlobalDefaultMode(homeDir = homedir()) {
157
181
  try {
158
182
  const raw = readFileSync(path, 'utf8');
159
183
  const parsed = globalConfigSchema.parse(JSON.parse(raw));
160
- return isPermissionMode(parsed.defaultPermissionMode) ? parsed.defaultPermissionMode : null;
184
+ if (typeof parsed.defaultPermissionMode !== 'string')
185
+ return null;
186
+ return parsePermissionMode(parsed.defaultPermissionMode);
161
187
  }
162
188
  catch {
163
189
  return null;
@@ -29,6 +29,7 @@
29
29
  * `/quit` confirmation and `/help` footer - never inline.
30
30
  */
31
31
  import { listRoles } from '../agents/registry.js';
32
+ import { PERMISSION_MODES, parsePermissionMode, } from '../permissions/index.js';
32
33
  /**
33
34
  * Deterministic stub copy returned by the Tier 3 commands. Spec'd
34
35
  * inline so the unit test can pin the exact text without poking at
@@ -86,7 +87,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
86
87
  // Settings
87
88
  { name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
88
89
  { name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
89
- { name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan / ask / allow / bypass) (also: /plan)', group: 'Settings' },
90
+ { name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (default / acceptEdits / plan / auto / dontAsk / bypassPermissions) (also: /plan, Shift+Tab cycle)', group: 'Settings' },
90
91
  { name: 'plan', args: '[--back | --persist] [<prompt>]', gloss: 'Switch to plan mode (read-only). Same as /permissions plan, slicker UX.', group: 'Settings' },
91
92
  { name: 'model', args: '[<slug>]', gloss: 'Show or select the active model. Bare /model lists tier-gated options', group: 'Settings' },
92
93
  { name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
@@ -332,26 +333,29 @@ export function parseSlashCommand(input) {
332
333
  }
333
334
  case 'permissions':
334
335
  case 'perms': {
335
- // Leak L6: `/permissions [mode] [--persist] [--confirm]`.
336
+ // Wave 7: `/permissions [mode] [--persist] [--confirm]`.
336
337
  //
337
338
  // Argument grammar (single line, no quoting):
338
- // /permissions -> show current mode + table
339
- // /permissions plan|ask|allow -> flip mode
340
- // /permissions bypass --confirm -> flip to bypass (refused
341
- // without --confirm — safety)
339
+ // /permissions -> show + table
340
+ // /permissions default|acceptEdits|plan|auto|dontAsk -> flip mode
341
+ // /permissions bypassPermissions --confirm -> flip to
342
+ // bypassPermissions (refused
343
+ // без --confirm — safety)
342
344
  // /permissions <mode> --persist -> also write to ~/.pugi/config.json
343
345
  //
344
- // Anything else returns an `error` result so the runtime can
345
- // render the usage hint inline.
346
+ // α6 aliases (`ask`, `allow`, `bypass`) are accepted и mapped to
347
+ // their Wave 7 canonical names via `parsePermissionMode`.
346
348
  const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
347
349
  if (tokens.length === 0) {
348
350
  return { kind: 'permissions', persist: false, confirmBypass: false };
349
351
  }
350
- const head0 = tokens[0]?.toLowerCase();
351
- if (head0 !== 'plan' && head0 !== 'ask' && head0 !== 'allow' && head0 !== 'bypass') {
352
+ const headRaw = tokens[0] ?? '';
353
+ const mode = parsePermissionMode(headRaw);
354
+ if (!mode) {
355
+ const modeList = [...PERMISSION_MODES].join('|');
352
356
  return {
353
357
  kind: 'error',
354
- message: `Usage: /permissions [plan|ask|allow|bypass] [--persist] [--confirm]; unknown mode '${tokens[0] ?? ''}'`,
358
+ message: `Usage: /permissions [${modeList}] [--persist] [--confirm]; unknown mode '${headRaw}'`,
355
359
  };
356
360
  }
357
361
  const flags = tokens.slice(1);
@@ -371,7 +375,7 @@ export function parseSlashCommand(input) {
371
375
  };
372
376
  }
373
377
  }
374
- return { kind: 'permissions', mode: head0, persist, confirmBypass };
378
+ return { kind: 'permissions', mode, persist, confirmBypass };
375
379
  }
376
380
  case 'init': {
377
381
  // β1 Sl11: surface the init flow inside the REPL. Tail args
@@ -62,6 +62,54 @@ export async function fireSessionStartMvp(session) {
62
62
  return 0;
63
63
  }
64
64
  }
65
+ /**
66
+ * Wave 7 P1 — fire the v2 `SessionStart` event from `~/.pugi/hooks.json`
67
+ * (global) + `<workspaceRoot>/.pugi/hooks.json` (project). Companion to
68
+ * `fireSessionStartMvp`; both surfaces run because they read different
69
+ * config files.
70
+ *
71
+ * Headless by default (no trust prompt) — the v2 trust ledger gates
72
+ * first-run executions. Operators with no prior trust decision will see
73
+ * the SessionStart hook skipped with a `denied by trust ledger` stderr
74
+ * note; running `pugi hooks trust allow <command>` enrolls it.
75
+ *
76
+ * Returns the number of hooks that ran (excluding trust-denied skips).
77
+ * Never throws.
78
+ */
79
+ export async function fireSessionStartV2(session) {
80
+ try {
81
+ const { fireSessionStart } = await import('./hooks/v2/index.js');
82
+ const outcome = await fireSessionStart({
83
+ sessionId: session.id,
84
+ workspaceRoot: session.root,
85
+ transcriptPath: session.eventsPath,
86
+ permissionMode: 'ask',
87
+ });
88
+ return outcome.results.filter((r) => r.exitCode !== -1).length;
89
+ }
90
+ catch {
91
+ return 0;
92
+ }
93
+ }
94
+ /**
95
+ * Wave 7 P1 — fire the v2 `SessionEnd` event. Called by the REPL
96
+ * teardown path. Companion to `fireSessionStartV2`.
97
+ */
98
+ export async function fireSessionEndV2(session) {
99
+ try {
100
+ const { fireSessionEnd } = await import('./hooks/v2/index.js');
101
+ const outcome = await fireSessionEnd({
102
+ sessionId: session.id,
103
+ workspaceRoot: session.root,
104
+ transcriptPath: session.eventsPath,
105
+ permissionMode: 'ask',
106
+ });
107
+ return outcome.results.filter((r) => r.exitCode !== -1).length;
108
+ }
109
+ catch {
110
+ return 0;
111
+ }
112
+ }
65
113
  export function recordCommandStarted(session, command) {
66
114
  if (!session.enabled)
67
115
  return;
@@ -755,7 +755,7 @@ async function dispatchRewind(args, flags, _session) {
755
755
  async function dispatchPermissions(args, flags, _session) {
756
756
  const head = args[0];
757
757
  if (head && parsePermissionMode(head) === null) {
758
- writeOutput(flags, { error: 'unknown_mode', mode: head }, `Unknown mode '${head}'. Allowed: plan, ask, allow, bypass.`);
758
+ writeOutput(flags, { error: 'unknown_mode', mode: head }, `Unknown mode '${head}'. Allowed: default, acceptEdits, plan, auto, dontAsk, bypassPermissions (α6 aliases ask/allow/bypass accepted).`);
759
759
  process.exitCode = 1;
760
760
  return;
761
761
  }
@@ -780,8 +780,8 @@ async function dispatchPermissions(args, flags, _session) {
780
780
  await runPermissionsCommand({
781
781
  mode: chosen,
782
782
  persist: Boolean(flags.persist),
783
- // The picker selection IS the confirm gesture for `bypass`.
784
- confirmBypass: chosen === 'bypass' ? true : Boolean(flags.confirm),
783
+ // The picker selection IS the confirm gesture for `bypassPermissions`.
784
+ confirmBypass: chosen === 'bypassPermissions' ? true : Boolean(flags.confirm),
785
785
  }, {
786
786
  workspaceRoot: process.cwd(),
787
787
  writeOutput: (text) => writeOutput(flags, { text }, text),
@@ -1524,7 +1524,7 @@ function parseArgs(argv) {
1524
1524
  else if (arg === '--mode') {
1525
1525
  const next = argv[index + 1];
1526
1526
  if (!next || next.startsWith('--')) {
1527
- throw new Error('--mode requires plan|ask|allow|bypass');
1527
+ throw new Error('--mode requires default|acceptEdits|plan|auto|dontAsk|bypassPermissions (α6 aliases ask|allow|bypass accepted)');
1528
1528
  }
1529
1529
  flags.mode = next;
1530
1530
  index += 1;
@@ -10,7 +10,9 @@ import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
10
10
  import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
11
11
  import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
12
12
  import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
13
+ import { buildOrchestratorTools } from '../../core/mcp/orchestrator-tools.js';
13
14
  import { serveHttp } from '../../core/mcp/http-server.js';
15
+ import { resolveActiveCredential, DEFAULT_API_URL } from '../../core/credentials.js';
14
16
  import { listMcpPermissions, clearMcpPermission, } from '../../core/mcp/permission.js';
15
17
  export async function runMcpCommand(args, ctx) {
16
18
  const sub = args[0] ?? 'list';
@@ -74,6 +76,15 @@ const USAGE_LINES = [
74
76
  ' --allow-write Expose edit/write (default off — explicit opt-in).',
75
77
  ' --allow-bash Expose the bash tool (default off — explicit opt-in).',
76
78
  ' --no-bash Deprecated alias (bash is already off by default).',
79
+ ' --orchestrator Expose pugi.run / pugi.read / pugi.write /',
80
+ ' pugi.dispatch / pugi.publish / pugi.deploy instead of',
81
+ ' the engine surface. Designed for external Claude Code',
82
+ ' / Cursor sessions driving fix-publish-test loops.',
83
+ ' Each tool family is gated by an env switch:',
84
+ ' PUGI_MCP_EXEC_ENABLED=1 enables pugi.run',
85
+ ' PUGI_MCP_PUBLISH_ENABLED=1 enables pugi.publish',
86
+ ' PUGI_MCP_DEPLOY_ENABLED=1 enables pugi.deploy',
87
+ ' PUGI_MCP_WORKSPACE_ROOT=... overrides cwd for path validation',
77
88
  ' perms list Show cached per-(server, tool) decisions',
78
89
  ' perms reset <server>:<tool> Forget one cached decision',
79
90
  ];
@@ -536,16 +547,23 @@ async function runMcpServe(args, ctx) {
536
547
  const readOnly = flags.readOnly === true;
537
548
  const writeAllowed = !readOnly && flags.writeAllowed;
538
549
  const bashAllowed = !readOnly && flags.bashAllowed;
539
- const tools = buildPugiMcpTools(toolCtx, {
540
- bashAllowed,
541
- // Keep the legacy contract: `readOnly` for the tool-builder means
542
- // "do not advertise edit/write tools". Bash advertisement is gated
543
- // by the independent `bashAllowed` knob. So the builder sees
544
- // `readOnly = true` whenever the operator did not opt into write
545
- // explicitly, which preserves the deny-by-default surface for
546
- // edit/write but no longer accidentally suppresses bash.
547
- readOnly: readOnly || !writeAllowed,
548
- });
550
+ // Wave 7 P1 — when `--orchestrator` is set the surface swaps to the
551
+ // CLI-orchestrator family (pugi.run / pugi.read / pugi.write /
552
+ // pugi.dispatch / pugi.publish / pugi.deploy). The engine surface is
553
+ // intentionally dropped the two are mutually exclusive on the wire
554
+ // to keep tool-name resolution unambiguous on the consumer side.
555
+ const tools = flags.orchestrator
556
+ ? buildOrchestratorTools(buildOrchestratorContext(ctx.workspaceRoot))
557
+ : buildPugiMcpTools(toolCtx, {
558
+ bashAllowed,
559
+ // Keep the legacy contract: `readOnly` for the tool-builder means
560
+ // "do not advertise edit/write tools". Bash advertisement is gated
561
+ // by the independent `bashAllowed` knob. So the builder sees
562
+ // `readOnly = true` whenever the operator did not opt into write
563
+ // explicitly, which preserves the deny-by-default surface for
564
+ // edit/write but no longer accidentally suppresses bash.
565
+ readOnly: readOnly || !writeAllowed,
566
+ });
549
567
  // β4 r1 P1 #2 — deny-by-default permissionGate. The MCP cache + FSM
550
568
  // are consulted on every dispatch; allow_always-cached entries pass
551
569
  // silently, allow_once entries pass and self-clear, deny entries
@@ -604,6 +622,7 @@ async function runMcpServe(args, ctx) {
604
622
  command: 'mcp.serve',
605
623
  transport: 'http',
606
624
  url: handle.url,
625
+ surface: flags.orchestrator ? 'orchestrator' : 'engine',
607
626
  bearerTokenSource: handle.bearerTokenAutoGenerated
608
627
  ? 'auto-generated (see stderr)'
609
628
  : explicitToken === envToken
@@ -649,7 +668,7 @@ async function runMcpServe(args, ctx) {
649
668
  // the wire; nothing is printed unless the parent agent sends a
650
669
  // request that returns a response. Operator sees one info line on
651
670
  // stderr so they know the server is up.
652
- process.stderr.write(`pugi-mcp (stdio): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
671
+ process.stderr.write(`pugi-mcp (stdio, ${flags.orchestrator ? 'orchestrator' : 'engine'}): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
653
672
  await serveStdio({
654
673
  server,
655
674
  stdin: ctx.stdin ?? process.stdin,
@@ -665,6 +684,7 @@ function parseServeFlags(args) {
665
684
  readOnly: false,
666
685
  writeAllowed: false,
667
686
  bashAllowed: false,
687
+ orchestrator: false,
668
688
  };
669
689
  for (let i = 0; i < args.length; i += 1) {
670
690
  const arg = args[i] ?? '';
@@ -722,6 +742,9 @@ function parseServeFlags(args) {
722
742
  // so existing operator scripts do not error.
723
743
  flags.bashAllowed = false;
724
744
  }
745
+ else if (arg === '--orchestrator') {
746
+ flags.orchestrator = true;
747
+ }
725
748
  else if (arg === '--help') {
726
749
  // Caller renders USAGE_LINES. We surface the same via top-level
727
750
  // dispatch — nothing to do here, just don't error.
@@ -755,9 +778,41 @@ function buildServePermissionGate(opts) {
755
778
  return false;
756
779
  if (tool.permission === 'edit' && !opts.writeAllowed)
757
780
  return false;
781
+ // `network` is the permission class used by orchestrator tools
782
+ // (pugi.dispatch / pugi.publish / pugi.deploy). The env capability
783
+ // gates inside each tool's `execute` body provide the per-family
784
+ // kill switch, so the serve-time gate is permissive here. The
785
+ // server's overall `permissionGate` is already deny-most-other —
786
+ // adding a third boolean knob (`networkAllowed`) would create more
787
+ // ways to misconfigure than to protect. Wave 7 P1 (2026-05-28).
758
788
  return true;
759
789
  };
760
790
  }
791
+ /**
792
+ * Build the OrchestratorToolContext for `pugi mcp serve --orchestrator`.
793
+ * Reads from process.env + the credentials store. Encapsulated so tests
794
+ * never need to mock the resolveActiveCredential path — they call
795
+ * `buildOrchestratorTools` directly with a hand-rolled context.
796
+ *
797
+ * Wave 7 P1 (2026-05-28).
798
+ */
799
+ function buildOrchestratorContext(workspaceRoot) {
800
+ const envRoot = process.env.PUGI_MCP_WORKSPACE_ROOT;
801
+ const root = envRoot && envRoot.length > 0 ? resolve(envRoot) : workspaceRoot;
802
+ const credential = resolveActiveCredential();
803
+ return {
804
+ workspaceRoot: root,
805
+ pugiBin: process.env.PUGI_MCP_PUGI_BIN ?? 'pugi',
806
+ apiUrl: credential?.apiUrl ?? DEFAULT_API_URL,
807
+ apiKey: credential?.apiKey ?? null,
808
+ capabilities: {
809
+ exec: process.env.PUGI_MCP_EXEC_ENABLED === '1',
810
+ publish: process.env.PUGI_MCP_PUBLISH_ENABLED === '1',
811
+ deploy: process.env.PUGI_MCP_DEPLOY_ENABLED === '1',
812
+ },
813
+ sshAlias: process.env.PUGI_MCP_SSH_ALIAS ?? 'codeforge',
814
+ };
815
+ }
761
816
  function parseHttpBinding(input) {
762
817
  // Accept `:7100`, `7100`, or `host:7100`.
763
818
  let host = '127.0.0.1';
@@ -29,9 +29,10 @@ export async function runPermissionsCommand(command, ctx) {
29
29
  renderModeTable(ctx);
30
30
  return;
31
31
  }
32
- if (command.mode === 'bypass' && !command.confirmBypass) {
33
- ctx.writeOutput('Bypass mode disables policy hooks (skill steering, denial tracking).');
34
- ctx.writeOutput('Run `/permissions bypass --confirm` to acknowledge before flipping.');
32
+ if (command.mode === 'bypassPermissions' && !command.confirmBypass) {
33
+ ctx.writeOutput('bypassPermissions disables policy hooks (skill steering, denial tracking) AND skips the deny-list.');
34
+ ctx.writeOutput('Catastrophic patterns (rm -rf /, fork bomb, dd if=/) still trip the circuit-breaker, но that is the only guardrail left.');
35
+ ctx.writeOutput('Run `/permissions bypassPermissions --confirm` to acknowledge before flipping.');
35
36
  return;
36
37
  }
37
38
  setCurrentMode(ctx.workspaceRoot, command.mode);
@@ -42,8 +43,8 @@ export async function runPermissionsCommand(command, ctx) {
42
43
  ? ' Persisted to ~/.pugi/config.json for future sessions.'
43
44
  : '';
44
45
  ctx.writeOutput(`Permission mode set to '${command.mode}'.${persistedHint} ${PERMISSION_MODE_GLOSS[command.mode]}`);
45
- if (command.mode === 'bypass') {
46
- ctx.writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
46
+ if (command.mode === 'bypassPermissions') {
47
+ ctx.writeOutput('bypassPermissions — all tools execute without prompts AND policy hooks disabled (circuit-breaker still trips on rm -rf /). Switch back with /permissions dontAsk.');
47
48
  }
48
49
  }
49
50
  /**
@@ -68,12 +69,13 @@ function renderCurrentMode(ctx) {
68
69
  */
69
70
  function renderModeTable(ctx) {
70
71
  ctx.writeOutput('');
71
- ctx.writeOutput('Permission modes:');
72
+ ctx.writeOutput('Permission modes (Shift+Tab cycles in REPL):');
72
73
  for (const mode of PERMISSION_MODES) {
73
- ctx.writeOutput(` ${mode.padEnd(7)} ${PERMISSION_MODE_GLOSS[mode]}`);
74
+ // Wave 7: longest canonical name is `bypassPermissions` (17 chars).
75
+ ctx.writeOutput(` ${mode.padEnd(18)} ${PERMISSION_MODE_GLOSS[mode]}`);
74
76
  }
75
77
  ctx.writeOutput('');
76
- ctx.writeOutput('Switch with `/permissions <mode> [--persist]`. Bypass requires `--confirm`.');
78
+ ctx.writeOutput('Switch with `/permissions <mode> [--persist]`. bypassPermissions requires `--confirm`.');
77
79
  }
78
80
  /**
79
81
  * Render the one-shot banner shown on session boot when the effective
@@ -82,7 +84,7 @@ function renderModeTable(ctx) {
82
84
  * but the caller is responsible for the once-only semantics.
83
85
  */
84
86
  export function renderBypassBanner(writeOutput) {
85
- writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
87
+ writeOutput('bypassPermissions — all tools execute without prompts AND policy hooks disabled (circuit-breaker still trips on rm -rf /). Switch back with /permissions dontAsk.');
86
88
  }
87
89
  /**
88
90
  * Resolve the effective mode + the layered source label used by the