@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6

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 (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +158 -46
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. package/package.json +11 -5
package/README.md CHANGED
@@ -127,6 +127,26 @@ The CLI never installs anything outside the Node global prefix and the
127
127
  Homebrew cellar. `.pugi/` directories in your repos are left untouched on
128
128
  uninstall; remove them manually if you want a clean slate.
129
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
+
130
150
  ## Distribution
131
151
 
132
152
  The three install paths are documented in detail at
@@ -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,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