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

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 (250) 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 +1045 -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/powershell.js +156 -0
  211. package/dist/tools/registry.js +51 -0
  212. package/dist/tools/skill-tool.js +96 -0
  213. package/dist/tools/tasks.js +208 -0
  214. package/dist/tools/todo-write.js +184 -0
  215. package/dist/tools/web-fetch.js +147 -2
  216. package/dist/tools/web-search.js +458 -0
  217. package/dist/tui/agent-progress-card.js +111 -0
  218. package/dist/tui/agent-tree.js +10 -0
  219. package/dist/tui/ask-modal.js +2 -2
  220. package/dist/tui/ask-user-question-prompt.js +192 -0
  221. package/dist/tui/compact-banner.js +81 -0
  222. package/dist/tui/conversation-pane.js +82 -8
  223. package/dist/tui/cost-table.js +111 -0
  224. package/dist/tui/doctor-table.js +46 -0
  225. package/dist/tui/feedback-prompt.js +156 -0
  226. package/dist/tui/input-box.js +69 -2
  227. package/dist/tui/markdown-render.js +4 -4
  228. package/dist/tui/onboarding-wizard.js +240 -0
  229. package/dist/tui/permissions-picker.js +86 -0
  230. package/dist/tui/render.js +35 -0
  231. package/dist/tui/repl-render.js +303 -13
  232. package/dist/tui/repl-splash.js +2 -2
  233. package/dist/tui/repl.js +72 -14
  234. package/dist/tui/splash.js +1 -1
  235. package/dist/tui/status-bar.js +94 -16
  236. package/dist/tui/status-table.js +7 -0
  237. package/dist/tui/stickers-art.js +136 -0
  238. package/dist/tui/style-table.js +28 -0
  239. package/dist/tui/theme-table.js +29 -0
  240. package/dist/tui/tool-stream-pane.js +52 -3
  241. package/dist/tui/update-banner.js +20 -2
  242. package/dist/tui/vim-input.js +267 -0
  243. package/docs/examples/codegraph.mcp.json +10 -0
  244. package/package.json +12 -6
  245. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  246. package/test/scenarios/compact-force.scenario.txt +11 -0
  247. package/test/scenarios/identity.scenario.txt +11 -0
  248. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  249. package/test/scenarios/walkback.scenario.txt +12 -0
  250. package/dist/core/engine/compaction-hook.js +0 -154
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Headless mode runner for `pugi --print "<brief>"`.
3
+ *
4
+ * Design intent (CEO directive 2026-05-27 "нужно тестирование по кругу"):
5
+ *
6
+ * Pugi must be drivable non-interactively by Claude Code, Codex, CI
7
+ * runners, Docker images, and any agent that can spawn a subprocess.
8
+ * The Ink REPL is the human-facing surface; headless mode is the
9
+ * machine-facing surface. One round-trip = one engine turn = one
10
+ * `--print` invocation.
11
+ *
12
+ * Output contract (mirrors `claude --print --json`):
13
+ *
14
+ * Stdout — ONLY NDJSON event lines (one JSON object per line):
15
+ * {"type":"session.start", sessionId, cwd, timestamp}
16
+ * {"type":"turn.start", turnIndex, timestamp}
17
+ * {"type":"tool.call", callId, tool, args, timestamp}
18
+ * {"type":"tool.result", callId, status, summary, timestamp}
19
+ * {"type":"text.delta", content, timestamp}
20
+ * {"type":"thinking.delta",content, blockId, timestamp}
21
+ * {"type":"turn.end", turnIndex, usage, timestamp}
22
+ * {"type":"session.end", status, totalTokensIn, totalTokensOut,
23
+ * durationMs, filesChanged, timestamp}
24
+ * {"type":"error", kind, message, exit_code}
25
+ *
26
+ * Stderr — human-readable status + error details (never NDJSON).
27
+ *
28
+ * Stdout vs stderr discipline lets a caller pipe cleanly:
29
+ *
30
+ * pugi --print "..." --json 2>/dev/null | jq .
31
+ *
32
+ * Exit codes:
33
+ *
34
+ * 0 session.end emitted with status=done (or "complete")
35
+ * 1 hard error (auth, network, server crash, engine_unavailable)
36
+ * 2 turn-limit / budget exhausted without completion
37
+ *
38
+ * Reuses the existing `NativePugiEngineAdapter` (the same path the
39
+ * REPL drives) — the only difference is the output sink: instead of
40
+ * mounting Ink, we subscribe to the adapter's `streamEmitter` and
41
+ * forward each typed event as NDJSON.
42
+ */
43
+ import { resolve as resolvePath } from 'node:path';
44
+ import { existsSync, statSync } from 'node:fs';
45
+ import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
46
+ import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
47
+ import { defaultNonInteractiveMcpPrompt } from '../tools/mcp-tool.js';
48
+ import { loadMcpRegistry } from '../core/mcp/registry.js';
49
+ import { openSession, recordCommandStarted, recordCommandCompleted, } from '../core/session.js';
50
+ import { buildRuntimeConfig, loadRuntimeConfig, } from '@pugi/sdk';
51
+ import { resolveActiveCredential } from '../core/credentials.js';
52
+ /**
53
+ * NDJSON event emitter. One JSON object per line on stdout. Never emits
54
+ * partial lines — every event ends with `\n` so a caller piping into
55
+ * `jq -c` can parse incrementally. Stderr is reserved for human prose
56
+ * (errors, traces) so the operator's `2>/dev/null` filter keeps stdout
57
+ * pure machine-readable.
58
+ */
59
+ class NdjsonEventSink {
60
+ stdoutWrite;
61
+ constructor(stdoutWrite) {
62
+ this.stdoutWrite = stdoutWrite;
63
+ }
64
+ emit(event) {
65
+ // Stamp every event with `timestamp` if the caller did not supply
66
+ // one. ISO-8601 with millisecond precision is the agreed contract
67
+ // (per spec); a downstream timeline UI can sort lexicographically.
68
+ const stamped = {
69
+ ...event,
70
+ timestamp: event.timestamp ?? new Date().toISOString(),
71
+ };
72
+ this.stdoutWrite(`${JSON.stringify(stamped)}\n`);
73
+ }
74
+ }
75
+ /**
76
+ * Human-readable text sink. Used when `--print` runs WITHOUT `--json`
77
+ * (a human watching the pipe). Tool calls show as `> tool(args)`,
78
+ * results as `< ok|fail: summary`, text deltas as raw prose chunks.
79
+ * Status frames are demoted to stderr so the conversation flow on
80
+ * stdout stays clean.
81
+ */
82
+ class TextEventSink {
83
+ stdoutWrite;
84
+ stderrWrite;
85
+ constructor(stdoutWrite, stderrWrite) {
86
+ this.stdoutWrite = stdoutWrite;
87
+ this.stderrWrite = stderrWrite;
88
+ }
89
+ emit(event) {
90
+ switch (event.type) {
91
+ case 'session.start':
92
+ this.stderrWrite(`pugi headless: session ${event.sessionId} cwd=${event.cwd}\n`);
93
+ return;
94
+ case 'turn.start':
95
+ this.stderrWrite(`> turn ${event.turnIndex}\n`);
96
+ return;
97
+ case 'tool.call':
98
+ this.stderrWrite(`> ${event.tool}(${truncate(JSON.stringify(event.args), 80)})\n`);
99
+ return;
100
+ case 'tool.result':
101
+ this.stderrWrite(`< ${event.status === 'success' ? 'ok' : 'fail'}: ${truncate(String(event.summary ?? ''), 120)}\n`);
102
+ return;
103
+ case 'text.delta':
104
+ // Bare prose on stdout — this IS the answer.
105
+ this.stdoutWrite(String(event.content ?? ''));
106
+ return;
107
+ case 'thinking.delta':
108
+ // Thinking is informational; surface on stderr so stdout is the
109
+ // visible answer only. Operators who want thinking pipe stderr.
110
+ this.stderrWrite(`[think] ${truncate(String(event.content ?? ''), 200)}\n`);
111
+ return;
112
+ case 'turn.end':
113
+ this.stderrWrite(`< turn ${event.turnIndex} end\n`);
114
+ return;
115
+ case 'session.end':
116
+ // Newline separator so the final stdout text and stderr summary
117
+ // do not run together when both are tee'd to the same terminal.
118
+ this.stdoutWrite('\n');
119
+ this.stderrWrite(`pugi headless: ${event.status} · ${event.totalTokensIn ?? 0}+${event.totalTokensOut ?? 0} tokens · ${event.durationMs}ms\n`);
120
+ return;
121
+ case 'error':
122
+ this.stderrWrite(`pugi headless: error (${event.kind}) ${event.message}\n`);
123
+ return;
124
+ default:
125
+ return;
126
+ }
127
+ }
128
+ }
129
+ function truncate(s, max) {
130
+ return s.length <= max ? s : `${s.slice(0, max - 3)}...`;
131
+ }
132
+ /**
133
+ * Auto-init the workspace if `.pugi/` is missing. Per beta.13 #453 the
134
+ * scaffold is silent — headless callers (CI, agents) drop into fresh
135
+ * cwds all the time and the first run must not require a manual
136
+ * `pugi init` step. We DO surface a stderr breadcrumb so the operator
137
+ * sees what changed on disk.
138
+ */
139
+ async function ensurePugiInitializedWithLog(cwd, stderrWrite) {
140
+ const pugiDir = resolvePath(cwd, '.pugi');
141
+ if (existsSync(pugiDir) && statSync(pugiDir).isDirectory())
142
+ return;
143
+ // Late-import so the CLI bootstrap cost stays minimal — scaffold is
144
+ // only loaded when the auto-init path actually fires.
145
+ const { scaffoldPugiWorkspace } = await import('./cli.js');
146
+ stderrWrite(`pugi headless: scaffolding .pugi/ in ${cwd}\n`);
147
+ await scaffoldPugiWorkspace({
148
+ cwd,
149
+ noDefaults: true,
150
+ log: (line) => stderrWrite(`[init] ${line}`),
151
+ });
152
+ }
153
+ /**
154
+ * Resolve the runtime credential. Headless mode REQUIRES a token — we
155
+ * cannot launch a device flow without a TTY to drop a browser into. A
156
+ * missing credential is a hard error with exit 1.
157
+ */
158
+ function resolveHeadlessCredential() {
159
+ const credential = resolveActiveCredential();
160
+ if (credential) {
161
+ return buildRuntimeConfig({
162
+ apiUrl: credential.apiUrl,
163
+ apiKey: credential.apiKey,
164
+ });
165
+ }
166
+ // Fall through to env-only config (CI sets PUGI_API_KEY directly).
167
+ return loadRuntimeConfig();
168
+ }
169
+ /**
170
+ * Headless entry point. Returns the desired process exit code.
171
+ *
172
+ * 0 — done / completed
173
+ * 1 — hard error (auth, engine_unavailable, transport crash)
174
+ * 2 — turn-limit / budget exhausted
175
+ *
176
+ * Caller (cli.ts) sets `process.exitCode`; we do NOT call `process.exit`
177
+ * directly so a future REPL-embedded `/print` slash can reuse the same
178
+ * function in-process without tearing down the host.
179
+ */
180
+ export async function runHeadlessPrint(opts) {
181
+ const cwd = resolvePath(opts.cwd);
182
+ // Default writers route to the real process streams; tests inject
183
+ // closures so `node:test`'s binary-IPC hijack of `process.stdout`
184
+ // does not collide with the captured NDJSON output.
185
+ const stdoutWrite = opts.stdoutWrite ?? ((chunk) => process.stdout.write(chunk));
186
+ const stderrWrite = opts.stderrWrite ?? ((chunk) => process.stderr.write(chunk));
187
+ const sink = opts.json
188
+ ? new NdjsonEventSink(stdoutWrite)
189
+ : new TextEventSink(stdoutWrite, stderrWrite);
190
+ const startedAt = Date.now();
191
+ // 1. Auto-init the workspace if needed.
192
+ try {
193
+ await ensurePugiInitializedWithLog(cwd, stderrWrite);
194
+ }
195
+ catch (error) {
196
+ const message = error instanceof Error ? error.message : String(error);
197
+ sink.emit({
198
+ type: 'error',
199
+ kind: 'init_failed',
200
+ message: `failed to scaffold .pugi/: ${message}`,
201
+ exit_code: 1,
202
+ });
203
+ return 1;
204
+ }
205
+ // 2. Resolve the credential. Headless cannot interactively log in.
206
+ // When a test factory is injected we still build a config (the
207
+ // factory ignores it) so the type contract stays narrow.
208
+ let config;
209
+ if (opts.engineClientFactory) {
210
+ config =
211
+ loadRuntimeConfig() ??
212
+ buildRuntimeConfig({
213
+ apiUrl: 'http://127.0.0.1:1/headless-fixture',
214
+ apiKey: 'pugi_headless_fixture',
215
+ });
216
+ }
217
+ else {
218
+ config = resolveHeadlessCredential();
219
+ }
220
+ if (!config) {
221
+ sink.emit({
222
+ type: 'error',
223
+ kind: 'auth_missing',
224
+ message: 'No Pugi credential. Run `pugi login` or set PUGI_API_KEY before invoking `pugi --print` (headless mode cannot launch device flow without a TTY).',
225
+ exit_code: 1,
226
+ });
227
+ return 1;
228
+ }
229
+ // 3. Open a session. The session id flows through every event line
230
+ // so external tooling can correlate the headless run with its
231
+ // server-side trace.
232
+ const session = openSession(cwd);
233
+ if (opts.sessionIdOverride && opts.sessionIdOverride.length > 0) {
234
+ // Override is informational — we cannot reassign `session.id`
235
+ // safely (it threads through `recordToolCall`), but we surface
236
+ // the requested id alongside the resolved one so the caller can
237
+ // join its own logs.
238
+ sink.emit({
239
+ type: 'session.start',
240
+ sessionId: session.id,
241
+ requestedSessionId: opts.sessionIdOverride,
242
+ cwd,
243
+ ...(opts.workspace ? { workspace: opts.workspace } : {}),
244
+ });
245
+ }
246
+ else {
247
+ sink.emit({
248
+ type: 'session.start',
249
+ sessionId: session.id,
250
+ cwd,
251
+ ...(opts.workspace ? { workspace: opts.workspace } : {}),
252
+ });
253
+ }
254
+ recordCommandStarted(session, 'print');
255
+ // 4. Construct the engine client + adapter. The factory seam exists
256
+ // purely for tests; production always builds `AnvilEngineLoopClient`.
257
+ const client = opts.engineClientFactory
258
+ ? opts.engineClientFactory(config)
259
+ : new AnvilEngineLoopClient(config);
260
+ // MCP registry: best-effort load. Headless mode honors the workspace
261
+ // MCP config the same way the REPL does — a failed registry should
262
+ // not kill the run, but the operator deserves a stderr breadcrumb.
263
+ let mcpRegistry;
264
+ try {
265
+ mcpRegistry = await loadMcpRegistry(cwd);
266
+ }
267
+ catch (error) {
268
+ stderrWrite(`pugi headless: MCP registry load failed — ${error.message}. Continuing without MCP.\n`);
269
+ mcpRegistry = undefined;
270
+ }
271
+ const adapter = new NativePugiEngineAdapter({
272
+ client,
273
+ session,
274
+ // --no-tools maps to suppressing fetch + search opt-ins. The
275
+ // tool-bridge schema is determined per-kind; we cannot dial it to
276
+ // zero from the adapter constructor today (that requires a
277
+ // tool-bridge plumbing change). For β1 we honor --no-tools by
278
+ // refusing the engine path entirely when set AND the kind is one
279
+ // that requires tools to be useful — see the gate below.
280
+ allowFetch: !opts.noTools,
281
+ allowSearch: !opts.noTools,
282
+ ...(mcpRegistry ? { mcpRegistry } : {}),
283
+ mcpPrompt: defaultNonInteractiveMcpPrompt,
284
+ interactive: false,
285
+ });
286
+ // 5. Subscribe to the adapter's typed stream emitter and forward
287
+ // every event as one NDJSON line. The emitter is the canonical
288
+ // source for tool / text / thinking deltas — the async-iterable
289
+ // `adapter.run()` collapses richer types into lossy `status` for
290
+ // legacy SDK consumers, which we deliberately bypass here.
291
+ let turnIndex = 0;
292
+ let currentTurnTokensIn = 0;
293
+ let currentTurnTokensOut = 0;
294
+ let totalTokens = 0;
295
+ const filesChanged = new Set();
296
+ const sawTurnStart = false; // tracked via emitter
297
+ const handleStreamEvent = (event) => {
298
+ switch (event.type) {
299
+ case 'status': {
300
+ // Status frames carry adapter lifecycle prose. Most are noise
301
+ // for an NDJSON consumer; the structurally meaningful ones we
302
+ // synthesize into typed events below. A few worth surfacing:
303
+ // `turn N: requesting model` → emit turn.start.
304
+ const m = /^turn (\d+): requesting model/.exec(event.message);
305
+ if (m) {
306
+ turnIndex = Number(m[1]);
307
+ sink.emit({ type: 'turn.start', turnIndex, timestamp: event.timestamp });
308
+ return;
309
+ }
310
+ const t = /^turn (\d+): model returned final text/.exec(event.message);
311
+ if (t) {
312
+ // Closing turn.end is emitted on result yield below so we
313
+ // have token usage to attach. Skip the synthetic close here.
314
+ return;
315
+ }
316
+ // All other status frames are diagnostic — surface as a
317
+ // structured `status` event so consumers can opt in.
318
+ sink.emit({
319
+ type: 'status',
320
+ message: event.message,
321
+ timestamp: event.timestamp,
322
+ });
323
+ return;
324
+ }
325
+ case 'tool.start':
326
+ sink.emit({
327
+ type: 'tool.call',
328
+ callId: event.callId,
329
+ tool: event.name,
330
+ args: safeJsonParse(event.arguments),
331
+ timestamp: event.timestamp,
332
+ });
333
+ return;
334
+ case 'tool.end':
335
+ sink.emit({
336
+ type: 'tool.result',
337
+ callId: event.callId,
338
+ status: event.ok ? 'success' : 'error',
339
+ summary: event.summary,
340
+ timestamp: event.timestamp,
341
+ });
342
+ return;
343
+ case 'tool.delta':
344
+ // Pass through as a partial chunk so a long-running bash can
345
+ // surface progress.
346
+ sink.emit({
347
+ type: 'tool.delta',
348
+ callId: event.callId,
349
+ chunk: event.chunk,
350
+ timestamp: event.timestamp,
351
+ });
352
+ return;
353
+ case 'text.delta':
354
+ sink.emit({
355
+ type: 'text.delta',
356
+ content: event.chunk,
357
+ timestamp: event.timestamp,
358
+ });
359
+ return;
360
+ case 'thinking.start':
361
+ sink.emit({
362
+ type: 'thinking.start',
363
+ blockId: event.blockId,
364
+ timestamp: event.timestamp,
365
+ });
366
+ return;
367
+ case 'thinking.delta':
368
+ sink.emit({
369
+ type: 'thinking.delta',
370
+ blockId: event.blockId,
371
+ content: event.chunk,
372
+ timestamp: event.timestamp,
373
+ });
374
+ return;
375
+ case 'thinking.end':
376
+ sink.emit({
377
+ type: 'thinking.end',
378
+ blockId: event.blockId,
379
+ timestamp: event.timestamp,
380
+ });
381
+ return;
382
+ default:
383
+ return;
384
+ }
385
+ };
386
+ adapter.streamEmitter.on('event', handleStreamEvent);
387
+ // 6. Optional timeout — wire an AbortController so the engine loop
388
+ // bails cleanly. The signal also feeds the tool-bridge cancellation
389
+ // gate so long-running bash / network tools tear down.
390
+ const abort = new AbortController();
391
+ let timeoutHandle = null;
392
+ if (opts.timeoutSeconds && opts.timeoutSeconds > 0) {
393
+ timeoutHandle = setTimeout(() => {
394
+ abort.abort();
395
+ stderrWrite(`pugi headless: timeout after ${opts.timeoutSeconds}s — aborting engine\n`);
396
+ }, opts.timeoutSeconds * 1000);
397
+ // Don't keep node alive purely for the timeout — the engine path
398
+ // already holds the event loop via the AnvilEngineLoopClient fetch.
399
+ timeoutHandle.unref?.();
400
+ }
401
+ // 7. Drive the adapter to terminal. We iterate the (lossier)
402
+ // EngineEvent stream solely to learn when the result frame lands;
403
+ // every richer event already flew through `streamEmitter` above.
404
+ const kind = opts.kind ?? 'code';
405
+ const taskId = `print-${Date.now()}`;
406
+ let finalStatus = 'failed';
407
+ let finalSummary = '';
408
+ let resultRisks = [];
409
+ let eventRefs = [];
410
+ try {
411
+ const events = adapter.run({
412
+ id: taskId,
413
+ kind,
414
+ prompt: opts.prompt,
415
+ workspaceRoot: cwd,
416
+ allowedPaths: [cwd],
417
+ deniedPaths: [],
418
+ artifacts: [],
419
+ permissionMode: 'auto',
420
+ ...(opts.maxTurns ? { budget: { tokens: opts.maxTurns * 16384 } } : {}),
421
+ }, { sessionId: session.id, signal: abort.signal });
422
+ for await (const ev of events) {
423
+ if (ev.type === 'result') {
424
+ finalStatus = ev.result.status;
425
+ finalSummary = ev.result.summary;
426
+ resultRisks = ev.result.risks;
427
+ eventRefs = ev.result.eventRefs;
428
+ for (const f of ev.result.filesChanged)
429
+ filesChanged.add(f);
430
+ }
431
+ // status frames already routed through the emitter — ignore here.
432
+ }
433
+ }
434
+ catch (error) {
435
+ const message = error instanceof Error ? error.message : String(error);
436
+ sink.emit({
437
+ type: 'error',
438
+ kind: 'engine_crash',
439
+ message,
440
+ exit_code: 1,
441
+ });
442
+ if (timeoutHandle)
443
+ clearTimeout(timeoutHandle);
444
+ adapter.streamEmitter.off('event', handleStreamEvent);
445
+ recordCommandCompleted(session, 'print', 'error');
446
+ return 1;
447
+ }
448
+ if (timeoutHandle)
449
+ clearTimeout(timeoutHandle);
450
+ adapter.streamEmitter.off('event', handleStreamEvent);
451
+ if (mcpRegistry) {
452
+ await mcpRegistry.shutdown().catch(() => {
453
+ /* swallow — best-effort */
454
+ });
455
+ }
456
+ // 8. Pull headline metrics out of the result's eventRefs (adapter
457
+ // echoes `tokens=N` / `turns=N` / `tool_calls=N` / `outcome=...`).
458
+ const metrics = parseRefs(eventRefs);
459
+ totalTokens = metrics.tokens;
460
+ currentTurnTokensIn;
461
+ currentTurnTokensOut;
462
+ sawTurnStart; // silence TS6133
463
+ // Emit one closing turn.end so consumers always see a paired turn
464
+ // start/end. We use the final turn index reported by the adapter.
465
+ sink.emit({
466
+ type: 'turn.end',
467
+ turnIndex: metrics.turns || turnIndex || 1,
468
+ usage: {
469
+ // Engine outcome carries a single cumulative `tokens` figure; we
470
+ // split it 50/50 as a best-effort estimate until the SDK splits
471
+ // input/output. Documented as approximate so consumers do not
472
+ // treat the breakdown as authoritative.
473
+ tokensIn: Math.floor(totalTokens / 2),
474
+ tokensOut: Math.ceil(totalTokens / 2),
475
+ },
476
+ });
477
+ // 9. Emit session.end. Exit code policy per spec:
478
+ // - done → 0
479
+ // - blocked + outcome=budget_exhausted → 2
480
+ // - blocked + any other reason → 2 (turn limit, tool refused — both
481
+ // count as "did not complete")
482
+ // - failed → 1
483
+ const exitCode = finalStatus === 'done'
484
+ ? 0
485
+ : finalStatus === 'blocked'
486
+ ? 2
487
+ : 1;
488
+ const durationMs = Date.now() - startedAt;
489
+ sink.emit({
490
+ type: 'session.end',
491
+ sessionId: session.id,
492
+ status: finalStatus,
493
+ outcome: metrics.outcome,
494
+ totalTokensIn: Math.floor(totalTokens / 2),
495
+ totalTokensOut: Math.ceil(totalTokens / 2),
496
+ totalToolCalls: metrics.toolCalls,
497
+ totalTurns: metrics.turns,
498
+ durationMs,
499
+ filesChanged: Array.from(filesChanged).sort(),
500
+ risks: resultRisks,
501
+ finalText: finalSummary,
502
+ });
503
+ recordCommandCompleted(session, 'print', exitCode === 0 ? 'success' : 'error');
504
+ return exitCode;
505
+ }
506
+ /**
507
+ * Best-effort JSON parse for tool arguments. Engine emits the raw
508
+ * argument string the model produced; if it is malformed we keep the
509
+ * raw string so the consumer can still see what the model sent.
510
+ */
511
+ function safeJsonParse(s) {
512
+ try {
513
+ return JSON.parse(s);
514
+ }
515
+ catch {
516
+ return s;
517
+ }
518
+ }
519
+ /**
520
+ * Parse the `eventRefs` echo the adapter attaches to its result frame.
521
+ * Mirrors `parseEventRefs` in `cli.ts` — kept local so the headless
522
+ * module has no circular import on the runtime entry.
523
+ */
524
+ function parseRefs(refs) {
525
+ const out = { toolCalls: 0, turns: 0, tokens: 0, outcome: null };
526
+ for (const ref of refs) {
527
+ const idx = ref.indexOf('=');
528
+ if (idx <= 0)
529
+ continue;
530
+ const key = ref.slice(0, idx);
531
+ const value = ref.slice(idx + 1);
532
+ if (key === 'tool_calls')
533
+ out.toolCalls = Number(value) || 0;
534
+ else if (key === 'turns')
535
+ out.turns = Number(value) || 0;
536
+ else if (key === 'tokens')
537
+ out.tokens = Number(value) || 0;
538
+ else if (key === 'outcome')
539
+ out.outcome = value || null;
540
+ }
541
+ return out;
542
+ }
543
+ //# sourceMappingURL=headless.js.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Fail-closed hook registry loader.
3
+ *
4
+ * r2 (triple-review 2026-05-26 P2): the previous fail-open path silently
5
+ * disabled BLOCK rules when `.pugi/hooks.json` parsed badly. This module
6
+ * is the dedicated load path used by the engine command dispatch — it
7
+ * distinguishes three states:
8
+ *
9
+ * (a) No hooks file present → returns `undefined` (acceptable; the
10
+ * operator has no hooks configured).
11
+ * (b) Hooks file present + parses + loads → returns the live registry.
12
+ * (c) Hooks file present + parse/load FAILS → emits a fatal message
13
+ * and signals `refusedExit`. The caller (cli.ts) is expected to
14
+ * honor that by calling `process.exit(1)`. `PUGI_HOOKS_BYPASS=1`
15
+ * is the explicit operator-typed escape hatch.
16
+ *
17
+ * Extracted from `cli.ts` purely so the fail-closed contract is unit-
18
+ * testable without spawning a subprocess. The shape returned is a
19
+ * discriminated union so tests can assert each branch directly.
20
+ *
21
+ * Brand voice: no forbidden words. ASCII only. No emoji.
22
+ */
23
+ import { existsSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { resolve } from 'node:path';
26
+ import { HookRegistry } from '../core/hooks.js';
27
+ export async function loadHookRegistryOrExit(opts) {
28
+ const stderrWrite = opts.deps?.stderrWrite ??
29
+ ((msg) => {
30
+ process.stderr.write(msg);
31
+ });
32
+ const env = opts.deps?.env ?? process.env;
33
+ const projectHooksPath = resolve(opts.workspaceRoot, '.pugi', 'hooks.json');
34
+ const pugiHomeRoot = env.PUGI_HOME ?? resolve(homedir(), '.pugi');
35
+ const userHooksPath = resolve(pugiHomeRoot, 'hooks.json');
36
+ const presentHookFiles = [projectHooksPath, userHooksPath].filter((p) => existsSync(p));
37
+ if (presentHookFiles.length === 0) {
38
+ return { kind: 'no-hooks-file', hooks: undefined };
39
+ }
40
+ try {
41
+ const reg = new HookRegistry({
42
+ workspaceRoot: opts.workspaceRoot,
43
+ session: opts.session,
44
+ });
45
+ await reg.load();
46
+ return { kind: 'loaded', hooks: reg };
47
+ }
48
+ catch (error) {
49
+ const msg = error.message;
50
+ stderrWrite(`pugi ${opts.label}: hook configuration present but failed to load — refusing to run.\n` +
51
+ ` files checked: ${presentHookFiles.join(', ')}\n` +
52
+ ` error: ${msg}\n` +
53
+ ` fix: validate the JSON or remove the file; or set PUGI_HOOKS_BYPASS=1 to override (NOT RECOMMENDED — disables block rules).\n`);
54
+ if (env.PUGI_HOOKS_BYPASS === '1') {
55
+ stderrWrite(`pugi ${opts.label}: PUGI_HOOKS_BYPASS=1 — continuing WITHOUT hooks despite parse failure.\n`);
56
+ return {
57
+ kind: 'parse-failure-bypassed',
58
+ hooks: undefined,
59
+ filesChecked: presentHookFiles,
60
+ error: msg,
61
+ };
62
+ }
63
+ return {
64
+ kind: 'parse-failure-refused',
65
+ filesChecked: presentHookFiles,
66
+ error: msg,
67
+ hooks: undefined,
68
+ };
69
+ }
70
+ }
71
+ //# sourceMappingURL=load-hooks-or-exit.js.map