@pugi/cli 0.1.0-beta.4 → 0.1.0-beta.40

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 (249) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  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/commands/smoke.js +133 -0
  7. package/dist/core/agent-progress/cleanup.js +134 -0
  8. package/dist/core/agent-progress/schema.js +144 -0
  9. package/dist/core/agent-progress/writer.js +101 -0
  10. package/dist/core/artifact-chain/dispatcher.js +148 -0
  11. package/dist/core/artifact-chain/exporter.js +164 -0
  12. package/dist/core/artifact-chain/state.js +243 -0
  13. package/dist/core/artifact-chain/steps.js +169 -0
  14. package/dist/core/auth/ensure-authenticated.js +129 -0
  15. package/dist/core/auth/env-provider.js +238 -0
  16. package/dist/core/auto-update/channels.js +122 -0
  17. package/dist/core/auto-update/checker.js +241 -0
  18. package/dist/core/auto-update/state.js +235 -0
  19. package/dist/core/bare-mode/index.js +107 -0
  20. package/dist/core/bash-classifier.js +108 -1
  21. package/dist/core/checkpoint/resumer.js +149 -0
  22. package/dist/core/checkpoint/rewinder.js +291 -0
  23. package/dist/core/codegraph/decision-store.js +248 -0
  24. package/dist/core/codegraph/detect-repo.js +459 -0
  25. package/dist/core/codegraph/install.js +134 -0
  26. package/dist/core/codegraph/offer-hook.js +220 -0
  27. package/dist/core/compact/auto-trigger.js +96 -0
  28. package/dist/core/compact/buffer-rewriter.js +115 -0
  29. package/dist/core/compact/summarizer.js +208 -0
  30. package/dist/core/compact/token-counter.js +108 -0
  31. package/dist/core/consensus/diff-capture.js +73 -0
  32. package/dist/core/context/index.js +7 -0
  33. package/dist/core/context/markdown-traverse.js +255 -0
  34. package/dist/core/cost/rate-card.js +129 -0
  35. package/dist/core/cost/tracker.js +221 -0
  36. package/dist/core/denial-tracking/index.js +8 -0
  37. package/dist/core/denial-tracking/state.js +264 -0
  38. package/dist/core/diagnostics/probe-runner.js +93 -0
  39. package/dist/core/diagnostics/probes/api.js +46 -0
  40. package/dist/core/diagnostics/probes/auth.js +86 -0
  41. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  42. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  43. package/dist/core/diagnostics/probes/config.js +72 -0
  44. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  45. package/dist/core/diagnostics/probes/disk.js +81 -0
  46. package/dist/core/diagnostics/probes/git.js +65 -0
  47. package/dist/core/diagnostics/probes/mcp.js +75 -0
  48. package/dist/core/diagnostics/probes/node.js +59 -0
  49. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  50. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  51. package/dist/core/diagnostics/probes/session.js +74 -0
  52. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  53. package/dist/core/diagnostics/probes/workspace.js +63 -0
  54. package/dist/core/diagnostics/types.js +70 -0
  55. package/dist/core/dispatch/cache-cleanup.js +197 -0
  56. package/dist/core/dispatch/cache-handoff.js +295 -0
  57. package/dist/core/edits/dispatch.js +218 -2
  58. package/dist/core/edits/journal.js +199 -0
  59. package/dist/core/edits/layer-d-ast.js +557 -14
  60. package/dist/core/edits/verify-hook.js +273 -0
  61. package/dist/core/edits/worktree.js +322 -0
  62. package/dist/core/engine/anvil-client.js +115 -5
  63. package/dist/core/engine/budgets.js +98 -0
  64. package/dist/core/engine/context-prefix.js +155 -0
  65. package/dist/core/engine/intent.js +260 -0
  66. package/dist/core/engine/native-pugi.js +860 -211
  67. package/dist/core/engine/prompts.js +88 -2
  68. package/dist/core/engine/strip-internal-fields.js +124 -0
  69. package/dist/core/engine/tool-bridge.js +992 -36
  70. package/dist/core/feedback/queue.js +177 -0
  71. package/dist/core/feedback/submitter.js +145 -0
  72. package/dist/core/file-cache.js +113 -1
  73. package/dist/core/hooks/events.js +44 -0
  74. package/dist/core/hooks/index.js +15 -0
  75. package/dist/core/hooks/registry.js +213 -0
  76. package/dist/core/hooks/runner.js +236 -0
  77. package/dist/core/hooks/v2/event-emitter.js +115 -0
  78. package/dist/core/hooks/v2/executor.js +282 -0
  79. package/dist/core/hooks/v2/index.js +25 -0
  80. package/dist/core/hooks/v2/lifecycle.js +104 -0
  81. package/dist/core/hooks/v2/loader.js +216 -0
  82. package/dist/core/hooks/v2/matcher.js +125 -0
  83. package/dist/core/hooks/v2/trust.js +143 -0
  84. package/dist/core/hooks/v2/types.js +86 -0
  85. package/dist/core/lsp/cache.js +105 -0
  86. package/dist/core/lsp/client.js +776 -0
  87. package/dist/core/lsp/language-detect.js +66 -0
  88. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  89. package/dist/core/mcp/client.js +75 -6
  90. package/dist/core/mcp/http-server.js +553 -0
  91. package/dist/core/mcp/orchestrator-tools.js +662 -0
  92. package/dist/core/mcp/permission.js +190 -0
  93. package/dist/core/mcp/registry.js +24 -2
  94. package/dist/core/mcp/server-tools.js +219 -0
  95. package/dist/core/mcp/server.js +397 -0
  96. package/dist/core/memory/dual-write.js +416 -0
  97. package/dist/core/memory/phase1-kinds.js +20 -0
  98. package/dist/core/memory-sync/queue.js +158 -0
  99. package/dist/core/onboarding/ensure-initialized.js +133 -0
  100. package/dist/core/onboarding/marker.js +111 -0
  101. package/dist/core/onboarding/telemetry-state.js +108 -0
  102. package/dist/core/output-style/presets.js +176 -0
  103. package/dist/core/output-style/state.js +185 -0
  104. package/dist/core/permissions/auto-classifier.js +124 -0
  105. package/dist/core/permissions/circuit-breaker.js +83 -0
  106. package/dist/core/permissions/gate.js +278 -0
  107. package/dist/core/permissions/index.js +20 -0
  108. package/dist/core/permissions/mode.js +174 -0
  109. package/dist/core/permissions/state.js +241 -0
  110. package/dist/core/permissions/tool-class.js +93 -0
  111. package/dist/core/prd-check/parser.js +215 -0
  112. package/dist/core/prd-check/reporter.js +127 -0
  113. package/dist/core/prd-check/session-review.js +557 -0
  114. package/dist/core/prd-check/verifiers.js +223 -0
  115. package/dist/core/pugi-md/context-injector.js +76 -0
  116. package/dist/core/pugi-md/walk-up.js +207 -0
  117. package/dist/core/release-notes/parser.js +241 -0
  118. package/dist/core/release-notes/state.js +116 -0
  119. package/dist/core/repl/history.js +11 -1
  120. package/dist/core/repl/model-pricing.js +135 -0
  121. package/dist/core/repl/session.js +1899 -38
  122. package/dist/core/repl/slash-commands.js +406 -21
  123. package/dist/core/repl/store/session-store.js +31 -2
  124. package/dist/core/repl/workspace-context.js +22 -0
  125. package/dist/core/repo-map/build.js +125 -0
  126. package/dist/core/repo-map/cache.js +185 -0
  127. package/dist/core/repo-map/extractor.js +254 -0
  128. package/dist/core/repo-map/formatter.js +145 -0
  129. package/dist/core/repo-map/scanner.js +211 -0
  130. package/dist/core/retry-budget/budget.js +284 -0
  131. package/dist/core/retry-budget/index.js +5 -0
  132. package/dist/core/session.js +92 -0
  133. package/dist/core/settings.js +80 -0
  134. package/dist/core/share/formatter.js +271 -0
  135. package/dist/core/share/redactor.js +221 -0
  136. package/dist/core/share/uploader.js +267 -0
  137. package/dist/core/skills/defaults.js +457 -0
  138. package/dist/core/smoke/headless-driver.js +174 -0
  139. package/dist/core/smoke/orchestrator.js +194 -0
  140. package/dist/core/smoke/runner.js +238 -0
  141. package/dist/core/smoke/scenario-parser.js +316 -0
  142. package/dist/core/subagents/dispatcher-real.js +600 -0
  143. package/dist/core/subagents/dispatcher.js +113 -24
  144. package/dist/core/subagents/index.js +18 -5
  145. package/dist/core/subagents/isolation-matrix.js +213 -0
  146. package/dist/core/subagents/spawn.js +19 -4
  147. package/dist/core/telemetry/emitter.js +229 -0
  148. package/dist/core/telemetry/queue.js +251 -0
  149. package/dist/core/theme/context.js +91 -0
  150. package/dist/core/theme/presets.js +228 -0
  151. package/dist/core/theme/state.js +181 -0
  152. package/dist/core/todos/invariant.js +10 -0
  153. package/dist/core/todos/state.js +177 -0
  154. package/dist/core/transport/version-interceptor.js +166 -0
  155. package/dist/core/vim/keymap.js +288 -0
  156. package/dist/core/vim/state.js +92 -0
  157. package/dist/index.js +28 -0
  158. package/dist/runtime/bootstrap.js +190 -0
  159. package/dist/runtime/cli.js +3073 -321
  160. package/dist/runtime/commands/cancel.js +231 -0
  161. package/dist/runtime/commands/chain.js +489 -0
  162. package/dist/runtime/commands/codegraph-status.js +227 -0
  163. package/dist/runtime/commands/compact.js +297 -0
  164. package/dist/runtime/commands/cost.js +199 -0
  165. package/dist/runtime/commands/delegate.js +242 -11
  166. package/dist/runtime/commands/dispatch.js +126 -0
  167. package/dist/runtime/commands/doctor.js +390 -0
  168. package/dist/runtime/commands/feedback.js +184 -0
  169. package/dist/runtime/commands/hooks.js +184 -0
  170. package/dist/runtime/commands/lsp.js +368 -0
  171. package/dist/runtime/commands/mcp.js +879 -0
  172. package/dist/runtime/commands/memory.js +508 -0
  173. package/dist/runtime/commands/model.js +237 -0
  174. package/dist/runtime/commands/onboarding.js +275 -0
  175. package/dist/runtime/commands/patch.js +128 -0
  176. package/dist/runtime/commands/permissions.js +112 -0
  177. package/dist/runtime/commands/plan.js +143 -0
  178. package/dist/runtime/commands/prd-check.js +285 -0
  179. package/dist/runtime/commands/redo-blob-store.js +92 -0
  180. package/dist/runtime/commands/redo.js +361 -0
  181. package/dist/runtime/commands/release-notes.js +229 -0
  182. package/dist/runtime/commands/repo-map.js +95 -0
  183. package/dist/runtime/commands/report.js +299 -0
  184. package/dist/runtime/commands/resume.js +118 -0
  185. package/dist/runtime/commands/review-consensus.js +17 -2
  186. package/dist/runtime/commands/rewind.js +333 -0
  187. package/dist/runtime/commands/sessions.js +163 -0
  188. package/dist/runtime/commands/share.js +316 -0
  189. package/dist/runtime/commands/status.js +186 -0
  190. package/dist/runtime/commands/stickers.js +82 -0
  191. package/dist/runtime/commands/style.js +194 -0
  192. package/dist/runtime/commands/theme.js +196 -0
  193. package/dist/runtime/commands/undo.js +32 -0
  194. package/dist/runtime/commands/update.js +289 -0
  195. package/dist/runtime/commands/vim.js +140 -0
  196. package/dist/runtime/commands/worktree.js +177 -0
  197. package/dist/runtime/headless-repl.js +195 -0
  198. package/dist/runtime/headless.js +543 -0
  199. package/dist/runtime/load-hooks-or-exit.js +71 -0
  200. package/dist/runtime/plan-decompose.js +531 -0
  201. package/dist/runtime/version.js +65 -0
  202. package/dist/tools/agent-tool.js +229 -0
  203. package/dist/tools/apply-patch.js +556 -0
  204. package/dist/tools/ask-user-question.js +213 -0
  205. package/dist/tools/ask-user.js +115 -0
  206. package/dist/tools/file-tools.js +85 -14
  207. package/dist/tools/lsp-tools.js +189 -0
  208. package/dist/tools/mcp-tool.js +260 -0
  209. package/dist/tools/multi-edit.js +361 -0
  210. package/dist/tools/registry.js +46 -0
  211. package/dist/tools/skill-tool.js +96 -0
  212. package/dist/tools/tasks.js +208 -0
  213. package/dist/tools/todo-write.js +184 -0
  214. package/dist/tools/web-fetch.js +147 -2
  215. package/dist/tools/web-search.js +458 -0
  216. package/dist/tui/agent-progress-card.js +111 -0
  217. package/dist/tui/agent-tree.js +10 -0
  218. package/dist/tui/ask-modal.js +2 -2
  219. package/dist/tui/ask-user-question-prompt.js +192 -0
  220. package/dist/tui/compact-banner.js +81 -0
  221. package/dist/tui/conversation-pane.js +82 -8
  222. package/dist/tui/cost-table.js +111 -0
  223. package/dist/tui/doctor-table.js +46 -0
  224. package/dist/tui/feedback-prompt.js +156 -0
  225. package/dist/tui/input-box.js +69 -2
  226. package/dist/tui/markdown-render.js +4 -4
  227. package/dist/tui/onboarding-wizard.js +240 -0
  228. package/dist/tui/permissions-picker.js +86 -0
  229. package/dist/tui/render.js +35 -0
  230. package/dist/tui/repl-render.js +303 -13
  231. package/dist/tui/repl-splash.js +2 -2
  232. package/dist/tui/repl.js +72 -14
  233. package/dist/tui/splash.js +1 -1
  234. package/dist/tui/status-bar.js +94 -16
  235. package/dist/tui/status-table.js +7 -0
  236. package/dist/tui/stickers-art.js +136 -0
  237. package/dist/tui/style-table.js +28 -0
  238. package/dist/tui/theme-table.js +29 -0
  239. package/dist/tui/tool-stream-pane.js +52 -3
  240. package/dist/tui/update-banner.js +20 -2
  241. package/dist/tui/vim-input.js +267 -0
  242. package/docs/examples/codegraph.mcp.json +10 -0
  243. package/package.json +12 -6
  244. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  245. package/test/scenarios/compact-force.scenario.txt +11 -0
  246. package/test/scenarios/identity.scenario.txt +11 -0
  247. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  248. package/test/scenarios/walkback.scenario.txt +12 -0
  249. package/dist/core/engine/compaction-hook.js +0 -154
@@ -1,3 +1,10 @@
1
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). The interceptor stamps the
2
+ // outbound X-Pugi-Cli-Version header, inspects the inbound recommended/
3
+ // server-version headers, and throws PugiCliUpgradeRequiredError on a
4
+ // 426 server response. The top-level catch in `runtime/cli.ts` /
5
+ // `index.ts` renders the upgrade message and exits 1.
6
+ import { assertNotUpgradeRequired, injectClientVersionHeader, inspectVersionResponse, } from '../transport/version-interceptor.js';
7
+ import { PUGI_CLI_VERSION } from '../../runtime/version.js';
1
8
  /**
2
9
  * Anvil-backed engine loop client.
3
10
  *
@@ -54,23 +61,77 @@ export class AnvilEngineLoopClient {
54
61
  options.signal.addEventListener('abort', onAbort);
55
62
  const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
56
63
  try {
64
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). Stamp the outbound
65
+ // X-Pugi-Cli-Version header so the admin-api middleware can
66
+ // decide whether to honour, soft-warn, or 426 this request.
67
+ const outboundHeaders = injectClientVersionHeader({
68
+ 'content-type': 'application/json',
69
+ authorization: `Bearer ${this.config.apiKey}`,
70
+ 'user-agent': 'pugi-cli/0.0.1',
71
+ }, PUGI_CLI_VERSION);
57
72
  const res = await fetch(url, {
58
73
  method: 'POST',
59
- headers: {
60
- 'content-type': 'application/json',
61
- authorization: `Bearer ${this.config.apiKey}`,
62
- 'user-agent': 'pugi-cli/0.0.1',
63
- },
74
+ headers: outboundHeaders,
64
75
  body: JSON.stringify({
65
76
  personaSlug: options.personaSlug,
66
77
  messages,
67
78
  tools,
68
79
  maxTokens: options.maxTokens,
69
80
  temperature: options.temperature,
81
+ // β1 (audit E2): the admin-api `EngineRequestDto` accepts
82
+ // these optional fields (see `pugi-engine.controller.ts:230`
83
+ // EngineRequestDto schema). Before this fix the CLI dropped
84
+ // them, which forced the controller to fall back to legacy
85
+ // per-persona resolution + emit `command="(none)"` in its
86
+ // structured logs. `undefined` keys are stripped by
87
+ // `JSON.stringify` so the payload stays clean for fixture
88
+ // clients that exact-match the body shape.
89
+ command: options.command,
90
+ // β1a r1: `tag` is `EngineDispatchTag` object shape now —
91
+ // `JSON.stringify` serialises it as `{tag, priority?,
92
+ // budget_hint?}` matching `EngineDispatchTagDto`. Previously
93
+ // this was a bare string and the server's `IsIn` validator
94
+ // rejected every payload with HTTP 400.
95
+ tag: options.tag,
96
+ model: options.model,
70
97
  }),
71
98
  signal: controller.signal,
72
99
  });
73
100
  const text = await res.text();
101
+ // PR-CLI-SERVER-VERSION-HANDSHAKE: cache server-recommended +
102
+ // server-version headers so UpdateBanner / `pugi doctor` can
103
+ // surface them, then short-circuit on 426 by throwing
104
+ // PugiCliUpgradeRequiredError. The throw bubbles to the
105
+ // top-level catch in index.ts which renders the upgrade banner.
106
+ // The getter shim handles both real `Response` (`.headers.get`)
107
+ // and minimal fixture/stub responses (`.headers?.[name]`) so
108
+ // existing transport tests that mock `fetch` with `{status, text}`
109
+ // don't need to grow a Headers polyfill just to keep passing.
110
+ //
111
+ // Cache poison guard: skip the inspect step on 426. A hostile
112
+ // upstream (proxy with a compromised cert pin, or a transient MITM
113
+ // on a coffee-shop network) could otherwise forge an
114
+ // `X-Pugi-Cli-Upgrade-Recommended` header alongside a 426 status
115
+ // and poison `cachedServerRecommendation` for the rest of the REPL
116
+ // session — `UpdateBanner` would then surface attacker-chosen
117
+ // version strings to the operator. The 426 body still carries the
118
+ // legitimate `recommendedVersion` field, which assertNotUpgrade-
119
+ // Required parses + throws with, so the operator-facing banner
120
+ // remains accurate via the error path.
121
+ if (res.status !== 426) {
122
+ inspectVersionResponse((name) => {
123
+ const h = res.headers;
124
+ if (h && typeof h.get === 'function') {
125
+ return h.get(name);
126
+ }
127
+ if (h && typeof h === 'object') {
128
+ const lowered = h[name.toLowerCase()];
129
+ return lowered ?? null;
130
+ }
131
+ return null;
132
+ });
133
+ }
134
+ assertNotUpgradeRequired(res.status, text, PUGI_CLI_VERSION);
74
135
  if (res.status === 200) {
75
136
  try {
76
137
  const json = JSON.parse(text);
@@ -119,6 +180,55 @@ export class AnvilEngineLoopClient {
119
180
  };
120
181
  }
121
182
  if (res.status === 401 || res.status === 403) {
183
+ // 403 has two distinct causes:
184
+ // 1. genuinely invalid / expired token (auth_missing) — the
185
+ // old default.
186
+ // 2. tenant authenticated successfully but the privacy mode
187
+ // (strict / balanced policy) refused upstream LLM dispatch.
188
+ // The admin-api returns
189
+ // `{ code: 'privacy_strict_upstream_blocked', mode, model,
190
+ // message: '...switch via pugi config set privacy=...' }`.
191
+ // Reported as `auth_missing` the user runs `pugi login`
192
+ // again, which does nothing — the actual fix is a privacy-
193
+ // mode change. Parse the body and route accordingly.
194
+ // (2026-05-27 P0.3 — dogfood surfaced this on /api/pugi/engine
195
+ // for a strict-mode tenant; see memory feedback_no_fake_dispatch_promises
196
+ // for the broader "misleading error" pattern.)
197
+ try {
198
+ const parsed = text ? JSON.parse(text) : null;
199
+ // 2026-05-27 dogfood cycle 2: distinct error code for the
200
+ // infra-side "PII scrubber down" case. Previously the engine
201
+ // server returned `privacy_strict_upstream_blocked` here even
202
+ // when the tenant was on BALANCED (the scrubber crash forced
203
+ // a fail-closed). Operators chased the wrong fix ("switch
204
+ // privacy") for hours. Server now emits
205
+ // `pii_scrubber_unavailable` — surface a distinct remediation
206
+ // that points at the infra side, not the operator's privacy
207
+ // posture.
208
+ if (parsed?.code === 'pii_scrubber_unavailable') {
209
+ return {
210
+ stop: 'error',
211
+ code: 'privacy_blocked',
212
+ message: parsed.message ?? 'PII scrubber unavailable; privacy filter refused dispatch.',
213
+ remediation: 'Infra-side issue (not your tenant privacy mode). Wait for ops to restore ' +
214
+ 'the PiiScrubberService, OR temporarily switch your tenant to permissive via ' +
215
+ '`pugi config set privacy=permissive`.',
216
+ };
217
+ }
218
+ if (parsed?.code === 'privacy_strict_upstream_blocked' || parsed?.code === 'privacy_blocked') {
219
+ return {
220
+ stop: 'error',
221
+ code: 'privacy_blocked',
222
+ message: parsed.message ?? 'Tenant privacy mode forbids upstream LLM dispatch.',
223
+ remediation: 'pugi config set privacy=balanced — OR configure a self-hosted Anvil model.',
224
+ };
225
+ }
226
+ }
227
+ catch {
228
+ // Body not JSON — fall through to the generic auth_missing
229
+ // branch below; the 200-char text echo on `failed` will at
230
+ // least give the operator the raw response to triage.
231
+ }
122
232
  return {
123
233
  stop: 'error',
124
234
  code: 'auth_missing',
@@ -0,0 +1,98 @@
1
+ /**
2
+ * β1 defaults. Source of truth for the per-command budget envelope.
3
+ * The runtime is allowed to look these up directly (no need to round
4
+ * trip through settings.json when no override is in play).
5
+ *
6
+ * 2026-05-28 bump (post-Wave-7 hooks-v2 + 6-perm-modes + auto-classifier
7
+ * added ~12K tokens of system-prompt + tools-schema overhead per turn):
8
+ * `code` 30k → 80k и `fix` 30k → 50k so a single-file refactor on a
9
+ * 1000-line source file no longer exhausts the budget on turn 2.
10
+ * Empirical: smoke `pugi code "сделай snake.html"` on beta.37 burned
11
+ * 36k/30k after 2 tool calls; beta.36 same task closed in 8k. The Wave-7
12
+ * additions are good (Claude Code parity), but the budget cap did not move with
13
+ * them. Claude Code's `code` default is ~80k; matching that restores headroom.
14
+ */
15
+ export const beta1DefaultBudgets = {
16
+ fix: { maxTokens: 50_000, maxToolCalls: 20 },
17
+ code: { maxTokens: 80_000, maxToolCalls: 20 },
18
+ build: { maxTokens: 200_000, maxToolCalls: 30 },
19
+ plan: { maxTokens: 200_000, maxToolCalls: 8 },
20
+ explain: { maxTokens: 20_000, maxToolCalls: 5 },
21
+ review_triple: { maxTokens: 100_000, maxToolCalls: 10 },
22
+ };
23
+ /**
24
+ * Hard upper bounds. Anything above this is treated as user error
25
+ * (likely a typo or misplaced decimal) and rejected by
26
+ * `assertBudgetWithinTier`. Stops a careless settings.json edit from
27
+ * silently authorising a 100M-token run.
28
+ */
29
+ export const HARD_MAX_TOKENS = 5_000_000;
30
+ export const HARD_MAX_TOOL_CALLS = 500;
31
+ /**
32
+ * Compute the effective budget for a given command, applying:
33
+ * 1. β1 defaults
34
+ * 2. settings.json `budgets.<command>` partial overrides
35
+ * 3. task-level override (caller-provided, e.g. CLI `--max-tokens`)
36
+ *
37
+ * Throws `BudgetConfigError` when the resolved budget exceeds the
38
+ * HARD_MAX_* caps so misconfigured settings.json fails fast.
39
+ */
40
+ export function resolveBudget(command, settings, override) {
41
+ const base = beta1DefaultBudgets[command];
42
+ const settingsBudget = readSettingsBudget(settings, command);
43
+ const resolved = {
44
+ maxTokens: override?.maxTokens ??
45
+ settingsBudget?.maxTokens ??
46
+ base.maxTokens,
47
+ maxToolCalls: override?.maxToolCalls ??
48
+ settingsBudget?.maxToolCalls ??
49
+ base.maxToolCalls,
50
+ };
51
+ assertBudgetWithinTier(command, resolved);
52
+ return resolved;
53
+ }
54
+ export class BudgetConfigError extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = 'BudgetConfigError';
58
+ }
59
+ }
60
+ export function assertBudgetWithinTier(command, budget) {
61
+ if (!Number.isFinite(budget.maxTokens) || budget.maxTokens <= 0) {
62
+ throw new BudgetConfigError(`budget[${command}].maxTokens must be a positive number, got ${budget.maxTokens}`);
63
+ }
64
+ if (!Number.isFinite(budget.maxToolCalls) || budget.maxToolCalls <= 0) {
65
+ throw new BudgetConfigError(`budget[${command}].maxToolCalls must be a positive number, got ${budget.maxToolCalls}`);
66
+ }
67
+ if (budget.maxTokens > HARD_MAX_TOKENS) {
68
+ throw new BudgetConfigError(`budget[${command}].maxTokens=${budget.maxTokens} exceeds hard cap ${HARD_MAX_TOKENS}`);
69
+ }
70
+ if (budget.maxToolCalls > HARD_MAX_TOOL_CALLS) {
71
+ throw new BudgetConfigError(`budget[${command}].maxToolCalls=${budget.maxToolCalls} exceeds hard cap ${HARD_MAX_TOOL_CALLS}`);
72
+ }
73
+ }
74
+ /**
75
+ * Pull a settings.json budget override for the given command, with
76
+ * defensive typing. `PugiSettings` does not yet declare `budgets`
77
+ * formally (β1 is the first sprint to land it) so we cast via unknown
78
+ * and validate each field at the boundary.
79
+ */
80
+ function readSettingsBudget(settings, command) {
81
+ if (!settings)
82
+ return undefined;
83
+ const root = settings.budgets;
84
+ if (!root || typeof root !== 'object' || Array.isArray(root))
85
+ return undefined;
86
+ const map = root;
87
+ const entry = map[command];
88
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
89
+ return undefined;
90
+ const e = entry;
91
+ const out = {};
92
+ if (typeof e['maxTokens'] === 'number')
93
+ out.maxTokens = e['maxTokens'];
94
+ if (typeof e['maxToolCalls'] === 'number')
95
+ out.maxToolCalls = e['maxToolCalls'];
96
+ return out;
97
+ }
98
+ //# sourceMappingURL=budgets.js.map
@@ -0,0 +1,155 @@
1
+ /** Hard cap on the rendered `<context>` block, bytes. */
2
+ export const CONTEXT_PREFIX_MAX_BYTES = 5 * 1024;
3
+ /** Hard cap on working-set entries surfaced in the prefix. */
4
+ export const CONTEXT_PREFIX_MAX_WORKING_SET = 50;
5
+ /** Hard cap on per-dir markdown files surfaced inline. */
6
+ export const CONTEXT_PREFIX_MAX_MARKDOWN = 3;
7
+ /** Per-markdown-file inline excerpt cap, bytes. Keeps any one file from dominating. */
8
+ export const CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES = 1024;
9
+ /**
10
+ * Build the `<context>` block. Always returns a result — when there
11
+ * is nothing to surface (no cwd hint, no working set, no per-dir
12
+ * files), returns `{ block: '', bytes: 0, truncated: false, counts: ... }`
13
+ * so the caller can short-circuit the splice cleanly.
14
+ *
15
+ * Determinism: same input always produces byte-identical output. The
16
+ * working-set order comes from the caller's summary; we preserve it
17
+ * verbatim. Per-dir files are sorted by `distanceFromCwd` ascending
18
+ * (closest first) so two equal `TraversedMarkdown` arrays produce
19
+ * identical blocks.
20
+ */
21
+ export function buildContextPrefix(input) {
22
+ const lines = [];
23
+ let bytes = 0;
24
+ let truncated = false;
25
+ // Walk a write-then-measure loop so we never overflow the byte cap.
26
+ // Each push checks budget; when full, set `truncated = true` and
27
+ // stop. The opener / closer tags always fit (small) — we reserve
28
+ // their byte cost up-front from the budget.
29
+ const opener = '<context>';
30
+ const closer = '</context>';
31
+ const reservedTagBytes = Buffer.byteLength(opener, 'utf8') + 1 + Buffer.byteLength(closer, 'utf8') + 1;
32
+ let budget = CONTEXT_PREFIX_MAX_BYTES - reservedTagBytes;
33
+ if (budget < 0) {
34
+ return {
35
+ block: '',
36
+ bytes: 0,
37
+ truncated: false,
38
+ counts: {
39
+ workingSetIncluded: 0,
40
+ workingSetTotal: input.workingSet?.length ?? 0,
41
+ markdownIncluded: 0,
42
+ markdownTotal: input.traversedMarkdown?.length ?? 0,
43
+ },
44
+ };
45
+ }
46
+ const pushLine = (line) => {
47
+ const lineBytes = Buffer.byteLength(line, 'utf8') + 1; // newline
48
+ if (lineBytes > budget) {
49
+ truncated = true;
50
+ return false;
51
+ }
52
+ lines.push(line);
53
+ budget -= lineBytes;
54
+ bytes += lineBytes;
55
+ return true;
56
+ };
57
+ // cwd — always cheap, always first.
58
+ pushLine(`cwd: ${input.cwdRelative || '.'}`);
59
+ // intent hint, if provided.
60
+ if (input.intentHint) {
61
+ pushLine(`intent: ${input.intentHint}`);
62
+ }
63
+ // Working set — render `open-files:` only when there is at least one entry.
64
+ const wsTotal = input.workingSet?.length ?? 0;
65
+ let wsIncluded = 0;
66
+ if (wsTotal > 0 && input.workingSet) {
67
+ const wsCap = Math.min(CONTEXT_PREFIX_MAX_WORKING_SET, wsTotal);
68
+ pushLine('open-files:');
69
+ for (let i = 0; i < wsCap; i += 1) {
70
+ const entry = input.workingSet[i];
71
+ if (!entry)
72
+ continue;
73
+ const ok = pushLine(` - ${entry.absPath}`);
74
+ if (!ok)
75
+ break;
76
+ wsIncluded += 1;
77
+ }
78
+ if (wsTotal > wsCap) {
79
+ pushLine(` ... (+${wsTotal - wsCap} more)`);
80
+ truncated = truncated || true;
81
+ }
82
+ }
83
+ // Per-dir markdown — closest-first, max 3, each capped to 1 KB excerpt.
84
+ const mdTotal = input.traversedMarkdown?.length ?? 0;
85
+ let mdIncluded = 0;
86
+ if (mdTotal > 0 && input.traversedMarkdown) {
87
+ const sorted = [...input.traversedMarkdown].sort((a, b) => a.distanceFromCwd - b.distanceFromCwd);
88
+ const top = sorted.slice(0, CONTEXT_PREFIX_MAX_MARKDOWN);
89
+ if (top.length > 0) {
90
+ pushLine('per-dir-conventions:');
91
+ for (const md of top) {
92
+ // Header line names the source file for traceability.
93
+ const header = ` [${md.resolvedPath}]`;
94
+ if (!pushLine(header))
95
+ break;
96
+ // Excerpt: cap to 1 KB, single-line collapse (replace newlines
97
+ // with " | " so the YAML-ish block stays parseable by humans).
98
+ const excerpt = excerpt1KB(md.content);
99
+ // Indent two spaces so it nests under the file header.
100
+ const indented = excerpt.split('\n').map((l) => ` ${l}`).join('\n');
101
+ if (!pushLine(indented))
102
+ break;
103
+ mdIncluded += 1;
104
+ }
105
+ if (mdTotal > top.length) {
106
+ pushLine(` ... (+${mdTotal - top.length} more files; closest-3 shown)`);
107
+ truncated = truncated || true;
108
+ }
109
+ }
110
+ }
111
+ if (lines.length === 0) {
112
+ return {
113
+ block: '',
114
+ bytes: 0,
115
+ truncated: false,
116
+ counts: {
117
+ workingSetIncluded: 0,
118
+ workingSetTotal: wsTotal,
119
+ markdownIncluded: 0,
120
+ markdownTotal: mdTotal,
121
+ },
122
+ };
123
+ }
124
+ const block = [opener, ...lines, closer].join('\n');
125
+ const totalBytes = Buffer.byteLength(block, 'utf8');
126
+ return {
127
+ block,
128
+ bytes: totalBytes,
129
+ truncated,
130
+ counts: {
131
+ workingSetIncluded: wsIncluded,
132
+ workingSetTotal: wsTotal,
133
+ markdownIncluded: mdIncluded,
134
+ markdownTotal: mdTotal,
135
+ },
136
+ };
137
+ }
138
+ /**
139
+ * Splice a built context block onto the front of a user message.
140
+ * Empty block → message returned verbatim (no leading blank line, no
141
+ * empty `<context>` tag).
142
+ */
143
+ export function spliceContextPrefix(block, userMessage) {
144
+ if (block.length === 0)
145
+ return userMessage;
146
+ return `${block}\n\n${userMessage}`;
147
+ }
148
+ function excerpt1KB(content) {
149
+ const capped = content.length <= CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES
150
+ ? content
151
+ : content.slice(0, CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES);
152
+ // Trim trailing newlines so the YAML-ish render stays tight.
153
+ return capped.replace(/\s+$/g, '');
154
+ }
155
+ //# sourceMappingURL=context-prefix.js.map
@@ -0,0 +1,260 @@
1
+ /**
2
+ * β5a P1+P6 — CLI-side intent classifier: definitional vs operational.
3
+ *
4
+ * Background (CEO 2026-05-26 quality gate ≥80% vs Claude Code): the
5
+ * α7.X Phase 2 comparative eval surfaced a high-frequency Pugi loss
6
+ * mode where the model interpreted EVERY user message as an operational
7
+ * task and reached for tools. Concrete reproducer: when an operator
8
+ * asks "What is grep?", pre-β5a Pugi would issue a `bash` tool call
9
+ * (`man grep`, `grep --help`, sometimes a `glob` over `**`) instead
10
+ * of answering explanatorily. Claude Code answers in one paragraph,
11
+ * no tool calls. That single failure mode costs ~3 of the 23
12
+ * comparative fixtures.
13
+ *
14
+ * Root cause: the system prompt advertises tools as the primary way
15
+ * to make progress; without a counter-instruction, the model defaults
16
+ * to tools even for pure-knowledge questions. Prompt v2 (P6) adds
17
+ * definitional EXAMPLES in the prompt; this classifier is the
18
+ * deterministic safety net that flags definitional questions BEFORE
19
+ * the engine loop fires so we can:
20
+ *
21
+ * 1. Inject a one-line `<intent>definitional</intent>` marker into
22
+ * the user message that the prompt explicitly teaches the model
23
+ * to honour ("when you see this marker, answer in prose, do not
24
+ * call tools").
25
+ * 2. Make the marker visible in the SSE stream so cabinet UI / eval
26
+ * reports can show why a tool-free answer was correct.
27
+ *
28
+ * The classifier is intentionally simple — pure regex/keyword scoring,
29
+ * sub-millisecond, no LLM call, no fs reads. It must be:
30
+ *
31
+ * - Deterministic (same input always → same output, the eval relies
32
+ * on this for stable comparison runs).
33
+ * - Conservative on the operational side. False-positive
34
+ * definitional is mostly harmless (the model can still call tools
35
+ * if needed — the marker is a hint, not a hard gate). False-
36
+ * positive operational on a definitional question is what we are
37
+ * fixing — biggest loss surface.
38
+ *
39
+ * Coverage: EN + RU (the two primary CLI languages per
40
+ * `feedback_chat_always_russian_no_drift.md`). Uses lowercase compare
41
+ * for keyword matches, anchored regex for prefix patterns. Stop-word
42
+ * agnostic — the question "what is X?" is the dominant template and
43
+ * survives every common stop-word filter.
44
+ *
45
+ * Out of scope: deciding WHICH tool to call when operational. That
46
+ * is the model's job. We only decide whether to suggest "no tools
47
+ * needed" up front.
48
+ */
49
+ /**
50
+ * EN + RU definitional question openers. Anchored at start-of-string
51
+ * (after trim + lower) so a sentence-medial "what is" inside a larger
52
+ * operational sentence does not flip the verdict. Each entry is
53
+ * either a literal prefix or a RegExp matched against the lowercase
54
+ * trimmed input.
55
+ *
56
+ * Boundary policy: JS `\b` is ASCII-only — Cyrillic letters do not
57
+ * count as word characters, so `^объясни\b` matches "объясни" against
58
+ * a space follower BUT fails when the next char is a Cyrillic letter
59
+ * (which it never is at this position) and surprisingly fails on
60
+ * "объясни X" because `\b` between `и` (non-word in ASCII regex) and
61
+ * ` ` (non-word) is never a boundary. We use an explicit
62
+ * `(?=\s|$|[!?.,])` lookahead so Cyrillic prefixes match the same
63
+ * way ASCII ones do.
64
+ */
65
+ const WB = '(?=\\s|$|[!?.,;:])';
66
+ const DEFINITIONAL_PATTERNS = [
67
+ // EN
68
+ new RegExp(`^what\\s+(is|are|does|do)${WB}`),
69
+ new RegExp(`^who\\s+(is|are|was|were)${WB}`),
70
+ new RegExp(`^when\\s+(is|was|does|did)${WB}`),
71
+ new RegExp(`^where\\s+(is|are|does|did)${WB}`),
72
+ new RegExp(`^why\\s+(is|are|does|did)${WB}`),
73
+ new RegExp(`^how\\s+(does|do|did)\\s+\\S+\\s+work${WB}`),
74
+ new RegExp(`^explain\\s+(what|why|how)${WB}`),
75
+ new RegExp(`^define${WB}`),
76
+ new RegExp(`^tell\\s+me\\s+(what|about)${WB}`),
77
+ new RegExp(`^can\\s+you\\s+(tell\\s+me|explain|describe)${WB}`),
78
+ // RU
79
+ new RegExp(`^что\\s+(такое|это|значит)${WB}`),
80
+ new RegExp(`^кто\\s+(такой|такая|такие|это)${WB}`),
81
+ new RegExp(`^как\\s+работает${WB}`),
82
+ new RegExp(`^зачем${WB}`),
83
+ new RegExp(`^почему${WB}`),
84
+ new RegExp(`^объясни${WB}`),
85
+ new RegExp(`^расскажи\\s+(про|о|об)${WB}`),
86
+ ];
87
+ /**
88
+ * Operational verbs that strongly imply a tool-use task. A match
89
+ * here overrides a weak definitional signal (e.g. "explain the bug
90
+ * and fix it" -> operational, not definitional).
91
+ */
92
+ const OPERATIONAL_KEYWORDS = [
93
+ // EN
94
+ 'fix', 'patch', 'add', 'remove', 'delete', 'create', 'build',
95
+ 'implement', 'refactor', 'rename', 'migrate', 'deploy', 'install',
96
+ 'run', 'test', 'debug', 'investigate', 'open a pr', 'commit',
97
+ 'push', 'merge', 'review my', 'write a', 'write the', 'replace',
98
+ 'update the', 'modify', 'edit', 'change the',
99
+ // RU
100
+ 'почини', 'исправь', 'добавь', 'удали', 'создай', 'собери',
101
+ 'реализуй', 'отрефактори', 'переименуй', 'мигрируй', 'задеплой',
102
+ 'установи', 'запусти', 'протестируй', 'отладь', 'разберись',
103
+ 'закоммить', 'смержи', 'напиши', 'замени', 'обнови', 'измени',
104
+ 'отредактируй',
105
+ ];
106
+ /**
107
+ * File-path / identifier signals that, when combined with any verb,
108
+ * push us toward operational regardless of question framing. A bare
109
+ * "what is" question that names a file (`what is in apps/admin-api/
110
+ * src/main.ts?`) reads as operational — the operator wants the file
111
+ * inspected, not a dictionary definition.
112
+ */
113
+ const PATH_SIGNAL = /[./][a-z0-9_-]+\.(ts|tsx|js|jsx|json|md|yaml|yml|sh|py|prisma|css|scss|html|sql|env)\b/i;
114
+ const CODE_SYMBOL_SIGNAL = /\b[A-Z][a-zA-Z0-9]+\.(prototype|find|create|run|build|register)\b/;
115
+ /**
116
+ * Classify a single user message. Returns one of:
117
+ *
118
+ * - `definitional` — high confidence the operator wants knowledge,
119
+ * not edits. Prompt the model to answer in prose, no tools.
120
+ * - `operational` — verbs / paths / explicit "fix X" framing.
121
+ * Model decides tools as normal.
122
+ * - `ambiguous` — neither side won decisively. The model gets no
123
+ * extra hint; default behaviour applies. The marker is NOT
124
+ * injected for ambiguous to keep the prompt cache stable.
125
+ *
126
+ * Confidence: rough heuristic — `1.0` when a pattern matched cleanly
127
+ * with no opposing signal; lower when both sides matched.
128
+ *
129
+ * Pure function, no side effects.
130
+ */
131
+ export function classifyIntent(rawMessage) {
132
+ const trimmed = rawMessage.trim();
133
+ if (trimmed.length === 0) {
134
+ return {
135
+ intent: 'ambiguous',
136
+ confidence: 0,
137
+ matched: [],
138
+ rationale: 'empty input',
139
+ };
140
+ }
141
+ const lower = trimmed.toLowerCase();
142
+ const matched = [];
143
+ let definitionalScore = 0;
144
+ let operationalScore = 0;
145
+ let hasDefinitionalOpener = false;
146
+ for (const pattern of DEFINITIONAL_PATTERNS) {
147
+ if (pattern.test(lower)) {
148
+ // Three points — heavier than a single operational keyword
149
+ // match (two) so a definitional opener cannot be flipped by
150
+ // ONE incidental verb appearing as part of a noun phrase
151
+ // (e.g. "what does prisma MIGRATE do" — the verb "migrate"
152
+ // here names a subcommand, not an action the operator wants
153
+ // executed). Operational still wins when multiple operational
154
+ // signals stack (verb + path + imperative).
155
+ definitionalScore += 3;
156
+ matched.push(`def:${pattern.source}`);
157
+ hasDefinitionalOpener = true;
158
+ break; // a single anchored prefix is enough
159
+ }
160
+ }
161
+ // ?-terminated short utterance with no verbs is a strong
162
+ // definitional signal even without a what/who/why opener.
163
+ if (lower.endsWith('?') && lower.split(/\s+/).length <= 6) {
164
+ definitionalScore += 1;
165
+ matched.push('def:short-question');
166
+ }
167
+ // A definitional opener PLUS an explicit `?` terminator together
168
+ // form a pure question. Verbs mentioned inside ("what does prisma
169
+ // MIGRATE do?") are sub-noun-phrase references, not commands —
170
+ // boost definitional so the verb-keyword scoring below cannot tie.
171
+ if (hasDefinitionalOpener && lower.endsWith('?')) {
172
+ definitionalScore += 2;
173
+ matched.push('def:opener+question');
174
+ }
175
+ for (const keyword of OPERATIONAL_KEYWORDS) {
176
+ if (lower.includes(keyword)) {
177
+ operationalScore += 2;
178
+ matched.push(`op:${keyword}`);
179
+ break;
180
+ }
181
+ }
182
+ if (PATH_SIGNAL.test(trimmed)) {
183
+ operationalScore += 1;
184
+ matched.push('op:path-signal');
185
+ }
186
+ if (CODE_SYMBOL_SIGNAL.test(trimmed)) {
187
+ operationalScore += 1;
188
+ matched.push('op:code-symbol');
189
+ }
190
+ // Imperative ("do X") without any question framing is operational.
191
+ if (!lower.endsWith('?') && /^(please\s+)?(do|run|make|let'?s)\b/.test(lower)) {
192
+ operationalScore += 1;
193
+ matched.push('op:imperative');
194
+ }
195
+ // Decision.
196
+ if (definitionalScore === 0 && operationalScore === 0) {
197
+ return {
198
+ intent: 'ambiguous',
199
+ confidence: 0,
200
+ matched,
201
+ rationale: 'no rules matched',
202
+ };
203
+ }
204
+ if (definitionalScore > operationalScore + 1) {
205
+ return {
206
+ intent: 'definitional',
207
+ confidence: clamp01(definitionalScore / (definitionalScore + operationalScore + 1)),
208
+ matched,
209
+ rationale: `definitional ${definitionalScore} > operational ${operationalScore}`,
210
+ };
211
+ }
212
+ if (operationalScore >= definitionalScore) {
213
+ return {
214
+ intent: 'operational',
215
+ confidence: clamp01(operationalScore / (definitionalScore + operationalScore + 1)),
216
+ matched,
217
+ rationale: `operational ${operationalScore} >= definitional ${definitionalScore}`,
218
+ };
219
+ }
220
+ // Definitional won but margin is tight — keep but lower confidence.
221
+ return {
222
+ intent: 'definitional',
223
+ confidence: clamp01(definitionalScore / (definitionalScore + operationalScore + 1)) * 0.7,
224
+ matched,
225
+ rationale: `definitional ${definitionalScore} marginal over operational ${operationalScore}`,
226
+ };
227
+ }
228
+ /**
229
+ * Marker the engine wraps the user message in when the classifier
230
+ * picked `definitional`. The system prompt v2 teaches the model to
231
+ * answer in prose when it sees this marker.
232
+ *
233
+ * Format choice: angle-bracket pseudo-XML, matches the existing
234
+ * `<context>` / `<privacy-mode>` markers in the prompt — consistent
235
+ * marker grammar across the codebase keeps the model attentive.
236
+ */
237
+ export const DEFINITIONAL_MARKER_OPEN = '<intent kind="definitional">';
238
+ export const DEFINITIONAL_MARKER_CLOSE = '</intent>';
239
+ /**
240
+ * Wrap a user message with the intent marker when the classifier
241
+ * fires `definitional`. Returns the original message verbatim for
242
+ * the `operational` and `ambiguous` cases — we never lie to the
243
+ * model about ambiguity, and we never restrict the model's tool
244
+ * use on an unrelated operational task.
245
+ */
246
+ export function applyIntentMarker(message, intent) {
247
+ if (intent !== 'definitional')
248
+ return message;
249
+ return `${DEFINITIONAL_MARKER_OPEN}\n${message}\n${DEFINITIONAL_MARKER_CLOSE}`;
250
+ }
251
+ function clamp01(value) {
252
+ if (!Number.isFinite(value))
253
+ return 0;
254
+ if (value < 0)
255
+ return 0;
256
+ if (value > 1)
257
+ return 1;
258
+ return value;
259
+ }
260
+ //# sourceMappingURL=intent.js.map