@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
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REPL slash command registry — Sprint α5.7, expanded α6.14 wave 2.
|
|
3
|
+
*
|
|
4
|
+
* The REPL input box surfaces a palette of slash commands the operator
|
|
5
|
+
* can run from inside a persistent session. The wave-2 expansion (CEO
|
|
6
|
+
* 2026-05-25) grows the surface from 6 to 20 commands so the `/help`
|
|
7
|
+
* overlay matches the breadth Claude Code / Codex CLI operators expect.
|
|
8
|
+
*
|
|
9
|
+
* The registry is pure: each `parseSlashCommand` call returns a
|
|
10
|
+
* `SlashCommandResult` describing what the REPL session should do next.
|
|
11
|
+
* The session module owns the side effects (network calls, dispatcher
|
|
12
|
+
* invocation, exit, transcript clear). Keeping the surface pure lets
|
|
13
|
+
* the unit test exercise every shape without standing up an Ink runtime
|
|
14
|
+
* or an Anvil endpoint.
|
|
15
|
+
*
|
|
16
|
+
* Tiering (per CEO wave-2 spec):
|
|
17
|
+
*
|
|
18
|
+
* Tier 1 — wired against real state (3 + existing 6 = 9 wired):
|
|
19
|
+
* brief, agents, stop, help, quit, web, clear, version, jobs.
|
|
20
|
+
*
|
|
21
|
+
* Tier 2 — best-effort wiring against existing surfaces (3):
|
|
22
|
+
* diff, cost, status.
|
|
23
|
+
*
|
|
24
|
+
* Tier 3 — deterministic stubs ("coming in αX.Y") (8):
|
|
25
|
+
* compact, resume, memory, config, privacy, budget, mcp, undo.
|
|
26
|
+
*
|
|
27
|
+
* Brand voice (brandbook §08): power words `brief / dispatch / stop /
|
|
28
|
+
* agents / quit / shipped`. Tagline `Brief it. It ships.` reserved for
|
|
29
|
+
* `/quit` confirmation and `/help` footer — never inline.
|
|
30
|
+
*/
|
|
31
|
+
import { listRoles } from '../agents/registry.js';
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic stub copy returned by the Tier 3 commands. Spec'd
|
|
34
|
+
* inline so the unit test can pin the exact text without poking at
|
|
35
|
+
* the help overlay. The version tag at the end maps to the sprint we
|
|
36
|
+
* intend to land the real wiring in.
|
|
37
|
+
*/
|
|
38
|
+
export const SLASH_STUB_MESSAGES = Object.freeze({
|
|
39
|
+
brief: '',
|
|
40
|
+
agents: '',
|
|
41
|
+
stop: '',
|
|
42
|
+
help: '',
|
|
43
|
+
quit: '',
|
|
44
|
+
web: '',
|
|
45
|
+
clear: '',
|
|
46
|
+
version: '',
|
|
47
|
+
jobs: '',
|
|
48
|
+
diff: '',
|
|
49
|
+
cost: '',
|
|
50
|
+
status: '',
|
|
51
|
+
compact: 'Manual context compaction lands in α6.5.',
|
|
52
|
+
resume: 'Resume last session lands in α6.4 once SQLite session.db lands.',
|
|
53
|
+
memory: 'Session memory editor lands in α6.5.',
|
|
54
|
+
config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
|
|
55
|
+
privacy: 'Run `pugi privacy show` from a fresh shell; in-REPL toggle lands in α6.5.',
|
|
56
|
+
budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
|
|
57
|
+
mcp: 'Run `pugi config mcp list` from a fresh shell; in-REPL palette lands in α6.5.',
|
|
58
|
+
undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
|
|
59
|
+
});
|
|
60
|
+
export const SLASH_COMMAND_HELP = Object.freeze([
|
|
61
|
+
// Workforce dispatch
|
|
62
|
+
{ name: 'brief', args: '<text>', gloss: 'Dispatch a brief to the workforce', group: 'Workforce dispatch' },
|
|
63
|
+
{ name: 'agents', args: '', gloss: 'List the on-watch agent roster', group: 'Workforce dispatch' },
|
|
64
|
+
{ name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug', group: 'Workforce dispatch' },
|
|
65
|
+
{ name: 'jobs', args: '', gloss: 'List background jobs', group: 'Workforce dispatch' },
|
|
66
|
+
// Session
|
|
67
|
+
{ name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
|
|
68
|
+
{ name: 'resume', args: '', gloss: 'Resume last session (α6.4)', group: 'Session', stub: true },
|
|
69
|
+
{ name: 'compact', args: '', gloss: 'Manual context compaction (α6.5)', group: 'Session', stub: true },
|
|
70
|
+
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5)', group: 'Session', stub: true },
|
|
71
|
+
// Pugi tools
|
|
72
|
+
{ name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
|
|
73
|
+
{ name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
|
|
74
|
+
{ name: 'cost', args: '', gloss: 'Token usage + budget', group: 'Pugi tools' },
|
|
75
|
+
{ name: 'status', args: '', gloss: 'Backend + tenant status', group: 'Pugi tools' },
|
|
76
|
+
// Settings
|
|
77
|
+
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
78
|
+
{ name: 'privacy', args: '', gloss: 'Show privacy mode', group: 'Settings', stub: true },
|
|
79
|
+
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
80
|
+
{ name: 'mcp', args: '', gloss: 'List MCP servers', group: 'Settings', stub: true },
|
|
81
|
+
{ name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
|
|
82
|
+
// Meta
|
|
83
|
+
{ name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
|
|
84
|
+
{ name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
|
|
85
|
+
{ name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Ordered list of groups. Drives the `/help` overlay sectioning so the
|
|
89
|
+
* operator reads commands by intent (dispatch first, meta last).
|
|
90
|
+
*/
|
|
91
|
+
export const SLASH_COMMAND_GROUPS = Object.freeze([
|
|
92
|
+
'Workforce dispatch',
|
|
93
|
+
'Session',
|
|
94
|
+
'Pugi tools',
|
|
95
|
+
'Settings',
|
|
96
|
+
'Meta',
|
|
97
|
+
]);
|
|
98
|
+
/**
|
|
99
|
+
* Parse one line of input from the REPL. The contract:
|
|
100
|
+
*
|
|
101
|
+
* - Empty / whitespace-only input returns `noop` with the original
|
|
102
|
+
* text so the REPL can ignore it without printing anything.
|
|
103
|
+
* - Input that does not start with `/` is treated as an implicit
|
|
104
|
+
* `/brief <text>` — the most-common operator action.
|
|
105
|
+
* - `/<name> [args]` resolves the name against the registry; unknown
|
|
106
|
+
* names return `error` so the REPL can render a one-line tip
|
|
107
|
+
* instead of silently dropping the input.
|
|
108
|
+
* - Tier 3 stubs return `{ kind: 'stub', name, message }` so the REPL
|
|
109
|
+
* can render the deterministic "coming in αX.Y" copy uniformly.
|
|
110
|
+
*
|
|
111
|
+
* The function never throws. Bad input maps to a structured result the
|
|
112
|
+
* REPL can render — the alternative (throwing from a keystroke handler)
|
|
113
|
+
* would unmount Ink mid-frame.
|
|
114
|
+
*/
|
|
115
|
+
export function parseSlashCommand(input) {
|
|
116
|
+
const trimmed = input.trim();
|
|
117
|
+
if (trimmed.length === 0) {
|
|
118
|
+
return { kind: 'noop', text: '' };
|
|
119
|
+
}
|
|
120
|
+
if (!trimmed.startsWith('/')) {
|
|
121
|
+
return { kind: 'dispatch', brief: trimmed };
|
|
122
|
+
}
|
|
123
|
+
// `/` with no name → render help overlay so the operator discovers
|
|
124
|
+
// the command palette without typing `/help`.
|
|
125
|
+
if (trimmed === '/') {
|
|
126
|
+
return { kind: 'help' };
|
|
127
|
+
}
|
|
128
|
+
const space = trimmed.indexOf(' ');
|
|
129
|
+
const head = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space);
|
|
130
|
+
const tail = space === -1 ? '' : trimmed.slice(space + 1).trim();
|
|
131
|
+
const name = head.toLowerCase();
|
|
132
|
+
switch (name) {
|
|
133
|
+
case 'brief': {
|
|
134
|
+
if (tail.length === 0) {
|
|
135
|
+
return { kind: 'error', message: 'Usage: /brief <text>' };
|
|
136
|
+
}
|
|
137
|
+
return { kind: 'dispatch', brief: tail };
|
|
138
|
+
}
|
|
139
|
+
case 'agents':
|
|
140
|
+
case 'agent':
|
|
141
|
+
case 'roster': {
|
|
142
|
+
return { kind: 'roster' };
|
|
143
|
+
}
|
|
144
|
+
case 'stop':
|
|
145
|
+
case 'kill': {
|
|
146
|
+
if (tail.length === 0) {
|
|
147
|
+
return {
|
|
148
|
+
kind: 'error',
|
|
149
|
+
message: `Usage: /stop <persona> (try one of: ${listRoles().join(', ')})`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return { kind: 'stop', persona: tail.toLowerCase() };
|
|
153
|
+
}
|
|
154
|
+
case 'help':
|
|
155
|
+
case '?': {
|
|
156
|
+
return { kind: 'help' };
|
|
157
|
+
}
|
|
158
|
+
case 'quit':
|
|
159
|
+
case 'exit':
|
|
160
|
+
case 'q': {
|
|
161
|
+
return { kind: 'quit' };
|
|
162
|
+
}
|
|
163
|
+
case 'web':
|
|
164
|
+
case 'fetch': {
|
|
165
|
+
if (tail.length === 0) {
|
|
166
|
+
return { kind: 'error', message: 'Usage: /web <url>' };
|
|
167
|
+
}
|
|
168
|
+
return { kind: 'web', url: tail };
|
|
169
|
+
}
|
|
170
|
+
case 'clear':
|
|
171
|
+
case 'cls': {
|
|
172
|
+
return { kind: 'clear' };
|
|
173
|
+
}
|
|
174
|
+
case 'version':
|
|
175
|
+
case 'v': {
|
|
176
|
+
return { kind: 'version' };
|
|
177
|
+
}
|
|
178
|
+
case 'jobs': {
|
|
179
|
+
return { kind: 'jobs' };
|
|
180
|
+
}
|
|
181
|
+
case 'diff': {
|
|
182
|
+
return { kind: 'diff' };
|
|
183
|
+
}
|
|
184
|
+
case 'cost': {
|
|
185
|
+
return { kind: 'cost' };
|
|
186
|
+
}
|
|
187
|
+
case 'status': {
|
|
188
|
+
return { kind: 'status' };
|
|
189
|
+
}
|
|
190
|
+
case 'compact':
|
|
191
|
+
case 'resume':
|
|
192
|
+
case 'memory':
|
|
193
|
+
case 'config':
|
|
194
|
+
case 'privacy':
|
|
195
|
+
case 'budget':
|
|
196
|
+
case 'mcp':
|
|
197
|
+
case 'undo': {
|
|
198
|
+
return {
|
|
199
|
+
kind: 'stub',
|
|
200
|
+
name: name,
|
|
201
|
+
message: SLASH_STUB_MESSAGES[name],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
default: {
|
|
205
|
+
return {
|
|
206
|
+
kind: 'error',
|
|
207
|
+
message: `Unknown command /${head}. Try /help for the palette.`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Filter SLASH_COMMAND_HELP rows whose name starts with the typed
|
|
214
|
+
* prefix. The REPL input box uses this to render an inline palette
|
|
215
|
+
* after the operator types `/`.
|
|
216
|
+
*
|
|
217
|
+
* Matching is case-insensitive; the prefix is normalized to lowercase
|
|
218
|
+
* before comparison so the operator can type `/HELP` and still see
|
|
219
|
+
* suggestions.
|
|
220
|
+
*/
|
|
221
|
+
export function matchSlashPrefix(prefix) {
|
|
222
|
+
const normalized = prefix.toLowerCase().replace(/^\//, '');
|
|
223
|
+
if (normalized.length === 0)
|
|
224
|
+
return SLASH_COMMAND_HELP;
|
|
225
|
+
return SLASH_COMMAND_HELP.filter((row) => row.name.startsWith(normalized));
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=slash-commands.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace context resolver — Sprint α6.14 wave 4.
|
|
3
|
+
*
|
|
4
|
+
* Reads the operator's cwd and synthesises the workspace bundle the CLI
|
|
5
|
+
* forwards to admin-api on POST /api/pugi/sessions. Mira's prompt v1.1
|
|
6
|
+
* consumes the bundle so "what repo is this?" / "а изучи репо…" answers
|
|
7
|
+
* from the live cwd instead of bouncing back "репо не привязано" (CEO
|
|
8
|
+
* dogfood 2026-05-25).
|
|
9
|
+
*
|
|
10
|
+
* Three fields:
|
|
11
|
+
*
|
|
12
|
+
* - `workspaceCwd` — absolute path the CLI was launched from.
|
|
13
|
+
* - `workspaceSlug` — a stable short identifier (slugForCwd).
|
|
14
|
+
* - `workspaceSummary` — first ~200 chars of `.pugi/PUGI.md` if the
|
|
15
|
+
* repo has one, else the directory basename.
|
|
16
|
+
*
|
|
17
|
+
* The helper is pure-ish (reads the filesystem but does not mutate it)
|
|
18
|
+
* so the production caller in `runtime/cli.ts` can call it eagerly at
|
|
19
|
+
* REPL launch without touching the network. Tests pass an explicit cwd
|
|
20
|
+
* + `fs` stub so the resolver stays deterministic.
|
|
21
|
+
*
|
|
22
|
+
* Failure mode: any FS error (permission denied, missing PUGI.md,
|
|
23
|
+
* symlink loop) returns the basename fallback. The CLI never bubbles
|
|
24
|
+
* the error to the operator — workspace context is a best-effort hint,
|
|
25
|
+
* not a precondition for opening the session.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
28
|
+
import { basename, resolve as resolvePath } from 'node:path';
|
|
29
|
+
import { slugForCwd } from './history.js';
|
|
30
|
+
/** Cap on the PUGI.md head we forward. Mirrors the admin-api clamp. */
|
|
31
|
+
const PUGI_MD_HEAD_LIMIT = 200;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a `ReplWorkspaceContext` from the operator's working directory.
|
|
34
|
+
* Returns a bundle with at least `workspaceCwd` + `workspaceSlug` +
|
|
35
|
+
* `workspaceSummary` populated. The summary is the PUGI.md head when
|
|
36
|
+
* available, else the directory basename.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveWorkspaceContext(cwd) {
|
|
39
|
+
const normalised = resolvePath(cwd);
|
|
40
|
+
const slug = slugForCwd(normalised);
|
|
41
|
+
const summary = readPugiSummary(normalised) ?? basename(normalised) ?? 'workspace';
|
|
42
|
+
return {
|
|
43
|
+
workspaceCwd: normalised,
|
|
44
|
+
workspaceSlug: slug,
|
|
45
|
+
workspaceSummary: summary,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read the first ~200 chars of `.pugi/PUGI.md` if the file exists. The
|
|
50
|
+
* project's own description is the highest-signal one-line summary we
|
|
51
|
+
* can hand to Mira — `pugi init` writes it on workspace creation, and
|
|
52
|
+
* the operator may have edited it since.
|
|
53
|
+
*
|
|
54
|
+
* Returns null on any FS error so the caller falls back to the
|
|
55
|
+
* basename. We never throw — workspace context is best-effort.
|
|
56
|
+
*/
|
|
57
|
+
function readPugiSummary(cwd) {
|
|
58
|
+
const candidate = resolvePath(cwd, '.pugi', 'PUGI.md');
|
|
59
|
+
try {
|
|
60
|
+
if (!existsSync(candidate))
|
|
61
|
+
return null;
|
|
62
|
+
const st = statSync(candidate);
|
|
63
|
+
if (!st.isFile())
|
|
64
|
+
return null;
|
|
65
|
+
// Cap the read at 2 KB — even a malformed PUGI.md will not be
|
|
66
|
+
// bigger than that's worth on a one-line summary, and capping
|
|
67
|
+
// bounds the slice cost on a giant file.
|
|
68
|
+
const raw = readFileSync(candidate, 'utf8').slice(0, 2048);
|
|
69
|
+
return summariseMarkdown(raw);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reduce a PUGI.md head to one short summary line: strip the front-
|
|
77
|
+
* matter and the leading H1 marker, take the first non-empty line,
|
|
78
|
+
* cap at PUGI_MD_HEAD_LIMIT. Whitespace collapses to single spaces so
|
|
79
|
+
* the summary survives the admin-api clamp without weird wrap.
|
|
80
|
+
*/
|
|
81
|
+
export function summariseMarkdown(raw) {
|
|
82
|
+
if (!raw || raw.trim().length === 0)
|
|
83
|
+
return null;
|
|
84
|
+
const body = stripFrontmatter(raw);
|
|
85
|
+
const lines = body.split(/\r?\n/);
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
// Strip leading `#` markers + trim whitespace. A heading like
|
|
88
|
+
// "# My Project" becomes "My Project".
|
|
89
|
+
const stripped = line.replace(/^#+\s*/, '').trim();
|
|
90
|
+
if (stripped.length === 0)
|
|
91
|
+
continue;
|
|
92
|
+
const oneLine = stripped.replace(/\s+/g, ' ');
|
|
93
|
+
return oneLine.length > PUGI_MD_HEAD_LIMIT
|
|
94
|
+
? oneLine.slice(0, PUGI_MD_HEAD_LIMIT)
|
|
95
|
+
: oneLine;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Drop a YAML front-matter block (`---\n…\n---`) from the head of a
|
|
101
|
+
* Markdown file. Mira does not need to see the metadata; the prose body
|
|
102
|
+
* carries the project description.
|
|
103
|
+
*/
|
|
104
|
+
function stripFrontmatter(raw) {
|
|
105
|
+
if (!raw.startsWith('---'))
|
|
106
|
+
return raw;
|
|
107
|
+
const end = raw.indexOf('\n---', 3);
|
|
108
|
+
if (end === -1)
|
|
109
|
+
return raw;
|
|
110
|
+
const afterFrontmatter = raw.slice(end + 4);
|
|
111
|
+
return afterFrontmatter.replace(/^\r?\n/, '');
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=workspace-context.js.map
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
export function openSession(root) {
|
|
5
|
+
const pugiDir = resolve(root, '.pugi');
|
|
6
|
+
const enabled = existsSync(pugiDir);
|
|
7
|
+
const id = `local-${Date.now()}-${randomUUID()}`;
|
|
8
|
+
const eventsPath = resolve(pugiDir, 'events.jsonl');
|
|
9
|
+
if (enabled) {
|
|
10
|
+
mkdirSync(pugiDir, { recursive: true });
|
|
11
|
+
appendEvent({ id: randomUUID(), sessionId: id, timestamp: now(), type: 'session', name: 'created' }, eventsPath);
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
root,
|
|
16
|
+
pugiDir,
|
|
17
|
+
eventsPath,
|
|
18
|
+
enabled,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function recordCommandStarted(session, command) {
|
|
22
|
+
if (!session.enabled)
|
|
23
|
+
return;
|
|
24
|
+
appendEvent({
|
|
25
|
+
id: randomUUID(),
|
|
26
|
+
sessionId: session.id,
|
|
27
|
+
timestamp: now(),
|
|
28
|
+
type: 'session',
|
|
29
|
+
name: 'command_started',
|
|
30
|
+
command,
|
|
31
|
+
}, session.eventsPath);
|
|
32
|
+
}
|
|
33
|
+
export function recordCommandCompleted(session, command, status) {
|
|
34
|
+
if (!session.enabled)
|
|
35
|
+
return;
|
|
36
|
+
appendEvent({
|
|
37
|
+
id: randomUUID(),
|
|
38
|
+
sessionId: session.id,
|
|
39
|
+
timestamp: now(),
|
|
40
|
+
type: 'session',
|
|
41
|
+
name: 'command_completed',
|
|
42
|
+
command,
|
|
43
|
+
status,
|
|
44
|
+
}, session.eventsPath);
|
|
45
|
+
}
|
|
46
|
+
export function recordToolCall(session, tool, inputSummary) {
|
|
47
|
+
const id = randomUUID();
|
|
48
|
+
if (!session.enabled)
|
|
49
|
+
return id;
|
|
50
|
+
appendEvent({
|
|
51
|
+
id,
|
|
52
|
+
sessionId: session.id,
|
|
53
|
+
timestamp: now(),
|
|
54
|
+
type: 'tool_call',
|
|
55
|
+
tool,
|
|
56
|
+
inputSummary,
|
|
57
|
+
}, session.eventsPath);
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
export function recordToolResult(session, toolCallId, status, outputSummary) {
|
|
61
|
+
if (!session.enabled)
|
|
62
|
+
return;
|
|
63
|
+
appendEvent({
|
|
64
|
+
id: randomUUID(),
|
|
65
|
+
sessionId: session.id,
|
|
66
|
+
timestamp: now(),
|
|
67
|
+
type: 'tool_result',
|
|
68
|
+
toolCallId,
|
|
69
|
+
status,
|
|
70
|
+
outputSummary,
|
|
71
|
+
}, session.eventsPath);
|
|
72
|
+
}
|
|
73
|
+
export function recordFileMutation(session, input) {
|
|
74
|
+
if (!session.enabled)
|
|
75
|
+
return;
|
|
76
|
+
appendEvent({
|
|
77
|
+
id: randomUUID(),
|
|
78
|
+
sessionId: session.id,
|
|
79
|
+
timestamp: now(),
|
|
80
|
+
type: 'file_mutation',
|
|
81
|
+
...input,
|
|
82
|
+
}, session.eventsPath);
|
|
83
|
+
}
|
|
84
|
+
export function recordHookInvoked(session, input) {
|
|
85
|
+
if (!session.enabled)
|
|
86
|
+
return;
|
|
87
|
+
appendEvent({
|
|
88
|
+
id: randomUUID(),
|
|
89
|
+
sessionId: session.id,
|
|
90
|
+
timestamp: now(),
|
|
91
|
+
type: 'hook.invoked',
|
|
92
|
+
event: input.event,
|
|
93
|
+
matchSummary: input.matchSummary,
|
|
94
|
+
runSummary: input.runSummary,
|
|
95
|
+
}, session.eventsPath);
|
|
96
|
+
}
|
|
97
|
+
export function recordHookResult(session, input) {
|
|
98
|
+
if (!session.enabled)
|
|
99
|
+
return;
|
|
100
|
+
appendEvent({
|
|
101
|
+
id: randomUUID(),
|
|
102
|
+
sessionId: session.id,
|
|
103
|
+
timestamp: now(),
|
|
104
|
+
type: 'hook.result',
|
|
105
|
+
event: input.event,
|
|
106
|
+
ok: input.ok,
|
|
107
|
+
exitCode: input.exitCode,
|
|
108
|
+
elapsedMs: input.elapsedMs,
|
|
109
|
+
stdoutLen: input.stdoutLen,
|
|
110
|
+
stderrLen: input.stderrLen,
|
|
111
|
+
}, session.eventsPath);
|
|
112
|
+
}
|
|
113
|
+
export function recordHookSkipped(session, input) {
|
|
114
|
+
if (!session.enabled)
|
|
115
|
+
return;
|
|
116
|
+
appendEvent({
|
|
117
|
+
id: randomUUID(),
|
|
118
|
+
sessionId: session.id,
|
|
119
|
+
timestamp: now(),
|
|
120
|
+
type: 'hook.skipped',
|
|
121
|
+
event: input.event,
|
|
122
|
+
reason: input.reason,
|
|
123
|
+
}, session.eventsPath);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Record a `subagent.spawned` event in the session audit log. Inputs
|
|
127
|
+
* arrive untyped (the dispatcher emits JSON to stay decoupled from this
|
|
128
|
+
* module); the recorder validates the role and isolation at the typed
|
|
129
|
+
* audit-event union boundary, so a malformed role would fail at the
|
|
130
|
+
* `AuditEvent` cast site rather than silently writing garbage. The cast
|
|
131
|
+
* itself is the load-bearing check: `assertSubagentRole` and
|
|
132
|
+
* `assertSubagentIsolation` narrow at runtime.
|
|
133
|
+
*/
|
|
134
|
+
export function recordSubagentSpawned(session, input) {
|
|
135
|
+
if (!session.enabled)
|
|
136
|
+
return;
|
|
137
|
+
appendEvent({
|
|
138
|
+
id: randomUUID(),
|
|
139
|
+
sessionId: session.id,
|
|
140
|
+
timestamp: now(),
|
|
141
|
+
type: 'subagent.spawned',
|
|
142
|
+
taskId: input.taskId,
|
|
143
|
+
role: assertSubagentRole(input.role),
|
|
144
|
+
personaSlug: input.personaSlug,
|
|
145
|
+
parentSessionId: input.parentSessionId,
|
|
146
|
+
isolation: assertSubagentIsolation(input.isolation),
|
|
147
|
+
}, session.eventsPath);
|
|
148
|
+
}
|
|
149
|
+
export function recordSubagentToolCall(session, input) {
|
|
150
|
+
if (!session.enabled)
|
|
151
|
+
return;
|
|
152
|
+
appendEvent({
|
|
153
|
+
id: randomUUID(),
|
|
154
|
+
sessionId: session.id,
|
|
155
|
+
timestamp: now(),
|
|
156
|
+
type: 'subagent.tool_call',
|
|
157
|
+
taskId: input.taskId,
|
|
158
|
+
role: assertSubagentRole(input.role),
|
|
159
|
+
personaSlug: input.personaSlug,
|
|
160
|
+
toolName: input.toolName,
|
|
161
|
+
toolCallId: input.toolCallId,
|
|
162
|
+
}, session.eventsPath);
|
|
163
|
+
}
|
|
164
|
+
export function recordSubagentCompleted(session, input) {
|
|
165
|
+
if (!session.enabled)
|
|
166
|
+
return;
|
|
167
|
+
appendEvent({
|
|
168
|
+
id: randomUUID(),
|
|
169
|
+
sessionId: session.id,
|
|
170
|
+
timestamp: now(),
|
|
171
|
+
type: 'subagent.completed',
|
|
172
|
+
taskId: input.taskId,
|
|
173
|
+
role: assertSubagentRole(input.role),
|
|
174
|
+
personaSlug: input.personaSlug,
|
|
175
|
+
toolCallCount: input.toolCallCount,
|
|
176
|
+
tokensIn: input.tokensIn,
|
|
177
|
+
tokensOut: input.tokensOut,
|
|
178
|
+
durationMs: input.durationMs,
|
|
179
|
+
}, session.eventsPath);
|
|
180
|
+
}
|
|
181
|
+
export function recordSubagentBlocked(session, input) {
|
|
182
|
+
if (!session.enabled)
|
|
183
|
+
return;
|
|
184
|
+
appendEvent({
|
|
185
|
+
id: randomUUID(),
|
|
186
|
+
sessionId: session.id,
|
|
187
|
+
timestamp: now(),
|
|
188
|
+
type: 'subagent.blocked',
|
|
189
|
+
taskId: input.taskId,
|
|
190
|
+
role: assertSubagentRole(input.role),
|
|
191
|
+
personaSlug: input.personaSlug,
|
|
192
|
+
reason: assertSubagentBlockedReason(input.reason),
|
|
193
|
+
detail: input.detail,
|
|
194
|
+
}, session.eventsPath);
|
|
195
|
+
}
|
|
196
|
+
export function recordSubagentFailed(session, input) {
|
|
197
|
+
if (!session.enabled)
|
|
198
|
+
return;
|
|
199
|
+
appendEvent({
|
|
200
|
+
id: randomUUID(),
|
|
201
|
+
sessionId: session.id,
|
|
202
|
+
timestamp: now(),
|
|
203
|
+
type: 'subagent.failed',
|
|
204
|
+
taskId: input.taskId,
|
|
205
|
+
role: assertSubagentRole(input.role),
|
|
206
|
+
personaSlug: input.personaSlug,
|
|
207
|
+
error: input.error,
|
|
208
|
+
}, session.eventsPath);
|
|
209
|
+
}
|
|
210
|
+
const SUBAGENT_ROLES = new Set([
|
|
211
|
+
'orchestrator',
|
|
212
|
+
'architect',
|
|
213
|
+
'coder',
|
|
214
|
+
'verifier',
|
|
215
|
+
'reviewer',
|
|
216
|
+
'researcher',
|
|
217
|
+
'release',
|
|
218
|
+
'devops',
|
|
219
|
+
'design_qa',
|
|
220
|
+
]);
|
|
221
|
+
const SUBAGENT_ISOLATIONS = new Set([
|
|
222
|
+
'prompt_only',
|
|
223
|
+
'shared_fs_readonly',
|
|
224
|
+
'shared_fs_serialized',
|
|
225
|
+
'worktree',
|
|
226
|
+
'remote_vm',
|
|
227
|
+
]);
|
|
228
|
+
const SUBAGENT_BLOCKED_REASONS = new Set([
|
|
229
|
+
'budget_exhausted',
|
|
230
|
+
'plan_mode_refused',
|
|
231
|
+
'permission_denied',
|
|
232
|
+
'tool_unavailable',
|
|
233
|
+
]);
|
|
234
|
+
function assertSubagentRole(value) {
|
|
235
|
+
if (!SUBAGENT_ROLES.has(value)) {
|
|
236
|
+
throw new Error(`recordSubagent*: unknown role '${value}'`);
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
function assertSubagentIsolation(value) {
|
|
241
|
+
if (!SUBAGENT_ISOLATIONS.has(value)) {
|
|
242
|
+
throw new Error(`recordSubagent*: unknown isolation '${value}'`);
|
|
243
|
+
}
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
function assertSubagentBlockedReason(value) {
|
|
247
|
+
if (!SUBAGENT_BLOCKED_REASONS.has(value)) {
|
|
248
|
+
throw new Error(`recordSubagent*: unknown blocked reason '${value}'`);
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
function appendEvent(event, eventsPath) {
|
|
253
|
+
appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
254
|
+
}
|
|
255
|
+
function now() {
|
|
256
|
+
return new Date().toISOString();
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const pugiSettingsSchema = z.object({
|
|
5
|
+
schema: z.number().int().positive().default(1),
|
|
6
|
+
workflow: z
|
|
7
|
+
.object({
|
|
8
|
+
brand: z.string().default('pugi'),
|
|
9
|
+
legacyName: z.string().optional(),
|
|
10
|
+
approvals: z.enum(['auto', 'manual']).default('auto'),
|
|
11
|
+
notAutomatic: z.array(z.string()).default([]),
|
|
12
|
+
defaultBaseBranch: z.string().default('dev'),
|
|
13
|
+
branchPrefixes: z.array(z.string()).default(['feature', 'fix', 'refactor', 'chore']),
|
|
14
|
+
aiCoAuthorTrailers: z.boolean().default(false),
|
|
15
|
+
})
|
|
16
|
+
.default({}),
|
|
17
|
+
permissions: z
|
|
18
|
+
.object({
|
|
19
|
+
mode: z.enum(['plan', 'ask', 'acceptEdits', 'auto', 'dontAsk', 'bypassPermissions']).default('auto'),
|
|
20
|
+
allow: z.array(z.string()).default([]),
|
|
21
|
+
deny: z.array(z.string()).default([]),
|
|
22
|
+
notAutomatic: z.array(z.string()).default([]),
|
|
23
|
+
})
|
|
24
|
+
.default({}),
|
|
25
|
+
privacy: z
|
|
26
|
+
.object({
|
|
27
|
+
mode: z.enum(['balanced', 'embeddings-only', 'airgapped']).default('balanced'),
|
|
28
|
+
telemetry: z.enum(['off', 'anonymous', 'community']).default('off'),
|
|
29
|
+
})
|
|
30
|
+
.default({}),
|
|
31
|
+
artifacts: z
|
|
32
|
+
.object({
|
|
33
|
+
defaultPath: z.string().default('.pugi/artifacts'),
|
|
34
|
+
promoteExplicitly: z.boolean().default(true),
|
|
35
|
+
})
|
|
36
|
+
.default({}),
|
|
37
|
+
// `web.fetch.enabled` gates the `pugi web` / `/web` SSRF-guarded
|
|
38
|
+
// fetcher. Default-off matches the spec posture; the schema must
|
|
39
|
+
// declare it explicitly because Zod's strict-pass strips unknown
|
|
40
|
+
// keys and would silently swallow the operator's intent.
|
|
41
|
+
web: z
|
|
42
|
+
.object({
|
|
43
|
+
fetch: z
|
|
44
|
+
.object({
|
|
45
|
+
enabled: z.boolean().optional(),
|
|
46
|
+
})
|
|
47
|
+
.optional(),
|
|
48
|
+
})
|
|
49
|
+
.optional(),
|
|
50
|
+
});
|
|
51
|
+
export function loadSettings(root) {
|
|
52
|
+
const settingsPath = resolve(root, '.pugi/settings.json');
|
|
53
|
+
if (!existsSync(settingsPath)) {
|
|
54
|
+
return pugiSettingsSchema.parse({});
|
|
55
|
+
}
|
|
56
|
+
const parsed = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
57
|
+
return pugiSettingsSchema.parse(parsed);
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=settings.js.map
|