@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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -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/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -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/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -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/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- 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/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- 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,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
|