@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20

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 (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. package/package.json +9 -6
@@ -38,20 +38,26 @@ import { listRoles } from '../agents/registry.js';
38
38
  * silently appear here with empty placeholders.
39
39
  */
40
40
  export const SLASH_STUB_MESSAGES = Object.freeze({
41
- compact: 'Manual context compaction lands in α6.5b.',
41
+ // Leak L8 (2026-05-27): /compact graduated from stub. The session
42
+ // module now owns the summariser round-trip + boundary marker append
43
+ // via `dispatchCompact`. Keep the type record exhaustive so a future
44
+ // stub addition cannot silently overlap the wired set.
42
45
  memory: 'Session memory editor lands in α6.5b.',
43
46
  config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
44
47
  // alpha 6.13: /privacy graduated from stub; nothing reads this at
45
48
  // runtime but the type record stays exhaustive.
46
49
  privacy: '',
47
50
  budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
48
- mcp: 'Run `pugi config mcp list` from a fresh shell; in-REPL palette lands in α6.5.',
51
+ // β4 Sl7 (2026-05-26): /mcp graduated from stub to a real handler
52
+ // that forwards to `runMcpCommand`. Stub message removed from the
53
+ // exhaustive record so the type narrows correctly.
49
54
  undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
50
55
  });
51
56
  export const SLASH_COMMAND_HELP = Object.freeze([
52
57
  // Workforce dispatch
53
58
  { name: 'brief', args: '<text>', gloss: 'Dispatch a brief to the workforce', group: 'Workforce dispatch' },
54
59
  { name: 'agents', args: '', gloss: 'List the on-watch agent roster', group: 'Workforce dispatch' },
60
+ { name: 'delegate', args: '<slug> <brief>', gloss: 'Dispatch a brief to one Tier 1 specialist (α7.5)', group: 'Workforce dispatch' },
55
61
  { name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug', group: 'Workforce dispatch' },
56
62
  { name: 'jobs', args: '', gloss: 'List background jobs', group: 'Workforce dispatch' },
57
63
  { name: 'ask', args: '<question>', gloss: 'Surface a yes/no modal locally (α6.3 forcing question)', group: 'Workforce dispatch' },
@@ -59,23 +65,27 @@ export const SLASH_COMMAND_HELP = Object.freeze([
59
65
  { name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
60
66
  { name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
61
67
  { name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
62
- { name: 'compact', args: '', gloss: 'Manual context compaction (α6.5b)', group: 'Session', stub: true },
68
+ { name: 'compact', args: '', gloss: 'Summarise older turns into a boundary marker (leak L8)', group: 'Session' },
63
69
  { name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
70
+ { name: 'init', args: '', gloss: 'Scaffold .pugi/ in the current workspace (β1 Sl11)', group: 'Session' },
64
71
  // Pugi tools
65
72
  { name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
66
73
  { name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
67
- { name: 'cost', args: '', gloss: 'Token usage + budget', group: 'Pugi tools' },
68
- { name: 'status', args: '', gloss: 'Backend + tenant status', group: 'Pugi tools' },
74
+ { name: 'cost', args: '', gloss: 'Session token + USD totals + last 5 turn breakdown', group: 'Pugi tools' },
75
+ { name: 'quota', args: '', gloss: 'Plan tier + monthly usage caps (sync / review / engine)', group: 'Pugi tools' },
76
+ { name: 'status', args: '', gloss: 'Session snapshot — id · cwd · mode · tokens · dispatches · auth', group: 'Pugi tools' },
69
77
  { name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
70
78
  // Settings
71
79
  { name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
72
80
  { name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
81
+ { name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan / ask / allow / bypass)', group: 'Settings' },
73
82
  { name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
74
- { name: 'mcp', args: '', gloss: 'List MCP servers', group: 'Settings', stub: true },
83
+ { name: 'mcp', args: '[sub]', gloss: 'MCP servers — list / trust / deny / install / serve / perms', group: 'Settings' },
75
84
  { name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
76
85
  // Meta
77
86
  { name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
78
87
  { name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
88
+ { name: 'doctor', args: '', gloss: 'Environment health report (auth · API · Node · disk · MCP · …)', group: 'Meta' },
79
89
  { name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
80
90
  ]);
81
91
  /**
@@ -135,6 +145,38 @@ export function parseSlashCommand(input) {
135
145
  case 'roster': {
136
146
  return { kind: 'roster' };
137
147
  }
148
+ case 'delegate': {
149
+ // tail must start with the persona slug followed by the brief.
150
+ // Slug accepts only the closed-set lowercase ASCII pattern the
151
+ // server-side persona registry enforces; anything else surfaces
152
+ // as a usage error so the operator sees the typo before the
153
+ // round-trip.
154
+ const innerSpace = tail.indexOf(' ');
155
+ if (innerSpace === -1 || innerSpace === 0) {
156
+ return {
157
+ kind: 'error',
158
+ message: 'Usage: /delegate <slug> <one-sentence brief>',
159
+ };
160
+ }
161
+ const persona = tail.slice(0, innerSpace).toLowerCase();
162
+ const brief = tail.slice(innerSpace + 1).trim();
163
+ // Pattern intentionally mirrors server-side PUGI_DELEGATE_REGEX in
164
+ // sessions.controller.ts (^[a-z]+$). Keeping them lockstep means
165
+ // the REPL surfaces typos locally instead of round-tripping a 4xx.
166
+ if (!/^[a-z]+$/.test(persona)) {
167
+ return {
168
+ kind: 'error',
169
+ message: `/delegate slug must be lowercase ASCII (a-z only); got '${persona}'`,
170
+ };
171
+ }
172
+ if (brief.length === 0) {
173
+ return {
174
+ kind: 'error',
175
+ message: 'Usage: /delegate <slug> <one-sentence brief>',
176
+ };
177
+ }
178
+ return { kind: 'delegate', persona, brief };
179
+ }
138
180
  case 'stop':
139
181
  case 'kill': {
140
182
  if (tail.length === 0) {
@@ -181,9 +223,19 @@ export function parseSlashCommand(input) {
181
223
  case 'diff': {
182
224
  return { kind: 'diff' };
183
225
  }
184
- case 'cost': {
226
+ case 'cost':
227
+ case 'usage': {
228
+ // L19 (2026-05-27): `/usage` is an alias of `/cost` per the cost-
229
+ // command spec. The previous mapping routed `/usage` to the
230
+ // network-backed `/quota` surface, but operators trained on Claude
231
+ // Code expect `/usage` to surface the per-model token breakdown
232
+ // (same shape as `/cost`). `/quota` remains the canonical name
233
+ // for the tier + monthly-cap fetch.
185
234
  return { kind: 'cost' };
186
235
  }
236
+ case 'quota': {
237
+ return { kind: 'quota' };
238
+ }
187
239
  case 'status': {
188
240
  return { kind: 'status' };
189
241
  }
@@ -215,11 +267,83 @@ export function parseSlashCommand(input) {
215
267
  // device flow + audit identity are wired correctly).
216
268
  return { kind: 'privacy' };
217
269
  }
218
- case 'compact':
270
+ case 'permissions':
271
+ case 'perms': {
272
+ // Leak L6: `/permissions [mode] [--persist] [--confirm]`.
273
+ //
274
+ // Argument grammar (single line, no quoting):
275
+ // /permissions -> show current mode + table
276
+ // /permissions plan|ask|allow -> flip mode
277
+ // /permissions bypass --confirm -> flip to bypass (refused
278
+ // without --confirm — safety)
279
+ // /permissions <mode> --persist -> also write to ~/.pugi/config.json
280
+ //
281
+ // Anything else returns an `error` result so the runtime can
282
+ // render the usage hint inline.
283
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
284
+ if (tokens.length === 0) {
285
+ return { kind: 'permissions', persist: false, confirmBypass: false };
286
+ }
287
+ const head0 = tokens[0]?.toLowerCase();
288
+ if (head0 !== 'plan' && head0 !== 'ask' && head0 !== 'allow' && head0 !== 'bypass') {
289
+ return {
290
+ kind: 'error',
291
+ message: `Usage: /permissions [plan|ask|allow|bypass] [--persist] [--confirm]; unknown mode '${tokens[0] ?? ''}'`,
292
+ };
293
+ }
294
+ const flags = tokens.slice(1);
295
+ let persist = false;
296
+ let confirmBypass = false;
297
+ for (const flag of flags) {
298
+ if (flag === '--persist') {
299
+ persist = true;
300
+ }
301
+ else if (flag === '--confirm') {
302
+ confirmBypass = true;
303
+ }
304
+ else {
305
+ return {
306
+ kind: 'error',
307
+ message: `/permissions: unknown flag '${flag}' (allowed: --persist, --confirm)`,
308
+ };
309
+ }
310
+ }
311
+ return { kind: 'permissions', mode: head0, persist, confirmBypass };
312
+ }
313
+ case 'init': {
314
+ // β1 Sl11: surface the init flow inside the REPL. Tail args
315
+ // are ignored — the init handler is parameterless today; `pugi
316
+ // init --no-defaults` is the CLI surface for skipping bundled
317
+ // skills.
318
+ return { kind: 'init' };
319
+ }
320
+ case 'mcp': {
321
+ // β4 Sl7: tokenize the tail. Empty tail -> `list` (matches CLI).
322
+ // Quoting / shell-escapes are NOT supported — the slash surface is
323
+ // intentionally simple; complex installs (env vars, multi-word
324
+ // args) go through `pugi mcp install` from a fresh shell.
325
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
326
+ return { kind: 'mcp', args: tokens };
327
+ }
328
+ case 'doctor':
329
+ case 'health': {
330
+ // L17 (2026-05-27): run the probe sweep inline. Tail is ignored —
331
+ // the doctor command has no operator-facing arguments (every
332
+ // probe runs unconditionally; per-probe disable lives on the CLI
333
+ // shell surface, not the slash one).
334
+ return { kind: 'doctor' };
335
+ }
336
+ case 'compact': {
337
+ // Leak L8 (2026-05-27): graduated from stub. The session module
338
+ // owns the summariser round-trip; tail args are ignored today
339
+ // because the surface is parameterless. Operators wanting a
340
+ // per-session compact run `pugi compact --session <id>` from a
341
+ // fresh shell.
342
+ return { kind: 'compact' };
343
+ }
219
344
  case 'memory':
220
345
  case 'config':
221
346
  case 'budget':
222
- case 'mcp':
223
347
  case 'undo': {
224
348
  const stubName = name;
225
349
  return {
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Leak L31 — Per-command tool retry budget (Claude Code parity).
3
+ *
4
+ * Claude Code limits the number of times the model may retry the SAME
5
+ * tool with the SAME arguments inside a single operator-input cycle.
6
+ * Once the cap is hit, the dispatcher hard-refuses and surfaces a
7
+ * sentinel string telling the model that this exact call has exhausted
8
+ * its retry budget. The model is expected (via system-prompt rule) to
9
+ * either change approach or ask the operator for guidance instead of
10
+ * looping forever on a transient failure.
11
+ *
12
+ * Why per-cycle, not per-session: a retry budget that persists across
13
+ * operator turns would surprise the operator. After the operator says
14
+ * "try again" the model rightly retries; the budget must reset when a
15
+ * fresh brief arrives. The simplest reset boundary is the executor
16
+ * lifetime — `buildExecutor` is called once per `runEngineLoop` and
17
+ * the loop drives exactly one operator-input cycle. Constructing the
18
+ * budget inside `buildExecutor` therefore gives us per-cycle scoping
19
+ * "for free" via closure lifetime; no external clear() call is needed
20
+ * from production callsites. The exported `clear()` exists so tests
21
+ * and a future hook surface (PreToolUse) can introspect the state.
22
+ *
23
+ * Hash design: same tool + same canonical args = same bucket. We
24
+ * canonicalise the args record by sorting object keys (stable across
25
+ * model output ordering) and then sha256 the JSON. The model emits
26
+ * `arguments` as a raw JSON string; we parse, canonicalise, hash. If
27
+ * parse fails we hash the raw string verbatim — that way an
28
+ * unparseable repeat still counts toward the cap (otherwise the model
29
+ * could loop on syntactic noise variants forever).
30
+ *
31
+ * Env overrides:
32
+ * PUGI_RETRY_BUDGET_<TOOLNAME>=<N> — override a single tool's cap.
33
+ * Toolname matches DEFAULT_CAPS
34
+ * keys verbatim, uppercased
35
+ * (PUGI_RETRY_BUDGET_BASH=8).
36
+ * PUGI_RETRY_BUDGET_DEFAULT=<N> — override the fallback cap for
37
+ * any tool not in DEFAULT_CAPS.
38
+ * PUGI_RETRY_BUDGET_DISABLED=1 — warn-only mode. `shouldAllow`
39
+ * still records but always
40
+ * returns `allowed: true`. The
41
+ * count is preserved so
42
+ * diagnostics can still surface
43
+ * the pattern.
44
+ */
45
+ import { createHash } from 'node:crypto';
46
+ /**
47
+ * Default per-tool retry caps. Tuned per leak research:
48
+ *
49
+ * bash — 5 (most volatile; transient flakes common)
50
+ * edit — 3 (deterministic; repeat = real bug)
51
+ * write — 3 (same)
52
+ * read — 10 (cheap; legitimate re-reads after edits)
53
+ * search/grep/glob — 10 (cheap; exploration loop)
54
+ * web_fetch — 5 (transient network; not infinite)
55
+ * default — 5 (any tool not in the table)
56
+ *
57
+ * Operators override per-tool via `PUGI_RETRY_BUDGET_<NAME>` env vars.
58
+ * Caps are bounded `[1, 1000]` after override to defend against typo
59
+ * runaway (e.g. `PUGI_RETRY_BUDGET_BASH=5000000`).
60
+ */
61
+ export const DEFAULT_CAPS = Object.freeze({
62
+ bash: 5,
63
+ edit: 3,
64
+ write: 3,
65
+ read: 10,
66
+ search: 10,
67
+ grep: 10,
68
+ glob: 10,
69
+ web_fetch: 5,
70
+ default: 5,
71
+ });
72
+ /**
73
+ * Lower / upper bound for any resolved cap. Defends against:
74
+ * - PUGI_RETRY_BUDGET_BASH=0 -> first call instantly denied
75
+ * - PUGI_RETRY_BUDGET_BASH=99999 -> effectively unbounded loop
76
+ */
77
+ export const MIN_CAP = 1;
78
+ export const MAX_CAP = 1000;
79
+ /**
80
+ * Per-cycle retry budget. One instance per `buildExecutor` call.
81
+ *
82
+ * Not thread-safe: the executor is single-threaded by construction
83
+ * (Node event loop + sequential await in dispatcher). If a future
84
+ * executor parallelises tool dispatch it must serialise the budget
85
+ * mutation explicitly.
86
+ */
87
+ export class RetryBudget {
88
+ counts = new Map();
89
+ capCache = new Map();
90
+ env;
91
+ programmaticCaps;
92
+ constructor(options = {}) {
93
+ this.env = options.env ?? process.env;
94
+ this.programmaticCaps = options.caps ?? {};
95
+ }
96
+ /**
97
+ * Returns true when PUGI_RETRY_BUDGET_DISABLED=1. In disabled mode
98
+ * `shouldAllow` still records attempts but always allows the
99
+ * dispatch — useful for operators triaging a false-positive without
100
+ * a code change.
101
+ */
102
+ isDisabled() {
103
+ return this.env.PUGI_RETRY_BUDGET_DISABLED === '1';
104
+ }
105
+ /**
106
+ * Record one dispatch attempt. Idempotent on the bucket key (tool
107
+ * + argHash). Call this BEFORE the dispatch (or after `shouldAllow`
108
+ * but before `dispatch()` resolves) so a thrown dispatch counts.
109
+ */
110
+ recordAttempt(toolName, argHash) {
111
+ const key = `${toolName}::${argHash}`;
112
+ const next = (this.counts.get(key) ?? 0) + 1;
113
+ this.counts.set(key, next);
114
+ return next;
115
+ }
116
+ /**
117
+ * Returns the current count for (tool, argHash) WITHOUT mutating.
118
+ */
119
+ peek(toolName, argHash) {
120
+ return this.counts.get(`${toolName}::${argHash}`) ?? 0;
121
+ }
122
+ /**
123
+ * Resolve the effective cap for a tool.
124
+ *
125
+ * Precedence:
126
+ * 1. PUGI_RETRY_BUDGET_<TOOL_UPPER>=<N> (env)
127
+ * 2. programmaticCaps[toolName] (constructor)
128
+ * 3. DEFAULT_CAPS[toolName] (this module)
129
+ * 4. PUGI_RETRY_BUDGET_DEFAULT=<N> (env fallback)
130
+ * 5. DEFAULT_CAPS.default (final fallback)
131
+ *
132
+ * Bounded by [MIN_CAP, MAX_CAP] post-resolution. Invalid (NaN, ≤0,
133
+ * non-integer) env values are ignored and the next layer wins.
134
+ */
135
+ capFor(toolName) {
136
+ const cached = this.capCache.get(toolName);
137
+ if (cached !== undefined)
138
+ return cached;
139
+ const envKey = `PUGI_RETRY_BUDGET_${toolName.toUpperCase()}`;
140
+ const envCap = parseCapEnv(this.env[envKey]);
141
+ const programmaticCap = this.programmaticCaps[toolName];
142
+ const defaultCap = DEFAULT_CAPS[toolName];
143
+ const fallbackEnvCap = parseCapEnv(this.env.PUGI_RETRY_BUDGET_DEFAULT);
144
+ // DEFAULT_CAPS.default is hard-coded above; cast keeps the type-
145
+ // narrower happy without leaking `| undefined` through the index
146
+ // access (tsc cannot prove the literal key exists).
147
+ const finalFallback = DEFAULT_CAPS.default;
148
+ let resolved;
149
+ if (envCap !== undefined) {
150
+ resolved = envCap;
151
+ }
152
+ else if (programmaticCap !== undefined) {
153
+ resolved = programmaticCap;
154
+ }
155
+ else if (defaultCap !== undefined) {
156
+ resolved = defaultCap;
157
+ }
158
+ else {
159
+ resolved = fallbackEnvCap ?? finalFallback;
160
+ }
161
+ const bounded = Math.min(MAX_CAP, Math.max(MIN_CAP, resolved));
162
+ this.capCache.set(toolName, bounded);
163
+ return bounded;
164
+ }
165
+ /**
166
+ * Should this dispatch be allowed? Caller passes the current count
167
+ * BEFORE recording — i.e. shouldAllow returns true when count < cap,
168
+ * then recordAttempt fires, bringing count up to cap. The next
169
+ * identical call sees count === cap and is refused.
170
+ *
171
+ * In disabled mode `allowed` is forced to true; `count` and `cap`
172
+ * still reflect reality so logs / diagnostics can spot the pattern.
173
+ */
174
+ shouldAllow(toolName, argHash) {
175
+ const cap = this.capFor(toolName);
176
+ const count = this.peek(toolName, argHash);
177
+ const disabled = this.isDisabled();
178
+ const allowed = disabled ? true : count < cap;
179
+ return { allowed, count, cap, argHash, disabled };
180
+ }
181
+ /** Reset all state. Used between operator-input cycles when the
182
+ * budget instance is reused (most callers throw the instance away
183
+ * per cycle, so clear() is mostly for tests and hook surfaces). */
184
+ clear() {
185
+ this.counts.clear();
186
+ this.capCache.clear();
187
+ }
188
+ /**
189
+ * Snapshot the current state for diagnostics. Returns a plain
190
+ * object so it round-trips through JSON.stringify cleanly.
191
+ */
192
+ snapshot() {
193
+ const out = [];
194
+ for (const [key, count] of this.counts) {
195
+ const sep = key.indexOf('::');
196
+ if (sep < 0)
197
+ continue;
198
+ out.push({ tool: key.slice(0, sep), argHash: key.slice(sep + 2), count });
199
+ }
200
+ return out;
201
+ }
202
+ }
203
+ /**
204
+ * Hash the model's tool-call arguments into a stable key. Same
205
+ * canonical args = same hash regardless of JSON whitespace / key
206
+ * order. Unparseable JSON is hashed verbatim so the budget still
207
+ * catches syntactically degenerate retry loops.
208
+ */
209
+ export function hashArgs(argsRaw) {
210
+ const canonical = canonicalise(argsRaw);
211
+ return createHash('sha256').update(canonical).digest('hex');
212
+ }
213
+ /**
214
+ * Canonicalise a raw JSON arg string. Object keys are sorted
215
+ * recursively. Arrays preserve order (semantic). Primitives untouched.
216
+ * On parse failure, returns the original string prefixed with `raw:`
217
+ * so a malformed-args repeat still hashes to the same bucket.
218
+ */
219
+ function canonicalise(argsRaw) {
220
+ try {
221
+ const parsed = JSON.parse(argsRaw);
222
+ return JSON.stringify(sortKeys(parsed));
223
+ }
224
+ catch {
225
+ return `raw:${argsRaw}`;
226
+ }
227
+ }
228
+ function sortKeys(value) {
229
+ if (value === null || typeof value !== 'object')
230
+ return value;
231
+ if (Array.isArray(value))
232
+ return value.map(sortKeys);
233
+ const obj = value;
234
+ const sorted = {};
235
+ for (const k of Object.keys(obj).sort()) {
236
+ sorted[k] = sortKeys(obj[k]);
237
+ }
238
+ return sorted;
239
+ }
240
+ /**
241
+ * Parse and bound a `PUGI_RETRY_BUDGET_*` env var. Returns `undefined`
242
+ * for any non-positive-integer string so the resolver can fall
243
+ * through to the next precedence layer. Bounded by [MIN_CAP, MAX_CAP]
244
+ * is NOT applied here — `capFor` clamps after the final layer wins,
245
+ * matching the "operator typo defends against runaway" requirement
246
+ * without silently swallowing a meaningful low value (e.g.
247
+ * `PUGI_RETRY_BUDGET_BASH=1` should clamp to MIN_CAP=1, which it
248
+ * does naturally since 1 >= MIN_CAP).
249
+ */
250
+ function parseCapEnv(raw) {
251
+ if (raw === undefined || raw === '')
252
+ return undefined;
253
+ const n = Number(raw);
254
+ if (!Number.isInteger(n) || n <= 0)
255
+ return undefined;
256
+ return n;
257
+ }
258
+ /**
259
+ * Sentinel emitted to the model when the budget is exhausted. The
260
+ * format is stable so the engine adapter, spec layer, and operator
261
+ * dashboards can pattern-match on it.
262
+ */
263
+ export function retryBudgetExhaustedSentinel(toolName, cap) {
264
+ return `RETRY_BUDGET_EXHAUSTED: ${toolName} exceeded ${cap} attempts with these args. Operator must intervene.`;
265
+ }
266
+ /**
267
+ * Typed error thrown by the tool-bridge when the cap is hit. Carries
268
+ * the sentinel string so the engine loop can pattern-match without
269
+ * re-parsing. `instanceof RetryBudgetExhausted` is the canonical
270
+ * downstream test.
271
+ */
272
+ export class RetryBudgetExhausted extends Error {
273
+ toolName;
274
+ cap;
275
+ argHash;
276
+ constructor(toolName, cap, argHash) {
277
+ super(retryBudgetExhaustedSentinel(toolName, cap));
278
+ this.name = 'RetryBudgetExhausted';
279
+ this.toolName = toolName;
280
+ this.cap = cap;
281
+ this.argHash = argHash;
282
+ }
283
+ }
284
+ //# sourceMappingURL=budget.js.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Leak L31 — Tool retry budget. Public surface.
3
+ */
4
+ export { DEFAULT_CAPS, MIN_CAP, MAX_CAP, RetryBudget, RetryBudgetExhausted, hashArgs, retryBudgetExhaustedSentinel, } from './budget.js';
5
+ //# sourceMappingURL=index.js.map
@@ -28,6 +28,17 @@ const pugiSettingsSchema = z.object({
28
28
  telemetry: z.enum(['off', 'anonymous', 'community']).default('off'),
29
29
  })
30
30
  .default({}),
31
+ // beta.13 P1 fix 2026-05-26: ui.cyberZoo gates the cyber-zoo splash +
32
+ // ambient art in the REPL. Schema must declare the key explicitly
33
+ // because Zod's strip pass swallows unknown keys, which is how the
34
+ // initial `pugi init` write (which serialises `ui.cyberZoo`) was
35
+ // bypassed by the runtime reader — the value never made it past the
36
+ // schema gate so admin-api always saw the historical 'on' default.
37
+ ui: z
38
+ .object({
39
+ cyberZoo: z.enum(['on', 'off']).default('on'),
40
+ })
41
+ .default({}),
31
42
  artifacts: z
32
43
  .object({
33
44
  defaultPath: z.string().default('.pugi/artifacts'),
@@ -38,6 +49,12 @@ const pugiSettingsSchema = z.object({
38
49
  // fetcher. Default-off matches the spec posture; the schema must
39
50
  // declare it explicitly because Zod's strict-pass strips unknown
40
51
  // keys and would silently swallow the operator's intent.
52
+ //
53
+ // β1b T4 (2026-05-26): added `web.search.enabled` to gate the
54
+ // Brave-Search-backed `web_search` tool. Distinct from `web.fetch`
55
+ // because search queries themselves are an egress event that can
56
+ // leak operator intent — an operator may want fetch without
57
+ // implicitly enabling search-as-egress.
41
58
  web: z
42
59
  .object({
43
60
  fetch: z
@@ -45,6 +62,60 @@ const pugiSettingsSchema = z.object({
45
62
  enabled: z.boolean().optional(),
46
63
  })
47
64
  .optional(),
65
+ search: z
66
+ .object({
67
+ enabled: z.boolean().optional(),
68
+ })
69
+ .optional(),
70
+ })
71
+ .optional(),
72
+ // β7 L9 — per-language LSP toggle. When omitted, every supported
73
+ // server is available subject to binary detection on PATH. When
74
+ // present, only languages set to `true` are launched (false silently
75
+ // skips that language even if the binary is installed). Use this in
76
+ // workspaces where a heavyweight server (rust-analyzer indexing a
77
+ // monorepo, pyright on a fresh venv) wastes resources for the
78
+ // current task. The `pugi lsp servers` subcommand surfaces the
79
+ // current toggle state per server.
80
+ //
81
+ // Schema is intentionally permissive (`optional()` on the section AND
82
+ // on every per-language flag) so a partial config keeps the
83
+ // backwards-compatible "every language enabled" default.
84
+ lsp: z
85
+ .object({
86
+ typescript: z.boolean().optional(),
87
+ javascript: z.boolean().optional(),
88
+ python: z.boolean().optional(),
89
+ go: z.boolean().optional(),
90
+ rust: z.boolean().optional(),
91
+ })
92
+ .optional(),
93
+ // β1 Pl9 (#74) — per-command budget overrides. Optional. Partial
94
+ // overrides merge against the β1 defaults in
95
+ // `core/engine/budgets.ts::beta1DefaultBudgets`. The schema is
96
+ // intentionally loose at the leaf (positive integers) so a typo lands
97
+ // a deterministic `BudgetConfigError` at `resolveBudget()` instead of
98
+ // a Zod parse error two layers up.
99
+ budgets: z
100
+ .object({
101
+ code: z
102
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
103
+ .optional(),
104
+ fix: z
105
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
106
+ .optional(),
107
+ build: z
108
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
109
+ .optional(),
110
+ plan: z
111
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
112
+ .optional(),
113
+ explain: z
114
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
115
+ .optional(),
116
+ review_triple: z
117
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
118
+ .optional(),
48
119
  })
49
120
  .optional(),
50
121
  });