@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.
- package/README.md +20 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +5 -0
- package/dist/core/engine/prompts.js +42 -0
- package/dist/core/engine/tool-bridge.js +159 -61
- package/dist/core/hooks.js +415 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/permission.js +221 -116
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/session.js +399 -0
- package/dist/core/repl/slash-commands.js +116 -0
- package/dist/core/session.js +168 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/runtime/cli.js +158 -46
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/input-box.js +91 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +68 -0
- package/dist/tui/repl-render.js +218 -0
- package/dist/tui/repl.js +152 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +58 -0
- 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
|