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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +157 -45
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. package/package.json +11 -5
@@ -1,3 +1,4 @@
1
+ import { getJobRegistry, summarizeJobsForPrompt, } from '../jobs/registry.js';
1
2
  /**
2
3
  * System prompts for each engine command. Each prompt:
3
4
  * - Anchors the model in Pugi's local-first contract (ADR-0037).
@@ -11,6 +12,12 @@
11
12
  * the persona system prompt comes from the runtime (Anvil bridge
12
13
  * prepends `oes-dev` / Sigma prompt automatically when configured); these
13
14
  * prompts ride on top and scope the model to the current command.
15
+ *
16
+ * Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J): the system prompt picks up
17
+ * a `BACKGROUND JOBS:` snapshot appended at the tail so the agent loop
18
+ * knows what background bash work is currently on watch and can avoid
19
+ * spawning a duplicate. The snapshot is sourced from `JobRegistry` and
20
+ * formatted by `summarizeJobsForPrompt` so the surface is single-sourced.
14
21
  */
15
22
  const COMMON_LOCAL_FIRST_PREAMBLE = [
16
23
  'You are the Pugi CLI agent running locally inside the operator\'s repository.',
@@ -26,6 +33,13 @@ const EDIT_FLOW_RULES = [
26
33
  'After your last tool call, summarise what you changed and what the operator should review.',
27
34
  ].join(' ');
28
35
  export function systemPromptFor(kind) {
36
+ const base = baseSystemPromptFor(kind);
37
+ const snapshot = formatBackgroundJobsSnapshot(getJobRegistrySafely());
38
+ if (!snapshot)
39
+ return base;
40
+ return `${base}\n\n${snapshot}`;
41
+ }
42
+ function baseSystemPromptFor(kind) {
29
43
  switch (kind) {
30
44
  case 'code':
31
45
  return [
@@ -64,6 +78,34 @@ export function systemPromptFor(kind) {
64
78
  ].join('\n\n');
65
79
  }
66
80
  }
81
+ /**
82
+ * Builds the BACKGROUND JOBS snapshot block injected at the tail of
83
+ * the system prompt. Sync because the surrounding `systemPromptFor`
84
+ * builder is sync (the engine adapter does not await prompt
85
+ * assembly) and the JobRegistry's `listSync()` is essentially free
86
+ * (single JSON file read with sync fs primitives). Returns an empty
87
+ * string if the registry cannot be reached so the prompt assembly
88
+ * never crashes when the ledger is unavailable.
89
+ */
90
+ export function formatBackgroundJobsSnapshot(registry) {
91
+ if (!registry)
92
+ return '';
93
+ try {
94
+ const entries = registry.listSync();
95
+ return summarizeJobsForPrompt(entries);
96
+ }
97
+ catch {
98
+ return '';
99
+ }
100
+ }
101
+ function getJobRegistrySafely() {
102
+ try {
103
+ return getJobRegistry();
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
67
109
  /**
68
110
  * Anvil persona slug to invoke per command. Today every command routes
69
111
  * to `oes-dev` (Sigma) — the Tier-2 reviewer persona already configured
@@ -1,4 +1,5 @@
1
- import { bashTool, editTool, globTool, grepTool, readTool, writeTool, } from '../../tools/file-tools.js';
1
+ import { editTool, globTool, grepTool, readTool, writeTool, } from '../../tools/file-tools.js';
2
+ import { bashToolSync } from '../../tools/bash.js';
2
3
  /**
3
4
  * Tool-bridge: turns the abstract tool registry into:
4
5
  * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
@@ -134,7 +135,7 @@ function requireString(obj, key) {
134
135
  return v;
135
136
  }
136
137
  export function buildExecutor(input) {
137
- const { kind, ctx } = input;
138
+ const { kind, ctx, hooks, sessionId } = input;
138
139
  const planMode = kind === 'plan';
139
140
  return async ({ name, arguments: argsRaw }) => {
140
141
  if (!WIRED_TOOLS.has(name)) {
@@ -146,70 +147,167 @@ export function buildExecutor(input) {
146
147
  // outcome, not a failure, because plan mode is doing its job.
147
148
  throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
148
149
  }
149
- const args = parseArgs(argsRaw);
150
- switch (name) {
151
- case 'read': {
152
- const { path } = { path: requireString(args, 'path') };
153
- const content = readTool(ctx, path);
154
- // Cap the content surfaced back to the model so a 10MB file
155
- // does not blow the context window. The model sees the head
156
- // and a truncation marker; if it needs more it can grep.
157
- const CAP = 32 * 1024;
158
- if (content.length > CAP) {
159
- return `${content.slice(0, CAP)}\n(...truncated at ${CAP} bytes; use grep or glob to narrow the read)`;
150
+ // Fire PreToolUse hooks. The match grammar takes the tool name and
151
+ // (when extractable) the target path. Each new tool dispatch starts a
152
+ // fresh dedup batch so a hook fires once per dispatch, not once per
153
+ // session.
154
+ if (hooks && sessionId) {
155
+ hooks.resetBatch();
156
+ const path = extractToolPath(name, argsRaw);
157
+ const preCtx = {
158
+ sessionId,
159
+ event: 'PreToolUse',
160
+ tool: name,
161
+ path,
162
+ payload: { tool: name, arguments: argsRaw },
163
+ };
164
+ // List the matching hooks BEFORE firing so we can correlate
165
+ // hook[i] with result[i] when checking onFailure: 'block'. The
166
+ // ordering of listMatching and fire is stable: fire iterates the
167
+ // same listMatching output internally.
168
+ const matchingPreHooks = hooks.listMatching(preCtx);
169
+ const preResults = await hooks.fire(preCtx);
170
+ for (let i = 0; i < matchingPreHooks.length; i += 1) {
171
+ const hook = matchingPreHooks[i];
172
+ const result = preResults[i];
173
+ if (hook && result && hook.onFailure === 'block' && !result.ok) {
174
+ throw new Error(`HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
160
175
  }
161
- return content;
162
- }
163
- case 'write': {
164
- const wargs = {
165
- path: requireString(args, 'path'),
166
- content: requireString(args, 'content'),
167
- };
168
- writeTool(ctx, wargs.path, wargs.content);
169
- return `wrote ${wargs.path} (${wargs.content.length} bytes)`;
170
- }
171
- case 'edit': {
172
- const eargs = {
173
- path: requireString(args, 'path'),
174
- oldString: requireString(args, 'oldString'),
175
- newString: requireString(args, 'newString'),
176
- };
177
- editTool(ctx, eargs.path, eargs.oldString, eargs.newString);
178
- return `edited ${eargs.path}`;
179
176
  }
180
- case 'grep': {
181
- const gargs = { query: requireString(args, 'query') };
182
- const matches = grepTool(ctx, gargs.query);
183
- if (matches.length === 0)
184
- return `no matches for ${gargs.query}`;
185
- const head = matches.slice(0, 50);
186
- const rendered = head.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n');
187
- const more = matches.length > head.length ? `\n(... ${matches.length - head.length} more)` : '';
188
- return `${matches.length} match(es):\n${rendered}${more}`;
189
- }
190
- case 'glob': {
191
- const gargs = { pattern: requireString(args, 'pattern') };
192
- const results = globTool(ctx, gargs.pattern);
193
- if (results.length === 0)
194
- return `no paths match ${gargs.pattern}`;
195
- return `${results.length} path(s):\n${results.slice(0, 100).join('\n')}${results.length > 100 ? `\n(... ${results.length - 100} more)` : ''}`;
177
+ }
178
+ const args = parseArgs(argsRaw);
179
+ const dispatch = async () => {
180
+ return dispatchTool(name, args, ctx);
181
+ };
182
+ try {
183
+ const result = await dispatch();
184
+ if (hooks && sessionId) {
185
+ const path = extractToolPath(name, argsRaw);
186
+ await hooks.fire({
187
+ sessionId,
188
+ event: 'PostToolUse',
189
+ tool: name,
190
+ path,
191
+ payload: { tool: name, arguments: argsRaw, ok: true, result: result.slice(0, 1024) },
192
+ });
196
193
  }
197
- case 'bash': {
198
- const bargs = { command: requireString(args, 'command') };
199
- const result = bashTool(ctx, bargs.command);
200
- const body = [
201
- `exit=${result.exitCode}`,
202
- result.stdout ? `stdout:\n${result.stdout}` : '',
203
- result.stderr ? `stderr:\n${result.stderr}` : '',
204
- ]
205
- .filter(Boolean)
206
- .join('\n');
207
- return body || '(no output)';
194
+ return result;
195
+ }
196
+ catch (error) {
197
+ if (hooks && sessionId) {
198
+ const path = extractToolPath(name, argsRaw);
199
+ await hooks.fire({
200
+ sessionId,
201
+ event: 'PostToolUseFailure',
202
+ tool: name,
203
+ path,
204
+ payload: {
205
+ tool: name,
206
+ arguments: argsRaw,
207
+ ok: false,
208
+ error: error instanceof Error ? error.message : String(error),
209
+ },
210
+ });
208
211
  }
209
- default:
210
- // Exhaustive; unreachable because of the WIRED_TOOLS guard above.
211
- throw new Error(`unhandled tool: ${name}`);
212
+ throw error;
212
213
  }
213
214
  };
214
215
  }
216
+ /**
217
+ * Best-effort extraction of the file path a tool targets. Returns
218
+ * undefined when the tool does not take a path or the path cannot be
219
+ * parsed cleanly — match rules with a `pathGlob` will then skip this
220
+ * dispatch, which is the safe default.
221
+ */
222
+ function extractToolPath(name, argsRaw) {
223
+ if (name !== 'read' && name !== 'write' && name !== 'edit')
224
+ return undefined;
225
+ try {
226
+ const parsed = JSON.parse(argsRaw);
227
+ const path = parsed.path;
228
+ return typeof path === 'string' ? path : undefined;
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ }
234
+ function dispatchTool(name, args, ctx) {
235
+ switch (name) {
236
+ case 'read': {
237
+ const { path } = { path: requireString(args, 'path') };
238
+ const content = readTool(ctx, path);
239
+ // Cap the content surfaced back to the model so a 10MB file
240
+ // does not blow the context window. The model sees the head
241
+ // and a truncation marker; if it needs more it can grep.
242
+ const CAP = 32 * 1024;
243
+ if (content.length > CAP) {
244
+ return `${content.slice(0, CAP)}\n(...truncated at ${CAP} bytes; use grep or glob to narrow the read)`;
245
+ }
246
+ return content;
247
+ }
248
+ case 'write': {
249
+ const wargs = {
250
+ path: requireString(args, 'path'),
251
+ content: requireString(args, 'content'),
252
+ };
253
+ writeTool(ctx, wargs.path, wargs.content);
254
+ return `wrote ${wargs.path} (${wargs.content.length} bytes)`;
255
+ }
256
+ case 'edit': {
257
+ const eargs = {
258
+ path: requireString(args, 'path'),
259
+ oldString: requireString(args, 'oldString'),
260
+ newString: requireString(args, 'newString'),
261
+ };
262
+ editTool(ctx, eargs.path, eargs.oldString, eargs.newString);
263
+ return `edited ${eargs.path}`;
264
+ }
265
+ case 'grep': {
266
+ const gargs = { query: requireString(args, 'query') };
267
+ const matches = grepTool(ctx, gargs.query);
268
+ if (matches.length === 0)
269
+ return `no matches for ${gargs.query}`;
270
+ const head = matches.slice(0, 50);
271
+ const rendered = head.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n');
272
+ const more = matches.length > head.length ? `\n(... ${matches.length - head.length} more)` : '';
273
+ return `${matches.length} match(es):\n${rendered}${more}`;
274
+ }
275
+ case 'glob': {
276
+ const gargs = { pattern: requireString(args, 'pattern') };
277
+ const results = globTool(ctx, gargs.pattern);
278
+ if (results.length === 0)
279
+ return `no paths match ${gargs.pattern}`;
280
+ return `${results.length} path(s):\n${results.slice(0, 100).join('\n')}${results.length > 100 ? `\n(... ${results.length - 100} more)` : ''}`;
281
+ }
282
+ case 'bash': {
283
+ const bargs = { command: requireString(args, 'command') };
284
+ // The class-aware bash tool (sprint α5.2) replaces the legacy
285
+ // file-tools entry point. We use the sync variant here because
286
+ // dispatchTool's signature is sync; the async tool is reserved
287
+ // for the REPL path (sprint α5.7) where promises are first class.
288
+ const result = bashToolSync({ cmd: bargs.command }, {
289
+ root: ctx.root,
290
+ settings: ctx.settings,
291
+ session: ctx.session,
292
+ source: 'agent',
293
+ });
294
+ const parts = [
295
+ `exit=${result.exitCode}`,
296
+ result.stdout ? `stdout:\n${result.stdout}` : '',
297
+ result.stderr ? `stderr:\n${result.stderr}` : '',
298
+ ];
299
+ if (result.artifactRef)
300
+ parts.push(`artifactRef=${result.artifactRef}`);
301
+ if (result.truncated)
302
+ parts.push('truncated=true');
303
+ if (result.timedOut)
304
+ parts.push('timedOut=true');
305
+ const body = parts.filter(Boolean).join('\n');
306
+ return body || '(no output)';
307
+ }
308
+ default:
309
+ // Exhaustive; unreachable because of the WIRED_TOOLS guard above.
310
+ throw new Error(`unhandled tool: ${name}`);
311
+ }
312
+ }
215
313
  //# sourceMappingURL=tool-bridge.js.map