@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.51

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 (264) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/assets/pugi-prozr2-mascot.ansi +9 -0
  4. package/bin/run.js +33 -1
  5. package/dist/commands/jobs-watch.js +201 -0
  6. package/dist/commands/jobs.js +15 -0
  7. package/dist/commands/smoke.js +133 -0
  8. package/dist/core/agent-progress/cleanup.js +134 -0
  9. package/dist/core/agent-progress/schema.js +144 -0
  10. package/dist/core/agent-progress/writer.js +101 -0
  11. package/dist/core/artifact-chain/dispatcher.js +148 -0
  12. package/dist/core/artifact-chain/exporter.js +164 -0
  13. package/dist/core/artifact-chain/state.js +243 -0
  14. package/dist/core/artifact-chain/steps.js +169 -0
  15. package/dist/core/auth/ensure-authenticated.js +129 -0
  16. package/dist/core/auth/env-provider.js +238 -0
  17. package/dist/core/auto-update/channels.js +122 -0
  18. package/dist/core/auto-update/checker.js +241 -0
  19. package/dist/core/auto-update/state.js +235 -0
  20. package/dist/core/bare-mode/index.js +107 -0
  21. package/dist/core/bash-classifier.js +400 -4
  22. package/dist/core/checkpoint/resumer.js +149 -0
  23. package/dist/core/checkpoint/rewinder.js +291 -0
  24. package/dist/core/codegraph/decision-store.js +248 -0
  25. package/dist/core/codegraph/detect-repo.js +459 -0
  26. package/dist/core/codegraph/install.js +134 -0
  27. package/dist/core/codegraph/offer-hook.js +220 -0
  28. package/dist/core/compact/auto-trigger.js +96 -0
  29. package/dist/core/compact/buffer-rewriter.js +115 -0
  30. package/dist/core/compact/summarizer.js +208 -0
  31. package/dist/core/compact/token-counter.js +108 -0
  32. package/dist/core/consensus/diff-capture.js +112 -3
  33. package/dist/core/context/index.js +7 -0
  34. package/dist/core/context/markdown-traverse.js +255 -0
  35. package/dist/core/cost/rate-card.js +129 -0
  36. package/dist/core/cost/tracker.js +221 -0
  37. package/dist/core/denial-tracking/index.js +8 -0
  38. package/dist/core/denial-tracking/state.js +264 -0
  39. package/dist/core/diagnostics/probe-runner.js +93 -0
  40. package/dist/core/diagnostics/probes/api.js +46 -0
  41. package/dist/core/diagnostics/probes/auth.js +86 -0
  42. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  43. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  44. package/dist/core/diagnostics/probes/config.js +72 -0
  45. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  46. package/dist/core/diagnostics/probes/disk.js +81 -0
  47. package/dist/core/diagnostics/probes/git.js +65 -0
  48. package/dist/core/diagnostics/probes/hooks.js +118 -0
  49. package/dist/core/diagnostics/probes/mcp.js +75 -0
  50. package/dist/core/diagnostics/probes/node.js +59 -0
  51. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  52. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  53. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  54. package/dist/core/diagnostics/probes/session.js +74 -0
  55. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  56. package/dist/core/diagnostics/probes/workspace.js +63 -0
  57. package/dist/core/diagnostics/types.js +70 -0
  58. package/dist/core/dispatch/cache-cleanup.js +197 -0
  59. package/dist/core/dispatch/cache-handoff.js +295 -0
  60. package/dist/core/edits/dispatch.js +218 -2
  61. package/dist/core/edits/journal.js +199 -0
  62. package/dist/core/edits/layer-d-ast.js +557 -14
  63. package/dist/core/edits/verify-hook.js +273 -0
  64. package/dist/core/edits/worktree.js +322 -0
  65. package/dist/core/engine/anvil-client.js +115 -5
  66. package/dist/core/engine/auto-compact.js +179 -0
  67. package/dist/core/engine/budgets.js +155 -0
  68. package/dist/core/engine/context-prefix.js +155 -0
  69. package/dist/core/engine/intent.js +260 -0
  70. package/dist/core/engine/native-pugi.js +897 -211
  71. package/dist/core/engine/prompts.js +88 -2
  72. package/dist/core/engine/strip-internal-fields.js +124 -0
  73. package/dist/core/engine/tool-bridge.js +1045 -36
  74. package/dist/core/feedback/queue.js +177 -0
  75. package/dist/core/feedback/submitter.js +145 -0
  76. package/dist/core/file-cache.js +113 -1
  77. package/dist/core/hooks/events.js +44 -0
  78. package/dist/core/hooks/index.js +15 -0
  79. package/dist/core/hooks/registry.js +213 -0
  80. package/dist/core/hooks/runner.js +236 -0
  81. package/dist/core/hooks/v2/event-emitter.js +115 -0
  82. package/dist/core/hooks/v2/executor.js +282 -0
  83. package/dist/core/hooks/v2/index.js +25 -0
  84. package/dist/core/hooks/v2/lifecycle.js +104 -0
  85. package/dist/core/hooks/v2/loader.js +216 -0
  86. package/dist/core/hooks/v2/matcher.js +125 -0
  87. package/dist/core/hooks/v2/trust.js +143 -0
  88. package/dist/core/hooks/v2/types.js +86 -0
  89. package/dist/core/lsp/cache.js +105 -0
  90. package/dist/core/lsp/client.js +776 -0
  91. package/dist/core/lsp/language-detect.js +66 -0
  92. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  93. package/dist/core/mcp/client.js +75 -6
  94. package/dist/core/mcp/http-server.js +553 -0
  95. package/dist/core/mcp/orchestrator-tools.js +662 -0
  96. package/dist/core/mcp/permission.js +190 -0
  97. package/dist/core/mcp/registry.js +24 -2
  98. package/dist/core/mcp/server-tools.js +219 -0
  99. package/dist/core/mcp/server.js +397 -0
  100. package/dist/core/memory/dual-write.js +416 -0
  101. package/dist/core/memory/phase1-kinds.js +20 -0
  102. package/dist/core/memory-sync/queue.js +158 -0
  103. package/dist/core/onboarding/ensure-initialized.js +133 -0
  104. package/dist/core/onboarding/marker.js +111 -0
  105. package/dist/core/onboarding/telemetry-state.js +108 -0
  106. package/dist/core/output-style/presets.js +176 -0
  107. package/dist/core/output-style/state.js +185 -0
  108. package/dist/core/path-security.js +284 -2
  109. package/dist/core/permissions/auto-classifier.js +124 -0
  110. package/dist/core/permissions/circuit-breaker.js +83 -0
  111. package/dist/core/permissions/gate.js +278 -0
  112. package/dist/core/permissions/index.js +20 -0
  113. package/dist/core/permissions/mode.js +174 -0
  114. package/dist/core/permissions/state.js +241 -0
  115. package/dist/core/permissions/tool-class.js +93 -0
  116. package/dist/core/prd-check/parser.js +215 -0
  117. package/dist/core/prd-check/reporter.js +127 -0
  118. package/dist/core/prd-check/session-review.js +557 -0
  119. package/dist/core/prd-check/verifiers.js +223 -0
  120. package/dist/core/pugi-md/context-injector.js +76 -0
  121. package/dist/core/pugi-md/walk-up.js +207 -0
  122. package/dist/core/release-notes/parser.js +241 -0
  123. package/dist/core/release-notes/state.js +116 -0
  124. package/dist/core/repl/history.js +11 -1
  125. package/dist/core/repl/model-pricing.js +135 -0
  126. package/dist/core/repl/session.js +1897 -37
  127. package/dist/core/repl/slash-commands.js +430 -15
  128. package/dist/core/repl/store/session-store.js +31 -2
  129. package/dist/core/repl/workspace-context.js +22 -0
  130. package/dist/core/repo-map/build.js +125 -0
  131. package/dist/core/repo-map/cache.js +185 -0
  132. package/dist/core/repo-map/extractor.js +254 -0
  133. package/dist/core/repo-map/formatter.js +145 -0
  134. package/dist/core/repo-map/scanner.js +211 -0
  135. package/dist/core/retry-budget/budget.js +284 -0
  136. package/dist/core/retry-budget/index.js +5 -0
  137. package/dist/core/session.js +92 -0
  138. package/dist/core/settings.js +80 -0
  139. package/dist/core/share/formatter.js +271 -0
  140. package/dist/core/share/redactor.js +221 -0
  141. package/dist/core/share/uploader.js +267 -0
  142. package/dist/core/skills/defaults.js +457 -0
  143. package/dist/core/smoke/headless-driver.js +174 -0
  144. package/dist/core/smoke/orchestrator.js +194 -0
  145. package/dist/core/smoke/runner.js +238 -0
  146. package/dist/core/smoke/scenario-parser.js +316 -0
  147. package/dist/core/subagents/dispatcher-real.js +600 -0
  148. package/dist/core/subagents/dispatcher.js +113 -24
  149. package/dist/core/subagents/index.js +18 -5
  150. package/dist/core/subagents/isolation-matrix.js +213 -0
  151. package/dist/core/subagents/spawn.js +19 -4
  152. package/dist/core/telemetry/emitter.js +229 -0
  153. package/dist/core/telemetry/queue.js +251 -0
  154. package/dist/core/theme/context.js +91 -0
  155. package/dist/core/theme/presets.js +228 -0
  156. package/dist/core/theme/state.js +181 -0
  157. package/dist/core/todos/invariant.js +10 -0
  158. package/dist/core/todos/state.js +177 -0
  159. package/dist/core/transport/version-interceptor.js +166 -0
  160. package/dist/core/vim/keymap.js +288 -0
  161. package/dist/core/vim/state.js +92 -0
  162. package/dist/core/worktree-manager/cleanup.js +123 -0
  163. package/dist/core/worktree-manager/manager.js +303 -0
  164. package/dist/index.js +28 -0
  165. package/dist/runtime/bootstrap.js +190 -0
  166. package/dist/runtime/cli.js +3241 -343
  167. package/dist/runtime/commands/cancel.js +231 -0
  168. package/dist/runtime/commands/chain.js +489 -0
  169. package/dist/runtime/commands/codegraph-status.js +227 -0
  170. package/dist/runtime/commands/compact.js +297 -0
  171. package/dist/runtime/commands/cost.js +199 -0
  172. package/dist/runtime/commands/delegate.js +242 -11
  173. package/dist/runtime/commands/dispatch.js +126 -0
  174. package/dist/runtime/commands/doctor.js +412 -0
  175. package/dist/runtime/commands/feedback.js +184 -0
  176. package/dist/runtime/commands/hooks.js +184 -0
  177. package/dist/runtime/commands/lsp.js +368 -0
  178. package/dist/runtime/commands/mcp.js +879 -0
  179. package/dist/runtime/commands/memory.js +508 -0
  180. package/dist/runtime/commands/model.js +237 -0
  181. package/dist/runtime/commands/onboarding.js +275 -0
  182. package/dist/runtime/commands/patch.js +128 -0
  183. package/dist/runtime/commands/permissions.js +112 -0
  184. package/dist/runtime/commands/plan.js +143 -0
  185. package/dist/runtime/commands/prd-check.js +285 -0
  186. package/dist/runtime/commands/redo-blob-store.js +92 -0
  187. package/dist/runtime/commands/redo.js +361 -0
  188. package/dist/runtime/commands/release-notes.js +229 -0
  189. package/dist/runtime/commands/repo-map.js +95 -0
  190. package/dist/runtime/commands/report.js +299 -0
  191. package/dist/runtime/commands/resume.js +118 -0
  192. package/dist/runtime/commands/review-consensus.js +17 -2
  193. package/dist/runtime/commands/rewind.js +333 -0
  194. package/dist/runtime/commands/sessions.js +163 -0
  195. package/dist/runtime/commands/share.js +316 -0
  196. package/dist/runtime/commands/status.js +186 -0
  197. package/dist/runtime/commands/stickers.js +82 -0
  198. package/dist/runtime/commands/style.js +194 -0
  199. package/dist/runtime/commands/theme.js +196 -0
  200. package/dist/runtime/commands/undo.js +32 -0
  201. package/dist/runtime/commands/update.js +289 -0
  202. package/dist/runtime/commands/vim.js +140 -0
  203. package/dist/runtime/commands/worktree.js +177 -0
  204. package/dist/runtime/commands/worktrees.js +155 -0
  205. package/dist/runtime/headless-repl.js +195 -0
  206. package/dist/runtime/headless.js +543 -0
  207. package/dist/runtime/load-hooks-or-exit.js +71 -0
  208. package/dist/runtime/plan-decompose.js +531 -0
  209. package/dist/runtime/version.js +65 -0
  210. package/dist/tools/agent-tool.js +229 -0
  211. package/dist/tools/apply-patch.js +556 -0
  212. package/dist/tools/ask-user-question.js +213 -0
  213. package/dist/tools/ask-user.js +115 -0
  214. package/dist/tools/bash.js +203 -4
  215. package/dist/tools/file-tools.js +85 -14
  216. package/dist/tools/lsp-tools.js +189 -0
  217. package/dist/tools/mcp-tool.js +260 -0
  218. package/dist/tools/multi-edit.js +361 -0
  219. package/dist/tools/powershell.js +268 -0
  220. package/dist/tools/registry.js +51 -0
  221. package/dist/tools/skill-tool.js +96 -0
  222. package/dist/tools/tasks.js +208 -0
  223. package/dist/tools/todo-write.js +184 -0
  224. package/dist/tools/web-fetch.js +147 -2
  225. package/dist/tools/web-search.js +458 -0
  226. package/dist/tui/agent-progress-card.js +111 -0
  227. package/dist/tui/agent-tree.js +10 -0
  228. package/dist/tui/ask-modal.js +2 -2
  229. package/dist/tui/ask-user-question-prompt.js +192 -0
  230. package/dist/tui/compact-banner.js +81 -0
  231. package/dist/tui/conversation-pane.js +82 -8
  232. package/dist/tui/cost-table.js +111 -0
  233. package/dist/tui/doctor-table.js +46 -0
  234. package/dist/tui/feedback-prompt.js +156 -0
  235. package/dist/tui/input-box.js +218 -3
  236. package/dist/tui/markdown-render.js +4 -4
  237. package/dist/tui/onboarding-wizard.js +240 -0
  238. package/dist/tui/permissions-picker.js +86 -0
  239. package/dist/tui/render.js +35 -0
  240. package/dist/tui/repl-render.js +313 -35
  241. package/dist/tui/repl-splash-art.js +1 -1
  242. package/dist/tui/repl-splash-mascot.js +32 -8
  243. package/dist/tui/repl-splash.js +2 -2
  244. package/dist/tui/repl.js +85 -5
  245. package/dist/tui/splash.js +1 -1
  246. package/dist/tui/status-bar.js +94 -16
  247. package/dist/tui/status-table.js +7 -0
  248. package/dist/tui/stickers-art.js +136 -0
  249. package/dist/tui/style-table.js +28 -0
  250. package/dist/tui/theme-table.js +29 -0
  251. package/dist/tui/thinking-spinner.js +123 -0
  252. package/dist/tui/tool-stream-pane.js +52 -3
  253. package/dist/tui/update-banner.js +27 -2
  254. package/dist/tui/vim-input.js +267 -0
  255. package/dist/tui/welcome-banner.js +107 -0
  256. package/dist/tui/welcome-data.js +293 -0
  257. package/docs/examples/codegraph.mcp.json +10 -0
  258. package/package.json +13 -7
  259. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  260. package/test/scenarios/compact-force.scenario.txt +11 -0
  261. package/test/scenarios/identity.scenario.txt +11 -0
  262. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  263. package/test/scenarios/walkback.scenario.txt +12 -0
  264. 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,179 @@
1
+ /**
2
+ * Crude token-count heuristic mirroring `runEngineLoop`'s fallback
3
+ * accounting (transcript char count / 4). The CLI does not have access
4
+ * to a real tokenizer pre-flight — the runtime returns `usage.totalTokens`
5
+ * only on the server response, which is too late for our pre-turn gate.
6
+ * char/4 is in the right order of magnitude for English/TS and matches
7
+ * what the loop's own fallback uses on `tokensUsed === 0` upstream.
8
+ */
9
+ export function estimateTranscriptTokens(messages) {
10
+ let chars = 0;
11
+ for (const m of messages) {
12
+ chars += m.content.length;
13
+ const calls = m.toolCalls ?? [];
14
+ for (const c of calls) {
15
+ chars += c.name.length + c.arguments.length;
16
+ }
17
+ }
18
+ return Math.ceil(chars / 4);
19
+ }
20
+ const FILE_TOOL_NAMES = new Set([
21
+ 'read',
22
+ 'write',
23
+ 'edit',
24
+ 'multi_edit',
25
+ 'multiEdit',
26
+ ]);
27
+ /**
28
+ * Walk the dropped slice and pull out tool-call metadata. We parse the
29
+ * `arguments` JSON best-effort — a bad parse is harmless here because
30
+ * the executor surfaced the canonical error to the model already; the
31
+ * gist just under-counts that one call.
32
+ */
33
+ export function summarizeDroppedTurns(dropped) {
34
+ let toolCalls = 0;
35
+ let bashCalls = 0;
36
+ const files = new Set();
37
+ for (const m of dropped) {
38
+ if (m.role === 'assistant') {
39
+ const calls = m.toolCalls ?? [];
40
+ toolCalls += calls.length;
41
+ for (const c of calls) {
42
+ if (c.name === 'bash') {
43
+ bashCalls += 1;
44
+ continue;
45
+ }
46
+ if (FILE_TOOL_NAMES.has(c.name)) {
47
+ const p = extractPath(c.arguments);
48
+ if (p)
49
+ files.add(p);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ return {
55
+ toolCalls,
56
+ fileCount: files.size,
57
+ bashCalls,
58
+ messagesDropped: dropped.length,
59
+ };
60
+ }
61
+ function extractPath(rawArgs) {
62
+ if (!rawArgs)
63
+ return null;
64
+ try {
65
+ const parsed = JSON.parse(rawArgs);
66
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
67
+ const obj = parsed;
68
+ const path = obj['path'] ?? obj['filePath'];
69
+ if (typeof path === 'string' && path.length > 0)
70
+ return path;
71
+ }
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ return null;
77
+ }
78
+ /**
79
+ * Format the deterministic gist string spliced into the synthetic
80
+ * system message. Stable shape so spec assertions and operator
81
+ * logs do not drift turn-over-turn.
82
+ */
83
+ export function renderAutoCompactSentinel(stats) {
84
+ return (`[auto-compact] Earlier turns ` +
85
+ `(${stats.toolCalls} tool calls, ${stats.fileCount} files read, ${stats.bashCalls} bash commands) ` +
86
+ `summarized to free transcript headroom. ` +
87
+ `Recent turns and the original task remain in context; ` +
88
+ `re-read any earlier file by name if you need its contents again.`);
89
+ }
90
+ /**
91
+ * Minimum transcript length (in messages) before compact is allowed.
92
+ * We always retain `system + user` (the first 2) + the last 2 turns,
93
+ * so anything <= 4 messages has nothing in the middle to drop.
94
+ * Compacting на 4-message transcript would either be a no-op or
95
+ * accidentally drop the user's original task.
96
+ */
97
+ export const MIN_COMPACT_TRANSCRIPT_LENGTH = 5;
98
+ /**
99
+ * Pure gate. Returns `compact` when ALL of:
100
+ * - `config.enabled` is true
101
+ * - estimated transcript tokens >= `thresholdRatio * maxTokens`
102
+ * - transcript length >= 5 (need history to drop)
103
+ */
104
+ export function evaluateAutoCompactDecision(input) {
105
+ const usedTokens = estimateTranscriptTokens(input.transcript);
106
+ if (!input.config.enabled) {
107
+ return { kind: 'skip', reason: 'disabled', usedTokens };
108
+ }
109
+ if (input.transcript.length < MIN_COMPACT_TRANSCRIPT_LENGTH) {
110
+ return { kind: 'skip', reason: 'transcript-too-short', usedTokens };
111
+ }
112
+ const thresholdTokens = Math.floor(input.config.thresholdRatio * input.maxTokens);
113
+ if (usedTokens < thresholdTokens) {
114
+ return { kind: 'skip', reason: 'below-threshold', usedTokens };
115
+ }
116
+ return { kind: 'compact', usedTokens, thresholdTokens };
117
+ }
118
+ /**
119
+ * Rewrite the transcript: keep the first two messages (system + user
120
+ * task), drop the middle (assistant + tool turns), insert a synthetic
121
+ * system sentinel summarizing what was dropped, then re-append the
122
+ * last 2 messages so the model has the most-recent tool result + its
123
+ * own last reply in full fidelity.
124
+ *
125
+ * Precondition: caller has already checked the decision is `compact`
126
+ * (length >= MIN_COMPACT_TRANSCRIPT_LENGTH). The function still guards
127
+ * with a defensive identity-return on shorter transcripts so a careless
128
+ * caller cannot corrupt the prefix.
129
+ */
130
+ export function compactTranscript(transcript) {
131
+ const preUsedTokens = estimateTranscriptTokens(transcript);
132
+ if (transcript.length < MIN_COMPACT_TRANSCRIPT_LENGTH) {
133
+ return {
134
+ transcript: transcript.slice(),
135
+ droppedCount: 0,
136
+ gist: '',
137
+ stats: { toolCalls: 0, fileCount: 0, bashCalls: 0, messagesDropped: 0 },
138
+ preUsedTokens,
139
+ postUsedTokens: preUsedTokens,
140
+ };
141
+ }
142
+ // Always retain: index 0 (system) + index 1 (original user task) +
143
+ // last 2 messages. The middle slice is what gets summarised.
144
+ const head = transcript.slice(0, 2);
145
+ const tail = transcript.slice(-2);
146
+ const middle = transcript.slice(2, -2);
147
+ const stats = summarizeDroppedTurns(middle);
148
+ const gist = renderAutoCompactSentinel(stats);
149
+ const sentinelMessage = {
150
+ role: 'system',
151
+ content: gist,
152
+ };
153
+ const next = [...head, sentinelMessage, ...tail];
154
+ const postUsedTokens = estimateTranscriptTokens(next);
155
+ return {
156
+ transcript: next,
157
+ droppedCount: middle.length,
158
+ gist,
159
+ stats,
160
+ preUsedTokens,
161
+ postUsedTokens,
162
+ };
163
+ }
164
+ /**
165
+ * Convenience composer used by `runEngineLoop`: evaluate → compact in
166
+ * one shot. Returns `null` when the decision was `skip` so the loop
167
+ * driver can branch cheaply без destructuring two layers of records.
168
+ */
169
+ export function maybeCompact(transcript, maxTokens, config) {
170
+ const decision = evaluateAutoCompactDecision({
171
+ transcript,
172
+ maxTokens,
173
+ config,
174
+ });
175
+ if (decision.kind === 'skip')
176
+ return null;
177
+ return compactTranscript(transcript);
178
+ }
179
+ //# sourceMappingURL=auto-compact.js.map
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Auto-compact (mid-loop transcript summarization) default trip point as
3
+ * a fraction of the per-command `maxTokens` envelope. CEO P1 #14 (CC
4
+ * parity): when transcript char-count tokens cross 75% of the budget,
5
+ * the engine loop drops the middle turns and inserts a deterministic
6
+ * `[auto-compact]` sentinel so the loop can continue без the model
7
+ * tripping the `budget_exhausted` terminal status mid-build.
8
+ *
9
+ * Empirically — `pugi code "big refactor"` hits the 80k cap on turn 4-5
10
+ * and refuses to finish; `pugi fix` does the same at 50k. Auto-compact
11
+ * keeps the recent N turns + a one-line gist of the dropped tool calls
12
+ * so the model retains the most recent state without paying for the
13
+ * full prefix.
14
+ *
15
+ * Operators can opt out / retune via `.pugi/settings.json`:
16
+ *
17
+ * {
18
+ * "autoCompact": { "enabled": true, "thresholdRatio": 0.75 }
19
+ * }
20
+ *
21
+ * Bad values fall back silently to the default — the engine loop never
22
+ * crashes on a malformed settings field (mirrors `resolveBudget`).
23
+ */
24
+ export const AUTO_COMPACT_THRESHOLD_RATIO = 0.75;
25
+ export const DEFAULT_AUTO_COMPACT_CONFIG = {
26
+ enabled: true,
27
+ thresholdRatio: AUTO_COMPACT_THRESHOLD_RATIO,
28
+ };
29
+ /**
30
+ * Pull the auto-compact override from `.pugi/settings.json`. Uses the
31
+ * same defensive-cast pattern as `readSettingsBudget` so an unknown
32
+ * field shape silently falls back к defaults (the gate is a comfort
33
+ * feature; a malformed settings line must not break the engine loop).
34
+ *
35
+ * Returns the merged config — caller never sees `undefined`.
36
+ */
37
+ export function resolveAutoCompactConfig(settings) {
38
+ if (!settings)
39
+ return DEFAULT_AUTO_COMPACT_CONFIG;
40
+ const root = settings.autoCompact;
41
+ if (!root || typeof root !== 'object' || Array.isArray(root)) {
42
+ return DEFAULT_AUTO_COMPACT_CONFIG;
43
+ }
44
+ const r = root;
45
+ const enabledRaw = r['enabled'];
46
+ const thresholdRaw = r['thresholdRatio'];
47
+ const enabled = typeof enabledRaw === 'boolean'
48
+ ? enabledRaw
49
+ : DEFAULT_AUTO_COMPACT_CONFIG.enabled;
50
+ let thresholdRatio = DEFAULT_AUTO_COMPACT_CONFIG.thresholdRatio;
51
+ if (typeof thresholdRaw === 'number' && Number.isFinite(thresholdRaw)) {
52
+ if (thresholdRaw > 0 && thresholdRaw <= 1) {
53
+ thresholdRatio = thresholdRaw;
54
+ }
55
+ }
56
+ return { enabled, thresholdRatio };
57
+ }
58
+ /**
59
+ * β1 defaults. Source of truth for the per-command budget envelope.
60
+ * The runtime is allowed to look these up directly (no need to round
61
+ * trip through settings.json when no override is in play).
62
+ *
63
+ * 2026-05-28 bump (post-Wave-7 hooks-v2 + 6-perm-modes + auto-classifier
64
+ * added ~12K tokens of system-prompt + tools-schema overhead per turn):
65
+ * `code` 30k → 80k и `fix` 30k → 50k so a single-file refactor on a
66
+ * 1000-line source file no longer exhausts the budget on turn 2.
67
+ * Empirical: smoke `pugi code "сделай snake.html"` on beta.37 burned
68
+ * 36k/30k after 2 tool calls; beta.36 same task closed in 8k. The Wave-7
69
+ * additions are good (Claude Code parity), but the budget cap did not move with
70
+ * them. Claude Code's `code` default is ~80k; matching that restores headroom.
71
+ */
72
+ export const beta1DefaultBudgets = {
73
+ fix: { maxTokens: 50_000, maxToolCalls: 20 },
74
+ code: { maxTokens: 80_000, maxToolCalls: 20 },
75
+ build: { maxTokens: 200_000, maxToolCalls: 30 },
76
+ plan: { maxTokens: 200_000, maxToolCalls: 8 },
77
+ explain: { maxTokens: 40_000, maxToolCalls: 10 },
78
+ review_triple: { maxTokens: 100_000, maxToolCalls: 10 },
79
+ };
80
+ /**
81
+ * Hard upper bounds. Anything above this is treated as user error
82
+ * (likely a typo or misplaced decimal) and rejected by
83
+ * `assertBudgetWithinTier`. Stops a careless settings.json edit from
84
+ * silently authorising a 100M-token run.
85
+ */
86
+ export const HARD_MAX_TOKENS = 5_000_000;
87
+ export const HARD_MAX_TOOL_CALLS = 500;
88
+ /**
89
+ * Compute the effective budget for a given command, applying:
90
+ * 1. β1 defaults
91
+ * 2. settings.json `budgets.<command>` partial overrides
92
+ * 3. task-level override (caller-provided, e.g. CLI `--max-tokens`)
93
+ *
94
+ * Throws `BudgetConfigError` when the resolved budget exceeds the
95
+ * HARD_MAX_* caps so misconfigured settings.json fails fast.
96
+ */
97
+ export function resolveBudget(command, settings, override) {
98
+ const base = beta1DefaultBudgets[command];
99
+ const settingsBudget = readSettingsBudget(settings, command);
100
+ const resolved = {
101
+ maxTokens: override?.maxTokens ??
102
+ settingsBudget?.maxTokens ??
103
+ base.maxTokens,
104
+ maxToolCalls: override?.maxToolCalls ??
105
+ settingsBudget?.maxToolCalls ??
106
+ base.maxToolCalls,
107
+ };
108
+ assertBudgetWithinTier(command, resolved);
109
+ return resolved;
110
+ }
111
+ export class BudgetConfigError extends Error {
112
+ constructor(message) {
113
+ super(message);
114
+ this.name = 'BudgetConfigError';
115
+ }
116
+ }
117
+ export function assertBudgetWithinTier(command, budget) {
118
+ if (!Number.isFinite(budget.maxTokens) || budget.maxTokens <= 0) {
119
+ throw new BudgetConfigError(`budget[${command}].maxTokens must be a positive number, got ${budget.maxTokens}`);
120
+ }
121
+ if (!Number.isFinite(budget.maxToolCalls) || budget.maxToolCalls <= 0) {
122
+ throw new BudgetConfigError(`budget[${command}].maxToolCalls must be a positive number, got ${budget.maxToolCalls}`);
123
+ }
124
+ if (budget.maxTokens > HARD_MAX_TOKENS) {
125
+ throw new BudgetConfigError(`budget[${command}].maxTokens=${budget.maxTokens} exceeds hard cap ${HARD_MAX_TOKENS}`);
126
+ }
127
+ if (budget.maxToolCalls > HARD_MAX_TOOL_CALLS) {
128
+ throw new BudgetConfigError(`budget[${command}].maxToolCalls=${budget.maxToolCalls} exceeds hard cap ${HARD_MAX_TOOL_CALLS}`);
129
+ }
130
+ }
131
+ /**
132
+ * Pull a settings.json budget override for the given command, with
133
+ * defensive typing. `PugiSettings` does not yet declare `budgets`
134
+ * formally (β1 is the first sprint to land it) so we cast via unknown
135
+ * and validate each field at the boundary.
136
+ */
137
+ function readSettingsBudget(settings, command) {
138
+ if (!settings)
139
+ return undefined;
140
+ const root = settings.budgets;
141
+ if (!root || typeof root !== 'object' || Array.isArray(root))
142
+ return undefined;
143
+ const map = root;
144
+ const entry = map[command];
145
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
146
+ return undefined;
147
+ const e = entry;
148
+ const out = {};
149
+ if (typeof e['maxTokens'] === 'number')
150
+ out.maxTokens = e['maxTokens'];
151
+ if (typeof e['maxToolCalls'] === 'number')
152
+ out.maxToolCalls = e['maxToolCalls'];
153
+ return out;
154
+ }
155
+ //# 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