@pugi/cli 0.1.0-alpha.10

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yurii Bulakh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # Pugi CLI
2
+
3
+ `pugi` — terminal-native software execution system. Run agents on your repo,
4
+ hand jobs off to the cabinet or a remote runner, and keep every artifact local
5
+ by default.
6
+
7
+ - **Local-first.** Every plan, diff, and artifact lives under `.pugi/` in your
8
+ repo. Nothing leaves the machine unless you explicitly run `pugi handoff` or
9
+ `pugi sync`.
10
+ - **Web continuation.** When a job needs collaboration, an approval, or a clean
11
+ Linux runner, hand it off to the cabinet at `app.pugi.io`.
12
+ - **One CLI, three install paths.** npm, Homebrew tap, and a one-liner shell
13
+ script.
14
+
15
+ ## Install
16
+
17
+ ### npm (works everywhere with Node 20+)
18
+
19
+ ```bash
20
+ npm install -g pugi
21
+ pugi --version
22
+ ```
23
+
24
+ ### Homebrew (macOS + Linux)
25
+
26
+ ```bash
27
+ brew install pugi-io/tap/pugi
28
+ pugi --version
29
+ ```
30
+
31
+ The formula declares a Node 20+ runtime dependency and downloads the published
32
+ npm tarball, so the result is identical to `npm install -g pugi`.
33
+
34
+ ### One-liner (curl)
35
+
36
+ ```bash
37
+ curl -fsSL https://pugi.dev/install | sh
38
+ ```
39
+
40
+ The script detects your OS (Darwin / Linux), bootstraps Node 20+ via Homebrew
41
+ or `apt` if it is missing, and then runs `npm install -g pugi`. It prints the
42
+ installed version on success and exits non-zero on any failure. The script
43
+ itself is served from `pugi.dev`; review it at `https://pugi.dev/install` or
44
+ in `apps/admin-api/public/install.sh` before piping into a shell.
45
+
46
+ ### Requirements
47
+
48
+ - Node.js **20 or newer** (`node --version`)
49
+ - A POSIX shell for the curl installer (macOS, Linux, WSL)
50
+ - Git, for any command that touches a repo
51
+
52
+ ## Quickstart
53
+
54
+ ```bash
55
+ mkdir my-project && cd my-project
56
+ git init
57
+ pugi init
58
+ pugi idea "build a tiny TODO app"
59
+ pugi plan
60
+ pugi build
61
+ pugi review
62
+ ```
63
+
64
+ Every command writes to `.pugi/` (events log, artifacts, index). Re-run
65
+ `pugi sessions --rebuild` if you ever delete the index — the append-only
66
+ `.pugi/events.jsonl` is the source of truth.
67
+
68
+ ## Login
69
+
70
+ Most commands run fully offline. The ones that talk to the Pugi runtime
71
+ (`pugi review --triple --remote`, future `pugi handoff`) need an API key.
72
+
73
+ ```bash
74
+ export PUGI_API_KEY=pugi_live_... # from app.pugi.io > Settings > API
75
+ export PUGI_API_URL=https://api.pugi.io # optional, this is the default
76
+ pugi review --triple --remote
77
+ ```
78
+
79
+ The key is read from the environment, never persisted to disk, and never
80
+ logged. To revoke it, rotate the key in the cabinet — the CLI will see a
81
+ `401` on the next call and exit `5`.
82
+
83
+ ## Common commands
84
+
85
+ ```bash
86
+ pugi init # bootstrap .pugi/ in the current repo
87
+ pugi idea "..." # capture an idea, opens a plan stub
88
+ pugi plan # ask the persona team to expand the idea
89
+ pugi build # execute the plan locally
90
+ pugi review # local diff review
91
+ pugi review --triple # local triple-review evidence bundle
92
+ pugi review --triple --remote
93
+ # call Anvil for 3-model consensus
94
+ pugi handoff --web # hand the session off to the cabinet
95
+ pugi sessions # list sessions from .pugi/index.json
96
+ pugi sessions --rebuild # rebuild the index from events.jsonl
97
+ pugi doctor --json # environment diagnostic
98
+ pugi version # CLI version
99
+ ```
100
+
101
+ Run `pugi --help` for the full list.
102
+
103
+ ## Privacy
104
+
105
+ Pugi defaults to `local-only` — no upload happens without an explicit flag.
106
+ `pugi sync --dry-run --privacy <mode>` lets you preview exactly what would
107
+ leave the machine before you ever enable real upload (still gated; the alpha
108
+ returns `status: blocked, reason: sync_upload_not_implemented`).
109
+
110
+ ## Updating
111
+
112
+ ```bash
113
+ npm install -g pugi@latest # if you installed via npm
114
+ brew upgrade pugi # if you installed via Homebrew
115
+ curl -fsSL https://pugi.dev/install | sh # one-liner re-run is idempotent
116
+ ```
117
+
118
+ ## Uninstall
119
+
120
+ ```bash
121
+ npm uninstall -g pugi
122
+ # or
123
+ brew uninstall pugi
124
+ ```
125
+
126
+ The CLI never installs anything outside the Node global prefix and the
127
+ Homebrew cellar. `.pugi/` directories in your repos are left untouched on
128
+ uninstall; remove them manually if you want a clean slate.
129
+
130
+ ## Hooks
131
+
132
+ Pugi runs user-defined shell commands at lifecycle events (`SessionStart`,
133
+ `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PostToolUseFailure`,
134
+ `Stop`, `SessionEnd`, `UserPromptSubmit`). Drop a `hooks.json` at one of:
135
+
136
+ - `~/.pugi/hooks.json` — user hooks, always loaded.
137
+ - `<workspace>/.pugi/hooks.json` — project hooks, loaded only when the
138
+ workspace is trusted (see Sprint α5.6 for the `pugi config trust .` UX).
139
+
140
+ See [`docs/hooks-example.json`](./docs/hooks-example.json) for a working
141
+ config. Example: log every bash invocation through `logger`:
142
+
143
+ ```json
144
+ { "event": "PreToolUse", "match": { "tool": "bash" }, "run": "logger -t pugi \"$PUGI_HOOK_PAYLOAD\"" }
145
+ ```
146
+
147
+ Hooks cannot bypass permissions — a hook that re-invokes `pugi` re-enters
148
+ the permission engine in its own process.
149
+
150
+ ## Distribution
151
+
152
+ The three install paths are documented in detail at
153
+ [`docs/features/pugi-cli-distribution.md`](../../docs/features/pugi-cli-distribution.md)
154
+ and rationalised in [`docs/adr/0049-pugi-cli-distribution-strategy.md`](../../docs/adr/0049-pugi-cli-distribution-strategy.md).
155
+ Release operators: see the "Release process" section in the feature doc for
156
+ the tag → publish → tap-formula bump → smoke-test loop.
157
+
158
+ ## Testing the published tarball locally
159
+
160
+ Before tagging a release, run the local smoke test:
161
+
162
+ ```bash
163
+ pnpm --filter pugi pack:smoke
164
+ ```
165
+
166
+ It runs `npm pack` against the CLI workspace, asserts the tarball contains
167
+ `bin/run.js`, `dist/`, `README.md`, and `LICENSE`, and rejects the build if
168
+ anything is missing.
169
+
170
+ ## License
171
+
172
+ MIT — see [LICENSE](./LICENSE).
package/bin/run.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
@@ -0,0 +1,245 @@
1
+ /**
2
+ * `pugi jobs` command surface — Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J).
3
+ *
4
+ * Subcommands:
5
+ * pugi jobs list — table of all tracked jobs (or JSON envelope)
6
+ * pugi jobs status <id> — full record + tail of overflow artifact
7
+ * pugi jobs tail <id> — stream the overflow artifact
8
+ * pugi jobs kill <id> — SIGTERM then SIGKILL after a grace
9
+ * pugi jobs kill --all — kill every running job in this session
10
+ *
11
+ * Power-word voice rules (per Pugi brand guide):
12
+ * - status names render as "on watch" (running), "shipped" (finished),
13
+ * "stood down" (killed), "blocked" (failed), "lost" (abandoned).
14
+ * - JSON envelopes keep the machine-friendly enum so consumers do
15
+ * not have to map back.
16
+ */
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { formatDuration, getJobRegistry, relativeAge, } from '../core/jobs/registry.js';
19
+ const HUMAN_STATUS = {
20
+ running: 'on watch',
21
+ finished: 'shipped',
22
+ killed: 'stood down',
23
+ failed: 'blocked',
24
+ abandoned: 'lost',
25
+ };
26
+ export async function runJobsCommand(args, flags, io, sessionId) {
27
+ const sub = args[0] ?? 'list';
28
+ switch (sub) {
29
+ case 'list':
30
+ return runList(flags, io);
31
+ case 'status':
32
+ return runStatus(args[1], flags, io);
33
+ case 'tail':
34
+ return runTail(args[1], flags, io);
35
+ case 'kill':
36
+ return runKill(args.slice(1), flags, io, sessionId);
37
+ default:
38
+ io.writeError(`Unknown subcommand: ${sub}`);
39
+ io.writeError(usage());
40
+ return 2;
41
+ }
42
+ }
43
+ function usage() {
44
+ return [
45
+ 'Usage:',
46
+ ' pugi jobs list [--json] Table of background jobs.',
47
+ ' pugi jobs status <id> [--json] Full record + tail of artifact.',
48
+ ' pugi jobs tail <id> Stream the captured artifact.',
49
+ ' pugi jobs kill <id> [--json] SIGTERM, escalate to SIGKILL.',
50
+ ' pugi jobs kill --all [--json] Stand down every running job.',
51
+ ].join('\n');
52
+ }
53
+ async function runList(flags, io) {
54
+ const registry = getJobRegistry();
55
+ const entries = await registry.list();
56
+ if (flags.json) {
57
+ io.write(`${JSON.stringify({
58
+ command: 'jobs.list',
59
+ count: entries.length,
60
+ jobs: entries.map(serializeForJson),
61
+ }, null, 2)}\n`);
62
+ return 0;
63
+ }
64
+ io.write(`${renderTable(entries)}\n`);
65
+ return 0;
66
+ }
67
+ async function runStatus(id, flags, io) {
68
+ if (!id) {
69
+ io.writeError('pugi jobs status requires a job id');
70
+ return 2;
71
+ }
72
+ const registry = getJobRegistry();
73
+ const entry = await registry.get(id);
74
+ if (!entry) {
75
+ if (flags.json) {
76
+ io.write(`${JSON.stringify({ command: 'jobs.status', error: 'not_found', id }, null, 2)}\n`);
77
+ }
78
+ else {
79
+ io.writeError(`Job not found: ${id}`);
80
+ }
81
+ return 1;
82
+ }
83
+ const tail = entry.outputArtifactRef ? readArtifactTail(entry.outputArtifactRef, 4_096) : '';
84
+ if (flags.json) {
85
+ io.write(`${JSON.stringify({
86
+ command: 'jobs.status',
87
+ job: serializeForJson(entry),
88
+ tail,
89
+ }, null, 2)}\n`);
90
+ return 0;
91
+ }
92
+ const lines = [
93
+ `Job ${entry.id}`,
94
+ ` PID: ${entry.pid}`,
95
+ ` Command: ${entry.command}`,
96
+ ` Class: ${entry.bashClass}`,
97
+ ` Status: ${HUMAN_STATUS[entry.status]} (${entry.status})`,
98
+ ` CWD: ${entry.cwd}`,
99
+ ` Started: ${entry.startedAt} (${relativeAge(entry.startedAt)} ago)`,
100
+ ` Duration: ${formatDuration(entry.startedAt, entry.finishedAt)}`,
101
+ ];
102
+ if (entry.finishedAt)
103
+ lines.push(` Finished: ${entry.finishedAt}`);
104
+ if (entry.exitCode !== undefined)
105
+ lines.push(` Exit code: ${entry.exitCode}`);
106
+ if (entry.outputArtifactRef)
107
+ lines.push(` Artifact: ${entry.outputArtifactRef}`);
108
+ if (tail) {
109
+ lines.push('', '--- output tail ---', tail);
110
+ }
111
+ io.write(`${lines.join('\n')}\n`);
112
+ return 0;
113
+ }
114
+ async function runTail(id, _flags, io) {
115
+ if (!id) {
116
+ io.writeError('pugi jobs tail requires a job id');
117
+ return 2;
118
+ }
119
+ const registry = getJobRegistry();
120
+ const entry = await registry.get(id);
121
+ if (!entry) {
122
+ io.writeError(`Job not found: ${id}`);
123
+ return 1;
124
+ }
125
+ if (!entry.outputArtifactRef) {
126
+ io.writeError(`Job ${id} has no captured output artifact yet (background jobs spawn with stdio=ignore by default).`);
127
+ return 1;
128
+ }
129
+ if (!existsSync(entry.outputArtifactRef)) {
130
+ io.writeError(`Artifact missing on disk: ${entry.outputArtifactRef}`);
131
+ return 1;
132
+ }
133
+ const body = readFileSync(entry.outputArtifactRef, 'utf8');
134
+ io.write(body.endsWith('\n') ? body : `${body}\n`);
135
+ return 0;
136
+ }
137
+ async function runKill(args, flags, io, sessionId) {
138
+ const registry = getJobRegistry();
139
+ const killAll = flags.all || args.includes('--all');
140
+ if (killAll) {
141
+ const entries = await registry.list();
142
+ const targets = entries.filter((entry) => {
143
+ if (entry.status !== 'running')
144
+ return false;
145
+ if (sessionId && entry.sessionId !== sessionId)
146
+ return false;
147
+ return true;
148
+ });
149
+ const results = [];
150
+ for (const target of targets) {
151
+ const result = await registry.kill(target.id);
152
+ results.push({ id: target.id, ...result });
153
+ }
154
+ if (flags.json) {
155
+ io.write(`${JSON.stringify({ command: 'jobs.kill', scope: 'all', results }, null, 2)}\n`);
156
+ }
157
+ else if (results.length === 0) {
158
+ io.write('No running jobs to stand down.\n');
159
+ }
160
+ else {
161
+ for (const result of results) {
162
+ io.write(` ${result.id} ${result.killed ? 'stood down' : 'noop'} (${result.method})\n`);
163
+ }
164
+ }
165
+ return 0;
166
+ }
167
+ const id = args[0];
168
+ if (!id) {
169
+ io.writeError('pugi jobs kill requires a job id (or --all)');
170
+ return 2;
171
+ }
172
+ const result = await registry.kill(id);
173
+ if (flags.json) {
174
+ io.write(`${JSON.stringify({ command: 'jobs.kill', id, ...result }, null, 2)}\n`);
175
+ return result.killed || result.method === 'noop' ? 0 : 1;
176
+ }
177
+ if (result.killed) {
178
+ io.write(`Job ${id} stood down (${result.method}).\n`);
179
+ return 0;
180
+ }
181
+ io.writeError(`Job ${id} was not running or could not be signalled.`);
182
+ return 1;
183
+ }
184
+ function serializeForJson(entry) {
185
+ const ageSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(entry.startedAt)) / 1000));
186
+ let durationSeconds;
187
+ if (entry.finishedAt) {
188
+ const start = Date.parse(entry.startedAt);
189
+ const end = Date.parse(entry.finishedAt);
190
+ if (!Number.isNaN(start) && !Number.isNaN(end) && end >= start) {
191
+ durationSeconds = Math.floor((end - start) / 1000);
192
+ }
193
+ }
194
+ return {
195
+ ...entry,
196
+ humanStatus: HUMAN_STATUS[entry.status],
197
+ ageSeconds,
198
+ durationSeconds,
199
+ };
200
+ }
201
+ function readArtifactTail(path, byteBudget) {
202
+ if (!existsSync(path))
203
+ return '';
204
+ try {
205
+ const body = readFileSync(path, 'utf8');
206
+ if (body.length <= byteBudget)
207
+ return body;
208
+ return `(...truncated; tail ${byteBudget} bytes of ${body.length})\n${body.slice(-byteBudget)}`;
209
+ }
210
+ catch {
211
+ return '';
212
+ }
213
+ }
214
+ function renderTable(entries) {
215
+ if (entries.length === 0) {
216
+ return 'No background jobs tracked. Spawn one via the bash tool (background: true) and it lands here.';
217
+ }
218
+ const header = ['ID', 'COMMAND', 'CLASS', 'STATUS', 'STARTED', 'DURATION'];
219
+ const rows = [header];
220
+ for (const entry of entries) {
221
+ rows.push([
222
+ entry.id.replace(/^pj-/, '').slice(0, 8),
223
+ truncate(entry.command, 24),
224
+ entry.bashClass,
225
+ HUMAN_STATUS[entry.status],
226
+ `${relativeAge(entry.startedAt)} ago`,
227
+ formatDuration(entry.startedAt, entry.finishedAt),
228
+ ]);
229
+ }
230
+ const widths = header.map((_, i) => Math.max(...rows.map((row) => (row[i] ?? '').length)));
231
+ // Reserve a little headroom so the right-hand columns do not bleed
232
+ // past 80 chars when the command column hits its 24-char ceiling.
233
+ return rows
234
+ .map((row) => row
235
+ .map((cell, i) => cell.padEnd(widths[i] ?? cell.length))
236
+ .join(' ')
237
+ .trimEnd())
238
+ .join('\n');
239
+ }
240
+ function truncate(value, max) {
241
+ if (value.length <= max)
242
+ return value;
243
+ return `${value.slice(0, max - 1)}…`;
244
+ }
245
+ //# sourceMappingURL=jobs.js.map
@@ -0,0 +1,104 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { assertValidSlug, parseSkillMarkdown } from '../skills/loader.js';
5
+ export function globalAgentsDir() {
6
+ const home = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
7
+ return join(home, 'agents');
8
+ }
9
+ export function workspaceAgentsDir(workspaceRoot) {
10
+ return join(workspaceRoot, '.pugi', 'agents');
11
+ }
12
+ export function globalAgentPath(slug) {
13
+ assertValidSlug(slug, 'agent');
14
+ return join(globalAgentsDir(), `${slug}.md`);
15
+ }
16
+ export function workspaceAgentPath(workspaceRoot, slug) {
17
+ assertValidSlug(slug, 'agent');
18
+ return join(workspaceAgentsDir(workspaceRoot), `${slug}.md`);
19
+ }
20
+ export function listAgents(scope, workspaceRoot) {
21
+ const dir = scope === 'global' ? globalAgentsDir() : workspaceAgentsDir(workspaceRoot);
22
+ if (!existsSync(dir))
23
+ return [];
24
+ return readdirSync(dir)
25
+ .filter((name) => name.endsWith('.md'))
26
+ .sort((a, b) => a.localeCompare(b))
27
+ .map((name) => loadAgent(join(dir, name), scope))
28
+ .filter((agent) => agent !== null);
29
+ }
30
+ function loadAgent(filePath, scope) {
31
+ try {
32
+ const source = readFileSync(filePath, 'utf8');
33
+ const parsed = parseSkillMarkdown(source);
34
+ // Files under `<scope>/.pugi/agents/` are agents by construction;
35
+ // the loader override here forces metadata.type=agent even when the
36
+ // upstream frontmatter (e.g. Anthropic flat dialect) omitted the
37
+ // declaration. We never mis-categorise a `<dir>/.pugi/agents/foo.md`
38
+ // file as a skill.
39
+ if (parsed.frontmatter.metadata.type !== 'agent' &&
40
+ parsed.frontmatter.metadata.type !== 'skill') {
41
+ return null;
42
+ }
43
+ const slug = filePath.split('/').pop()?.replace(/\.md$/, '') ?? parsed.frontmatter.name;
44
+ // Filenames on disk are produced by installAgent (which validates)
45
+ // OR placed manually by the operator. Validate before exposing the
46
+ // slug to the rest of the system so trust keys + log lines never
47
+ // carry a hostile string.
48
+ assertValidSlug(slug, 'agent');
49
+ const frontmatter = {
50
+ ...parsed.frontmatter,
51
+ metadata: { ...parsed.frontmatter.metadata, type: 'agent' },
52
+ };
53
+ return {
54
+ slug,
55
+ scope,
56
+ filePath,
57
+ frontmatter,
58
+ body: parsed.body,
59
+ source,
60
+ };
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ export function installAgent(input) {
67
+ // Fail-closed before any filesystem mutation. assertValidSlug also
68
+ // runs inside globalAgentPath/workspaceAgentPath but we surface it
69
+ // explicitly here so the error fires before mkdirSync.
70
+ assertValidSlug(input.slug, 'agent');
71
+ const target = input.scope === 'global'
72
+ ? globalAgentPath(input.slug)
73
+ : workspaceAgentPath(input.workspaceRoot, input.slug);
74
+ mkdirSync(dirname(target), { recursive: true });
75
+ const srcFile = pickAgentFile(input.payloadDir);
76
+ writeFileSync(target, readFileSync(srcFile), { mode: 0o600 });
77
+ return target;
78
+ }
79
+ function pickAgentFile(payloadDir) {
80
+ const stat = statSync(payloadDir);
81
+ if (stat.isFile())
82
+ return payloadDir;
83
+ const entries = readdirSync(payloadDir).filter((name) => name.toLowerCase().endsWith('.md'));
84
+ if (entries.length === 0) {
85
+ throw new Error('AGENT_INSTALL: payload directory contains no .md file');
86
+ }
87
+ if (entries.length > 1) {
88
+ throw new Error(`AGENT_INSTALL: payload directory contains ${entries.length} .md files (expected exactly 1)`);
89
+ }
90
+ const first = entries[0];
91
+ if (!first) {
92
+ throw new Error('AGENT_INSTALL: payload directory contains no .md file');
93
+ }
94
+ return join(payloadDir, first);
95
+ }
96
+ export function removeAgent(slug, scope, workspaceRoot) {
97
+ assertValidSlug(slug, 'agent');
98
+ const target = scope === 'global' ? globalAgentPath(slug) : workspaceAgentPath(workspaceRoot, slug);
99
+ if (!existsSync(target))
100
+ return false;
101
+ rmSync(target, { force: true });
102
+ return true;
103
+ }
104
+ //# sourceMappingURL=loader.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Subagent role → Cyber-Zoo persona mapping for the Pugi CLI.
3
+ *
4
+ * The CLI dispatcher resolves a role string ('coder', 'reviewer',
5
+ * 'orchestrator', ...) to a brand persona that owns the work and is
6
+ * stamped on the audit-trace event. This file is the single place where
7
+ * that mapping lives — keep it tight and explicit so persona drift never
8
+ * leaks back into the dispatch surface.
9
+ *
10
+ * M1 closed set (ADR-0056 Sprint α5.1): 9 roles, all mapped to Tier 1
11
+ * Engineering Core or Tier 2 Specialist personas from THE_TEN. Tier 1
12
+ * Missing Functions (Growth/Legal/Security/Sales/Support) are deferred
13
+ * to α7.5; Sigma is intentionally absent because it is an OES Enterprise
14
+ * persona via Anvil triple-review proxy, not a Cyber-Zoo brand persona.
15
+ */
16
+ import { THE_TEN, getPersona } from '@pugi/personas';
17
+ /**
18
+ * Resolve a slug from THE_TEN or throw with a build-time
19
+ * diagnostic. Used during registry construction so a typo in the mapping
20
+ * surfaces at module-load instead of at dispatch time.
21
+ */
22
+ function requirePersona(slug) {
23
+ const persona = getPersona(slug);
24
+ if (!persona) {
25
+ const available = THE_TEN.map((p) => p.slug).join(', ');
26
+ throw new Error(`SUBAGENT_REGISTRY: slug '${slug}' is not in THE_TEN (have: ${available})`);
27
+ }
28
+ return persona;
29
+ }
30
+ /**
31
+ * CLI-only role-to-persona mapping. Roles are dispatcher-facing strings;
32
+ * personas come from the brand-canonical THE_TEN. Vera (qa) intentionally
33
+ * dual-roles as verifier + reviewer per ADR-0056 — the cabinet's review
34
+ * pipeline already merges the two surfaces.
35
+ */
36
+ export const SUBAGENT_REGISTRY = [
37
+ { role: 'orchestrator', persona: requirePersona('main') }, // Mira (Pug)
38
+ { role: 'architect', persona: requirePersona('architect') }, // Marcus (Owl)
39
+ { role: 'coder', persona: requirePersona('dev') }, // Hiroshi (Wolf)
40
+ { role: 'verifier', persona: requirePersona('qa') }, // Vera (Fox)
41
+ { role: 'reviewer', persona: requirePersona('qa') }, // Vera dual-role
42
+ { role: 'researcher', persona: requirePersona('researcher') }, // Anika (Raven)
43
+ { role: 'release', persona: requirePersona('pm') }, // Olivia (Honeybee)
44
+ { role: 'devops', persona: requirePersona('devops') }, // Diego (Octopus)
45
+ { role: 'design_qa', persona: requirePersona('designer') }, // Sofia (Stag)
46
+ ];
47
+ const REGISTRY_BY_ROLE = new Map(SUBAGENT_REGISTRY.map((d) => [d.role, d]));
48
+ /**
49
+ * Resolve a role to its full subagent definition. Throws when the role
50
+ * is not registered; the closed SubagentRole union prevents that at
51
+ * compile time for typed callers, but the runtime guard catches dynamic
52
+ * dispatch paths (config files, plugin manifests, ...).
53
+ */
54
+ export function getSubagent(role) {
55
+ const def = REGISTRY_BY_ROLE.get(role);
56
+ if (!def) {
57
+ throw new Error(`getSubagent: unknown role '${role}'`);
58
+ }
59
+ return def;
60
+ }
61
+ /** Convenience: resolve a role straight to its persona. */
62
+ export function getPersonaForRole(role) {
63
+ return getSubagent(role).persona;
64
+ }
65
+ /** Stable enumeration of registered roles in registry order. */
66
+ export function listRoles() {
67
+ return SUBAGENT_REGISTRY.map((d) => d.role);
68
+ }
69
+ //# sourceMappingURL=registry.js.map