@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2

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 (68) hide show
  1. package/README.md +33 -0
  2. package/assets/pugi-mascot.ansi +41 -0
  3. package/dist/commands/deploy.js +439 -0
  4. package/dist/core/agents/loader.js +104 -0
  5. package/dist/core/agents/registry.js +1 -1
  6. package/dist/core/consensus/anvil-fanout.js +276 -0
  7. package/dist/core/consensus/diff-capture.js +382 -0
  8. package/dist/core/consensus/rubric.js +233 -0
  9. package/dist/core/context/index.js +21 -0
  10. package/dist/core/context/pugiignore.js +316 -0
  11. package/dist/core/context/repo-skeleton.js +533 -0
  12. package/dist/core/context/watcher.js +342 -0
  13. package/dist/core/context/working-set.js +165 -0
  14. package/dist/core/edits/dispatch.js +185 -0
  15. package/dist/core/edits/index.js +15 -0
  16. package/dist/core/edits/layer-a-apply.js +217 -0
  17. package/dist/core/edits/layer-b-apply.js +211 -0
  18. package/dist/core/edits/layer-c-apply.js +160 -0
  19. package/dist/core/edits/layer-d-ast.js +29 -0
  20. package/dist/core/edits/marker-parser.js +401 -0
  21. package/dist/core/edits/security-gate.js +223 -0
  22. package/dist/core/edits/worktree.js +229 -0
  23. package/dist/core/engine/native-pugi.js +6 -1
  24. package/dist/core/engine/prompts.js +4 -1
  25. package/dist/core/engine/tool-bridge.js +33 -1
  26. package/dist/core/lsp/client.js +631 -0
  27. package/dist/core/repl/ask.js +512 -0
  28. package/dist/core/repl/cancellation.js +98 -0
  29. package/dist/core/repl/dispatch-fsm.js +220 -0
  30. package/dist/core/repl/privacy-banner.js +71 -0
  31. package/dist/core/repl/session.js +1896 -13
  32. package/dist/core/repl/slash-commands.js +59 -32
  33. package/dist/core/repl/store/index.js +12 -0
  34. package/dist/core/repl/store/jsonl-log.js +321 -0
  35. package/dist/core/repl/store/lockfile.js +155 -0
  36. package/dist/core/repl/store/session-store.js +792 -0
  37. package/dist/core/repl/store/types.js +44 -0
  38. package/dist/core/repl/store/uuid-v7.js +68 -0
  39. package/dist/core/repl/workspace-context.js +72 -1
  40. package/dist/core/skills/loader.js +454 -0
  41. package/dist/core/skills/sources.js +480 -0
  42. package/dist/core/skills/trust.js +172 -0
  43. package/dist/runtime/cli.js +767 -10
  44. package/dist/runtime/commands/agents.js +385 -0
  45. package/dist/runtime/commands/config.js +338 -8
  46. package/dist/runtime/commands/lsp.js +184 -0
  47. package/dist/runtime/commands/patch.js +111 -0
  48. package/dist/runtime/commands/review-consensus.js +399 -0
  49. package/dist/runtime/commands/skills.js +401 -0
  50. package/dist/runtime/commands/worktree.js +133 -0
  51. package/dist/tools/apply-patch.js +314 -0
  52. package/dist/tools/file-tools.js +90 -0
  53. package/dist/tools/lsp-tools.js +189 -0
  54. package/dist/tools/registry.js +18 -0
  55. package/dist/tools/web-fetch.js +1 -1
  56. package/dist/tui/agent-tree-pane.js +9 -0
  57. package/dist/tui/ask-cli.js +52 -0
  58. package/dist/tui/ask-modal.js +211 -0
  59. package/dist/tui/conversation-pane.js +48 -3
  60. package/dist/tui/input-box.js +48 -5
  61. package/dist/tui/markdown-render.js +266 -0
  62. package/dist/tui/repl-render.js +185 -0
  63. package/dist/tui/repl-splash-mascot.js +130 -0
  64. package/dist/tui/repl-splash.js +7 -1
  65. package/dist/tui/repl.js +82 -11
  66. package/dist/tui/status-bar.js +63 -3
  67. package/dist/tui/tool-stream-pane.js +91 -0
  68. package/package.json +11 -5
@@ -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
@@ -34,7 +34,7 @@ function requirePersona(slug) {
34
34
  * pipeline already merges the two surfaces.
35
35
  */
36
36
  export const SUBAGENT_REGISTRY = [
37
- { role: 'orchestrator', persona: requirePersona('main') }, // Mira (Pug)
37
+ { role: 'orchestrator', persona: requirePersona('main') }, // Pugi (Pug)
38
38
  { role: 'architect', persona: requirePersona('architect') }, // Marcus (Owl)
39
39
  { role: 'coder', persona: requirePersona('dev') }, // Hiroshi (Wolf)
40
40
  { role: 'verifier', persona: requirePersona('qa') }, // Vera (Fox)
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Anvil fan-out — `pugi review --consensus` (α6.7).
3
+ *
4
+ * Posts the captured diff to Anvil's consensus endpoint and consumes the
5
+ * SSE stream that interleaves per-reviewer events (`type:"verdict"`) and
6
+ * the final consensus event (`type:"consensus"`).
7
+ *
8
+ * Endpoint contract (admin-api side, ships as α6.7.1 follow-up):
9
+ *
10
+ * POST {apiUrl}/api/pugi/review-consensus
11
+ * Authorization: Bearer {apiKey}
12
+ * Content-Type: application/json
13
+ * Body: { diff, context: { branch, commit, title } }
14
+ * Response: text/event-stream
15
+ * data: { reviewer: "codex"|"claude"|"deepseek", type:"started" }
16
+ * data: { reviewer, type:"verdict", severity:"P0|P1|P2|P3|CLEAN",
17
+ * rawContent:"<reviewer text>", latencyMs, error? }
18
+ * data: { type:"consensus", rubric_verdict, reasoning }
19
+ *
20
+ * The CLI side does NOT trust the server's `rubric_verdict` — we recompute
21
+ * it locally from the per-reviewer verdicts so a malformed / forged server
22
+ * cannot weaken the gate. The server-side verdict comes through as a
23
+ * cross-check (logged when it disagrees with the client rubric).
24
+ *
25
+ * Graceful degradation:
26
+ *
27
+ * - 404 from runtime → "endpoint_missing" (admin-api endpoint pending,
28
+ * α6.7 ships CLI-only). Caller falls back to the
29
+ * legacy `pugi review --triple --remote` flow OR
30
+ * prints a "backend not deployed" notice depending
31
+ * on the operator's invocation.
32
+ * - 401/403 / 429 → matching status with an actionable message.
33
+ * - 5xx / timeout → "failed" with the truncated body.
34
+ *
35
+ * Local-first contract (ADR-0037): this module never touches the disk,
36
+ * never logs the diff payload, and never retries on transient errors.
37
+ */
38
+ /**
39
+ * Dispatch the consensus request and stream events back through `sink`
40
+ * until the SSE stream closes OR a transport error occurs.
41
+ *
42
+ * Returns the collected reviewer events plus the server's final consensus
43
+ * event (if it sent one). The caller computes the authoritative rubric
44
+ * locally from the reviewer events.
45
+ */
46
+ export async function dispatchConsensus(config, request, sink) {
47
+ const url = `${config.apiUrl.replace(/\/+$/, '')}/api/pugi/review-consensus`;
48
+ const controller = new AbortController();
49
+ // Idle timeout: aborts the request when no SSE chunk has been
50
+ // received for `timeoutMs`. A one-shot setTimeout from request-start
51
+ // would kill long-running reviewers (codex ~30s, claude ~60s) even
52
+ // though the server is actively streaming events every few seconds.
53
+ // `resetIdleTimeout` is called from `parseSseStream` on each chunk.
54
+ let idleTimer = null;
55
+ const resetIdleTimeout = () => {
56
+ if (idleTimer)
57
+ clearTimeout(idleTimer);
58
+ idleTimer = setTimeout(() => controller.abort(), config.timeoutMs);
59
+ };
60
+ resetIdleTimeout();
61
+ try {
62
+ const res = await fetch(url, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'content-type': 'application/json',
66
+ accept: 'text/event-stream',
67
+ authorization: `Bearer ${config.apiKey}`,
68
+ 'user-agent': 'pugi-cli-consensus/0.1.0',
69
+ },
70
+ body: JSON.stringify(request),
71
+ signal: controller.signal,
72
+ });
73
+ const code = res.status;
74
+ if (code === 404) {
75
+ return {
76
+ status: 'endpoint_missing',
77
+ code,
78
+ message: 'POST /api/pugi/review-consensus not deployed on this runtime (α6.7.1 follow-up).',
79
+ };
80
+ }
81
+ if (code === 401 || code === 403) {
82
+ return {
83
+ status: 'unauthenticated',
84
+ code,
85
+ message: `runtime rejected credentials (HTTP ${code})`,
86
+ };
87
+ }
88
+ if (code === 429) {
89
+ const header = res.headers.get('retry-after');
90
+ const retryAfterMs = header ? Number.parseInt(header, 10) * 1000 : 60_000;
91
+ return {
92
+ status: 'rate_limited',
93
+ code,
94
+ retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : 60_000,
95
+ message: 'runtime rate limit reached for this tenant',
96
+ };
97
+ }
98
+ if (code !== 200) {
99
+ const text = await safeText(res);
100
+ return {
101
+ status: 'failed',
102
+ code,
103
+ message: `runtime returned HTTP ${code}${text ? `: ${text.slice(0, 200)}` : ''}`,
104
+ };
105
+ }
106
+ // 200 — consume the SSE stream. Surface a graceful failure if the
107
+ // body is missing (some intermediaries strip it on long-poll).
108
+ if (!res.body) {
109
+ return {
110
+ status: 'failed',
111
+ code,
112
+ message: 'runtime returned 200 but no SSE body',
113
+ };
114
+ }
115
+ const reviewerEvents = [];
116
+ let serverVerdict = null;
117
+ for await (const event of parseSseStream(res.body, resetIdleTimeout)) {
118
+ if (event.type === 'consensus') {
119
+ serverVerdict = event;
120
+ sink(event);
121
+ }
122
+ else {
123
+ reviewerEvents.push(event);
124
+ sink(event);
125
+ }
126
+ }
127
+ return { status: 'ok', serverVerdict, reviewerEvents };
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error
131
+ ? error.name === 'AbortError'
132
+ ? `runtime call idle for more than ${config.timeoutMs}ms`
133
+ : error.message
134
+ : 'unknown error';
135
+ return { status: 'failed', code: 0, message };
136
+ }
137
+ finally {
138
+ if (idleTimer)
139
+ clearTimeout(idleTimer);
140
+ }
141
+ }
142
+ /**
143
+ * Async-iterable SSE parser. Spec'd against the
144
+ * [HTML SSE spec](https://html.spec.whatwg.org/multipage/server-sent-events.html):
145
+ *
146
+ * - Events are delimited by a blank line.
147
+ * - Each line is `field:value` (whitespace after `:` stripped).
148
+ * - Multiple `data:` lines in one event concatenate with `\n`.
149
+ * - We only care about `data:` payloads carrying JSON; everything
150
+ * else (event:, id:, retry:) is ignored.
151
+ *
152
+ * The parser tolerates JSON-parse failures by dropping the malformed
153
+ * event and continuing; a single bad event must not block the consensus
154
+ * gate. Errors are surfaced to the sink as an `error` reviewer event
155
+ * with `reviewer:"stream"`.
156
+ */
157
+ export async function* parseSseStream(body, onChunk) {
158
+ const decoder = new TextDecoder('utf-8');
159
+ let buffer = '';
160
+ // Bridge Node ReadableStream and web ReadableStream. The web type has
161
+ // `getReader()`; the Node type is an `AsyncIterable<Buffer>`. Detect
162
+ // by feature so the parser works regardless of which fetch impl wrote
163
+ // the body.
164
+ const chunks = toAsyncIterable(body);
165
+ for await (const chunk of chunks) {
166
+ // Signal liveness so the dispatcher can reset its idle timeout. Any
167
+ // bytes received counts: even a heartbeat comment line keeps the
168
+ // connection alive from our perspective.
169
+ if (onChunk)
170
+ onChunk();
171
+ buffer += decoder.decode(chunk, { stream: true });
172
+ // SSE event boundary is a blank line. The spec allows either LF
173
+ // (`\n\n`) OR CRLF (`\r\n\r\n`). nginx, CDNs, and some load
174
+ // balancers rewrite to CRLF, so we accept both and find whichever
175
+ // delimiter appears first in the buffer.
176
+ let boundary = findNextEventBoundary(buffer);
177
+ while (boundary !== null) {
178
+ const rawEvent = buffer.slice(0, boundary.start);
179
+ buffer = buffer.slice(boundary.end);
180
+ const parsed = parseSseEvent(rawEvent);
181
+ if (parsed)
182
+ yield parsed;
183
+ boundary = findNextEventBoundary(buffer);
184
+ }
185
+ }
186
+ // Flush trailing event if the server omitted the final blank line.
187
+ const tail = buffer.trim();
188
+ if (tail.length > 0) {
189
+ const parsed = parseSseEvent(tail);
190
+ if (parsed)
191
+ yield parsed;
192
+ }
193
+ }
194
+ /**
195
+ * Find the next SSE event boundary in `buffer`. Returns the start
196
+ * index of the delimiter and the index where the next event begins,
197
+ * or `null` if no complete boundary has been buffered yet.
198
+ *
199
+ * Accepts both `\n\n` (Unix-style streams) and `\r\n\r\n` (CRLF, as
200
+ * emitted by nginx, Cloudflare, and some Node intermediaries). Picks
201
+ * whichever appears FIRST so a stream that mixes both styles parses
202
+ * deterministically.
203
+ */
204
+ function findNextEventBoundary(buffer) {
205
+ const lfIdx = buffer.indexOf('\n\n');
206
+ const crlfIdx = buffer.indexOf('\r\n\r\n');
207
+ if (lfIdx === -1 && crlfIdx === -1)
208
+ return null;
209
+ if (crlfIdx === -1)
210
+ return { start: lfIdx, end: lfIdx + 2 };
211
+ if (lfIdx === -1)
212
+ return { start: crlfIdx, end: crlfIdx + 4 };
213
+ if (lfIdx < crlfIdx)
214
+ return { start: lfIdx, end: lfIdx + 2 };
215
+ return { start: crlfIdx, end: crlfIdx + 4 };
216
+ }
217
+ function parseSseEvent(raw) {
218
+ const dataLines = [];
219
+ for (const line of raw.split(/\r?\n/)) {
220
+ // The SSE spec strips one leading space after the colon if present.
221
+ // We do the same so payloads written `data: {...}` parse correctly.
222
+ if (!line.startsWith('data:'))
223
+ continue;
224
+ const value = line.slice('data:'.length);
225
+ dataLines.push(value.startsWith(' ') ? value.slice(1) : value);
226
+ }
227
+ if (dataLines.length === 0)
228
+ return null;
229
+ const payload = dataLines.join('\n').trim();
230
+ if (payload.length === 0)
231
+ return null;
232
+ try {
233
+ const parsed = JSON.parse(payload);
234
+ if (parsed && typeof parsed === 'object' && typeof parsed['type'] === 'string') {
235
+ // Trust shape coming from the server enough to forward it; the
236
+ // command handler treats unknown fields defensively.
237
+ return parsed;
238
+ }
239
+ return null;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ function toAsyncIterable(body) {
246
+ // Web ReadableStream → iterate via reader.read() loop bridged to async-iter.
247
+ if (typeof body.getReader === 'function') {
248
+ return webStreamToAsyncIterable(body);
249
+ }
250
+ // Node Readable: already async-iterable.
251
+ return body;
252
+ }
253
+ async function* webStreamToAsyncIterable(stream) {
254
+ const reader = stream.getReader();
255
+ try {
256
+ while (true) {
257
+ const { done, value } = await reader.read();
258
+ if (done)
259
+ return;
260
+ if (value)
261
+ yield value;
262
+ }
263
+ }
264
+ finally {
265
+ reader.releaseLock();
266
+ }
267
+ }
268
+ async function safeText(res) {
269
+ try {
270
+ return await res.text();
271
+ }
272
+ catch {
273
+ return '';
274
+ }
275
+ }
276
+ //# sourceMappingURL=anvil-fanout.js.map