@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
@@ -18,6 +18,98 @@ export function openSession(root) {
18
18
  enabled,
19
19
  };
20
20
  }
21
+ /**
22
+ * Leak L12 MVP — fire the `SessionStart` lifecycle event for all hooks
23
+ * declared in `~/.pugi/hooks-mvp.json`. Single-call surface; the REPL
24
+ * boot path invokes this once after `openSession`. Best-effort: any
25
+ * failure (missing config, hook spawn error) is swallowed so a
26
+ * misconfigured hook can never crash the REPL.
27
+ *
28
+ * Returns the number of hooks that fired (0 when no config / no
29
+ * matching hooks). Tests assert on the return value as the
30
+ * single-call invariant.
31
+ */
32
+ export async function fireSessionStartMvp(session) {
33
+ try {
34
+ const { loadHooksConfig, fireHooks } = await import('./hooks/index.js');
35
+ // Defense-in-depth: `loadHooksConfig` is contractually non-null
36
+ // (returns `HooksConfig.empty(path)` when the file is absent), but
37
+ // the dynamic import boundary above can in principle return an
38
+ // unexpected shape if the module is mis-resolved at runtime. Guard
39
+ // the optional-chained `isEmpty()` call so a malformed loader can
40
+ // never raise `TypeError: Cannot read properties of undefined` and
41
+ // crash the REPL boot path. Belt-and-suspenders with the
42
+ // surrounding try/catch — the catch still swallows everything else.
43
+ const config = loadHooksConfig();
44
+ if (!config || config.isEmpty())
45
+ return 0;
46
+ const outcome = await fireHooks({
47
+ config,
48
+ event: 'SessionStart',
49
+ payload: {
50
+ event: 'SessionStart',
51
+ sessionId: session.id,
52
+ workspaceRoot: session.root,
53
+ startedAt: new Date().toISOString(),
54
+ },
55
+ workspaceRoot: session.root,
56
+ });
57
+ return outcome.results.length;
58
+ }
59
+ catch {
60
+ // SessionStart is never blocking — log nothing, return 0. A
61
+ // broken `hooks-mvp.json` is surfaced via `pugi hooks doctor`.
62
+ return 0;
63
+ }
64
+ }
65
+ /**
66
+ * Wave 7 P1 — fire the v2 `SessionStart` event from `~/.pugi/hooks.json`
67
+ * (global) + `<workspaceRoot>/.pugi/hooks.json` (project). Companion to
68
+ * `fireSessionStartMvp`; both surfaces run because they read different
69
+ * config files.
70
+ *
71
+ * Headless by default (no trust prompt) — the v2 trust ledger gates
72
+ * first-run executions. Operators with no prior trust decision will see
73
+ * the SessionStart hook skipped with a `denied by trust ledger` stderr
74
+ * note; running `pugi hooks trust allow <command>` enrolls it.
75
+ *
76
+ * Returns the number of hooks that ran (excluding trust-denied skips).
77
+ * Never throws.
78
+ */
79
+ export async function fireSessionStartV2(session) {
80
+ try {
81
+ const { fireSessionStart } = await import('./hooks/v2/index.js');
82
+ const outcome = await fireSessionStart({
83
+ sessionId: session.id,
84
+ workspaceRoot: session.root,
85
+ transcriptPath: session.eventsPath,
86
+ permissionMode: 'ask',
87
+ });
88
+ return outcome.results.filter((r) => r.exitCode !== -1).length;
89
+ }
90
+ catch {
91
+ return 0;
92
+ }
93
+ }
94
+ /**
95
+ * Wave 7 P1 — fire the v2 `SessionEnd` event. Called by the REPL
96
+ * teardown path. Companion to `fireSessionStartV2`.
97
+ */
98
+ export async function fireSessionEndV2(session) {
99
+ try {
100
+ const { fireSessionEnd } = await import('./hooks/v2/index.js');
101
+ const outcome = await fireSessionEnd({
102
+ sessionId: session.id,
103
+ workspaceRoot: session.root,
104
+ transcriptPath: session.eventsPath,
105
+ permissionMode: 'ask',
106
+ });
107
+ return outcome.results.filter((r) => r.exitCode !== -1).length;
108
+ }
109
+ catch {
110
+ return 0;
111
+ }
112
+ }
21
113
  export function recordCommandStarted(session, command) {
22
114
  if (!session.enabled)
23
115
  return;
@@ -28,6 +28,17 @@ const pugiSettingsSchema = z.object({
28
28
  telemetry: z.enum(['off', 'anonymous', 'community']).default('off'),
29
29
  })
30
30
  .default({}),
31
+ // beta.13 P1 fix 2026-05-26: ui.cyberZoo gates the cyber-zoo splash +
32
+ // ambient art in the REPL. Schema must declare the key explicitly
33
+ // because Zod's strip pass swallows unknown keys, which is how the
34
+ // initial `pugi init` write (which serialises `ui.cyberZoo`) was
35
+ // bypassed by the runtime reader — the value never made it past the
36
+ // schema gate so admin-api always saw the historical 'on' default.
37
+ ui: z
38
+ .object({
39
+ cyberZoo: z.enum(['on', 'off']).default('on'),
40
+ })
41
+ .default({}),
31
42
  artifacts: z
32
43
  .object({
33
44
  defaultPath: z.string().default('.pugi/artifacts'),
@@ -38,6 +49,12 @@ const pugiSettingsSchema = z.object({
38
49
  // fetcher. Default-off matches the spec posture; the schema must
39
50
  // declare it explicitly because Zod's strict-pass strips unknown
40
51
  // keys and would silently swallow the operator's intent.
52
+ //
53
+ // β1b T4 (2026-05-26): added `web.search.enabled` to gate the
54
+ // Brave-Search-backed `web_search` tool. Distinct from `web.fetch`
55
+ // because search queries themselves are an egress event that can
56
+ // leak operator intent — an operator may want fetch without
57
+ // implicitly enabling search-as-egress.
41
58
  web: z
42
59
  .object({
43
60
  fetch: z
@@ -45,6 +62,69 @@ const pugiSettingsSchema = z.object({
45
62
  enabled: z.boolean().optional(),
46
63
  })
47
64
  .optional(),
65
+ search: z
66
+ .object({
67
+ enabled: z.boolean().optional(),
68
+ })
69
+ .optional(),
70
+ })
71
+ .optional(),
72
+ // β7 L9 — per-language LSP toggle. When omitted, every supported
73
+ // server is available subject to binary detection on PATH. When
74
+ // present, only languages set to `true` are launched (false silently
75
+ // skips that language even if the binary is installed). Use this in
76
+ // workspaces where a heavyweight server (rust-analyzer indexing a
77
+ // monorepo, pyright on a fresh venv) wastes resources for the
78
+ // current task. The `pugi lsp servers` subcommand surfaces the
79
+ // current toggle state per server.
80
+ //
81
+ // Schema is intentionally permissive (`optional()` on the section AND
82
+ // on every per-language flag) so a partial config keeps the
83
+ // backwards-compatible "every language enabled" default.
84
+ lsp: z
85
+ .object({
86
+ typescript: z.boolean().optional(),
87
+ javascript: z.boolean().optional(),
88
+ python: z.boolean().optional(),
89
+ go: z.boolean().optional(),
90
+ rust: z.boolean().optional(),
91
+ // Leak L15 (2026-05-27): post-edit auto-diagnostics. When `true`,
92
+ // a successful `edit`/`write`/`multi_edit` triggers a diagnostic
93
+ // pull on the touched file(s) and the result is appended to the
94
+ // tool envelope so the model can self-correct in the same turn.
95
+ // Off by default — the cold-start of `typescript-language-server`
96
+ // is heavy enough that we opt in explicitly until dogfood proves
97
+ // the throughput trade is worth it. Also enabled via env var
98
+ // `PUGI_LSP_POST_EDIT=1` for CI / one-off operator probes.
99
+ postEditDiagnostics: z.boolean().optional(),
100
+ })
101
+ .optional(),
102
+ // β1 Pl9 (#74) — per-command budget overrides. Optional. Partial
103
+ // overrides merge against the β1 defaults in
104
+ // `core/engine/budgets.ts::beta1DefaultBudgets`. The schema is
105
+ // intentionally loose at the leaf (positive integers) so a typo lands
106
+ // a deterministic `BudgetConfigError` at `resolveBudget()` instead of
107
+ // a Zod parse error two layers up.
108
+ budgets: z
109
+ .object({
110
+ code: z
111
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
112
+ .optional(),
113
+ fix: z
114
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
115
+ .optional(),
116
+ build: z
117
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
118
+ .optional(),
119
+ plan: z
120
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
121
+ .optional(),
122
+ explain: z
123
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
124
+ .optional(),
125
+ review_triple: z
126
+ .object({ maxTokens: z.number().int().positive().optional(), maxToolCalls: z.number().int().positive().optional() })
127
+ .optional(),
48
128
  })
49
129
  .optional(),
50
130
  });
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Markdown transcript formatter used by `pugi share` (Leak L20, 2026-05-27).
3
+ *
4
+ * Walks the session's `.pugi/events.jsonl` audit log and reconstructs a
5
+ * Markdown document the operator (and downstream Gist / pugi.io readers)
6
+ * can read top-to-bottom. The format is intentionally human-first — turn
7
+ * headers, code-block-wrapped tool I/O, and a small front-matter block
8
+ * with session metadata. Machine-readability is a non-goal here; the
9
+ * JSONL log remains the source of truth for tooling.
10
+ *
11
+ * Why we reconstruct from JSONL instead of from the live REPL state:
12
+ *
13
+ * - The CLI top-level `pugi share` command runs from a fresh shell and
14
+ * has no in-memory REPL state to read; only the event log is
15
+ * persisted.
16
+ * - The in-REPL `/share` slash uses the same handler so behaviour is
17
+ * identical regardless of entry point. Operators sharing a session
18
+ * from inside the REPL get the exact same output they would get from
19
+ * a follow-up shell command.
20
+ * - The JSONL log is append-only and survives REPL crashes, so a
21
+ * `--share` after a crash is the most useful debug surface.
22
+ *
23
+ * Event vocabulary the formatter knows about (see
24
+ * `packages/pugi-sdk/src/audit-trace.ts` for the schema):
25
+ *
26
+ * session.created Session boundary; emits front matter.
27
+ * session.command_started One-line "running pugi <cmd>" header.
28
+ * session.command_completed Status line ("success" / "error").
29
+ * tool_call Markdown turn header + inputSummary
30
+ * rendered as a fenced block.
31
+ * tool_result "Result (status):" + outputSummary
32
+ * rendered as a fenced block.
33
+ * file_mutation Inline `path` + operation summary.
34
+ * subagent.* Indented "[subagent <role>] ..." line.
35
+ * hook.* Quiet "[hook <event>] ..." line.
36
+ * compaction.* "[compaction <tier>] ..." line.
37
+ *
38
+ * Unknown event types are surfaced as a single italic line ("[event
39
+ * type=...]") so a future event added to the SDK does not silently
40
+ * vanish from the transcript.
41
+ *
42
+ * Performance: the formatter is O(n) over the events file, runs entirely
43
+ * in memory, and is bounded by the session log size (currently capped at
44
+ * a few MB per session). No streaming I/O is needed for typical sessions
45
+ * — the operator does not run /share against multi-GB logs.
46
+ */
47
+ /**
48
+ * Format a session's event log as Markdown. Pure — no I/O. The caller
49
+ * reads `.pugi/events.jsonl` and hands the contents in.
50
+ */
51
+ export function formatTranscript(input) {
52
+ const now = input.now ? input.now() : new Date();
53
+ const events = parseEvents(input.eventsJsonl);
54
+ const filteredSessionId = pickSessionId(input.sessionId, events);
55
+ const sessionEvents = events.filter((e) => typeof e.raw.sessionId !== 'string' || e.raw.sessionId === filteredSessionId);
56
+ const lines = [];
57
+ // Front matter — a fenced block at the top so downstream readers can
58
+ // grok the context before any turn content. Not YAML front matter
59
+ // (`---`) because Gist + pugi.io render Markdown directly; the fenced
60
+ // approach renders predictably without a parser.
61
+ lines.push('# Pugi session transcript');
62
+ lines.push('');
63
+ lines.push('```');
64
+ lines.push(`session_id: ${filteredSessionId}`);
65
+ lines.push(`workspace: ${input.workspaceRoot}`);
66
+ lines.push(`cli_version: ${input.cliVersion}`);
67
+ lines.push(`exported_at: ${now.toISOString()}`);
68
+ lines.push(`event_count: ${sessionEvents.length}`);
69
+ lines.push('```');
70
+ lines.push('');
71
+ if (sessionEvents.length === 0) {
72
+ lines.push('_No events recorded for this session._');
73
+ return {
74
+ markdown: `${lines.join('\n')}\n`,
75
+ turnCount: 0,
76
+ eventCount: 0,
77
+ };
78
+ }
79
+ let turnCount = 0;
80
+ for (const event of sessionEvents) {
81
+ const rendered = renderEvent(event);
82
+ if (rendered === null)
83
+ continue;
84
+ lines.push(...rendered.lines);
85
+ lines.push('');
86
+ if (rendered.isTurn)
87
+ turnCount += 1;
88
+ }
89
+ return {
90
+ markdown: `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`,
91
+ turnCount,
92
+ eventCount: sessionEvents.length,
93
+ };
94
+ }
95
+ /**
96
+ * Parse the JSONL log. Malformed lines are skipped silently — the file
97
+ * is append-only and may have partial-write tail rows. Returning the
98
+ * stable typed shape lets the formatter walk without re-checking every
99
+ * field.
100
+ */
101
+ function parseEvents(raw) {
102
+ const out = [];
103
+ for (const line of raw.split('\n')) {
104
+ const trimmed = line.trim();
105
+ if (trimmed.length === 0)
106
+ continue;
107
+ try {
108
+ const parsed = JSON.parse(trimmed);
109
+ const type = typeof parsed.type === 'string' ? parsed.type : '';
110
+ const timestamp = typeof parsed.timestamp === 'string' ? parsed.timestamp : '';
111
+ if (type.length === 0)
112
+ continue;
113
+ out.push({ raw: parsed, type, timestamp });
114
+ }
115
+ catch {
116
+ // partial-write or corrupt row; skip without affecting the rest
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * Resolve the effective session id. When the operator passes a non-empty
123
+ * value we honour it. When they pass an empty string / placeholder
124
+ * (`'no-session'`), we fall back to the newest `session.created` event
125
+ * id in the file. Last-resort fallback is the literal placeholder so the
126
+ * transcript still renders something meaningful in the front matter.
127
+ */
128
+ function pickSessionId(provided, events) {
129
+ if (provided && provided !== 'no-session')
130
+ return provided;
131
+ for (let i = events.length - 1; i >= 0; i -= 1) {
132
+ const e = events[i];
133
+ if (!e)
134
+ continue;
135
+ if (e.type === 'session' && e.raw.name === 'created' && typeof e.raw.sessionId === 'string') {
136
+ return e.raw.sessionId;
137
+ }
138
+ }
139
+ return provided || 'unknown-session';
140
+ }
141
+ /**
142
+ * Format one event as a Markdown block. Returns `null` to suppress the
143
+ * event entirely (e.g. session.created is captured in front matter so we
144
+ * skip it here).
145
+ */
146
+ function renderEvent(event) {
147
+ const ts = event.timestamp || '';
148
+ switch (event.type) {
149
+ case 'session': {
150
+ const name = String(event.raw.name ?? '');
151
+ if (name === 'created')
152
+ return null; // captured by front matter
153
+ if (name === 'command_started') {
154
+ const command = String(event.raw.command ?? '');
155
+ return {
156
+ lines: [`## ${ts} — command \`${escapeInline(command)}\``],
157
+ isTurn: false,
158
+ };
159
+ }
160
+ if (name === 'command_completed') {
161
+ const command = String(event.raw.command ?? '');
162
+ const status = String(event.raw.status ?? 'unknown');
163
+ return {
164
+ lines: [`_command \`${escapeInline(command)}\` finished: ${status}_`],
165
+ isTurn: false,
166
+ };
167
+ }
168
+ return {
169
+ lines: [`_session ${name}_`],
170
+ isTurn: false,
171
+ };
172
+ }
173
+ case 'tool_call': {
174
+ const tool = String(event.raw.tool ?? 'unknown');
175
+ const summary = String(event.raw.inputSummary ?? '');
176
+ const out = [`### ${ts} — tool \`${escapeInline(tool)}\``];
177
+ if (summary.length > 0) {
178
+ out.push('');
179
+ out.push('Input:');
180
+ out.push(fenced(summary));
181
+ }
182
+ return { lines: out, isTurn: true };
183
+ }
184
+ case 'tool_result': {
185
+ const status = String(event.raw.status ?? 'unknown');
186
+ const summary = String(event.raw.outputSummary ?? '');
187
+ const out = [`Result (${status}):`];
188
+ if (summary.length > 0) {
189
+ out.push(fenced(summary));
190
+ }
191
+ return { lines: out, isTurn: false };
192
+ }
193
+ case 'file_mutation': {
194
+ const path = String(event.raw.path ?? '');
195
+ const op = String(event.raw.operation ?? '');
196
+ return {
197
+ lines: [`- file ${op}: \`${escapeInline(path)}\``],
198
+ isTurn: false,
199
+ };
200
+ }
201
+ case 'subagent.spawned':
202
+ case 'subagent.tool_call':
203
+ case 'subagent.completed':
204
+ case 'subagent.blocked':
205
+ case 'subagent.failed': {
206
+ const role = String(event.raw.role ?? '');
207
+ const persona = String(event.raw.personaSlug ?? '');
208
+ const detail = String(event.raw.detail ?? event.raw.error ?? event.raw.toolName ?? '');
209
+ const tail = detail.length > 0 ? ` ${detail}` : '';
210
+ return {
211
+ lines: [`_[subagent ${role} / ${persona}] ${event.type}${tail}_`],
212
+ isTurn: false,
213
+ };
214
+ }
215
+ case 'hook.invoked':
216
+ case 'hook.result':
217
+ case 'hook.skipped': {
218
+ const ev = String(event.raw.event ?? '');
219
+ const reason = String(event.raw.reason ?? event.raw.runSummary ?? event.raw.matchSummary ?? '');
220
+ const tail = reason.length > 0 ? ` ${reason}` : '';
221
+ return {
222
+ lines: [`_[hook ${ev}] ${event.type.replace('hook.', '')}${tail}_`],
223
+ isTurn: false,
224
+ };
225
+ }
226
+ case 'compaction.started':
227
+ case 'compaction.completed':
228
+ case 'compaction.skipped':
229
+ case 'compaction.invariant_violated': {
230
+ const tier = String(event.raw.tier ?? '');
231
+ return {
232
+ lines: [`_[compaction ${tier}] ${event.type.replace('compaction.', '')}_`],
233
+ isTurn: false,
234
+ };
235
+ }
236
+ default: {
237
+ return {
238
+ lines: [`_[event type=${event.type}]_`],
239
+ isTurn: false,
240
+ };
241
+ }
242
+ }
243
+ }
244
+ /**
245
+ * Wrap a string in a fenced code block. Pick a fence length that does
246
+ * not collide with backtick runs inside the content. Markdown 0.30 allows
247
+ * variable-length fences; we pick the shortest that is longer than the
248
+ * longest run inside the content (min 3, max 7).
249
+ */
250
+ function fenced(content) {
251
+ const longestRun = (content.match(/`+/g) ?? [])
252
+ .map((s) => s.length)
253
+ .reduce((max, n) => (n > max ? n : max), 0);
254
+ const fenceLen = Math.min(7, Math.max(3, longestRun + 1));
255
+ const fence = '`'.repeat(fenceLen);
256
+ // Trim trailing whitespace inside content so the closing fence sits
257
+ // tight against the body; preserve leading whitespace (matters for code).
258
+ return `${fence}\n${content.replace(/\s+$/u, '')}\n${fence}`;
259
+ }
260
+ /**
261
+ * Escape inline-Markdown specials (backtick, pipe) inside a span that we
262
+ * are wrapping in inline code. The closing backtick rule says a `<code>`
263
+ * span can contain backticks as long as the fence length differs — for
264
+ * simplicity we replace bare backticks with a Unicode look-alike when
265
+ * they appear in identifier-like positions (e.g. paths or tool names).
266
+ * Backticks in real content go through `fenced()` instead.
267
+ */
268
+ function escapeInline(text) {
269
+ return text.replace(/`/g, 'ˋ');
270
+ }
271
+ //# sourceMappingURL=formatter.js.map
@@ -0,0 +1,221 @@
1
+ /**
2
+ * PII redactor used by `pugi share --redact` (Leak L20, 2026-05-27).
3
+ *
4
+ * Zero-dependency regex-based redaction over a Markdown transcript. We
5
+ * intentionally do NOT pull in `apps/admin-api/src/privacy/regex-scrubber.ts`
6
+ * because the CLI is a stand-alone npm package: customers install
7
+ * `@pugi/cli` globally, no admin-api binary is present. The pattern set
8
+ * here mirrors the high-signal subset of the admin-api `RegexScrubber`
9
+ * catalog (apps/admin-api/src/privacy/regex-scrubber.ts) so audit downstream
10
+ * sees the same `[REDACTED:<CATEGORY>:<HASH8>]` token shape regardless of
11
+ * which side scrubs.
12
+ *
13
+ * Coverage (high-signal, low-false-positive):
14
+ *
15
+ * EMAIL user@example.com (RFC-5322 simplified)
16
+ * PHONE +1-555-123-4567 / (555) 123-4567 / 555 123 4567
17
+ * IPV4 1.2.3.4 with octet bounds check
18
+ * API_KEY_OPENAI sk-..., sk-proj-..., sk-svcacct-...
19
+ * API_KEY_ANTHROPIC sk-ant-...
20
+ * API_KEY_GOOGLE AIza...
21
+ * API_KEY_GITHUB ghp_/gho_/ghu_/ghs_/ghr_..., github_pat_...
22
+ * API_KEY_PUGI pugi_live_..., pugi_sk_..., anvil_*_...
23
+ * API_KEY_AWS AKIA... / ASIA...
24
+ * BEARER_TOKEN "Bearer <token>" auth headers (also used by the
25
+ * credential heuristic to refuse upload)
26
+ * JWT eyJ...header.eyJ...payload.signature
27
+ * STRIPE_ID sk_live_..., pk_live_..., whsec_...
28
+ *
29
+ * Out of scope (matches the admin-api RegexScrubber posture):
30
+ *
31
+ * - PERSON / ORG / GPE named entities (L2 NER, no CLI dep)
32
+ * - Free-form addresses
33
+ * - Date-of-birth in prose
34
+ *
35
+ * Token shape `[REDACTED:<CATEGORY>:<HASH8>]` matches the admin-api L1
36
+ * convention (SHA-256 first 8 chars of the original match). The hash is
37
+ * stable across runs so an operator who re-runs `--redact` on the same
38
+ * transcript sees identical tokens — useful for diffing two exports.
39
+ */
40
+ import { createHash } from 'node:crypto';
41
+ function hash8(text) {
42
+ return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 8);
43
+ }
44
+ function token(category, original) {
45
+ return `[REDACTED:${category}:${hash8(original)}]`;
46
+ }
47
+ /**
48
+ * IPv4 octet bounds. The catch-all `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`
49
+ * matches `999.999.999.999` and version strings like `4.5.6.7`. We reject
50
+ * any match where an octet exceeds 255. Loopback / placeholder addresses
51
+ * (`0.0.0.0`) are also rejected so config-doc snippets do not get redacted
52
+ * into noise.
53
+ */
54
+ function ipv4Valid(match) {
55
+ const parts = match.split('.');
56
+ if (parts.length !== 4)
57
+ return false;
58
+ for (const p of parts) {
59
+ const n = Number.parseInt(p, 10);
60
+ if (Number.isNaN(n) || n < 0 || n > 255)
61
+ return false;
62
+ }
63
+ if (match === '0.0.0.0')
64
+ return false;
65
+ return true;
66
+ }
67
+ /**
68
+ * Catalog. Order matters: prefixed API-key rules first so the broader
69
+ * `sk-` pattern does not shadow `sk-ant-` / `sk-proj-`. JWT before
70
+ * BEARER_TOKEN so a `Bearer eyJ...` header redacts the JWT specifically
71
+ * rather than the generic bearer prefix.
72
+ */
73
+ const RULES = [
74
+ // Stripe IDs (livemode + testmode). Catches the secret-key form too;
75
+ // operators paste these into chats more often than they should.
76
+ {
77
+ category: 'STRIPE_ID',
78
+ pattern: /\b(?:cus|sub|pi|ch|acct|seti|prod|price|in|re|whsec|sk_live|sk_test|pk_live|pk_test)_[A-Za-z0-9]{14,}\b/g,
79
+ },
80
+ // Pugi / Anvil API keys.
81
+ {
82
+ category: 'API_KEY_PUGI',
83
+ pattern: /\b(?:pugi|anvil)_(?:live|test|sk)_[A-Za-z0-9_-]{20,}\b/g,
84
+ },
85
+ // Anthropic API keys.
86
+ {
87
+ category: 'API_KEY_ANTHROPIC',
88
+ pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
89
+ },
90
+ // OpenAI API keys (classic sk-, project-scoped sk-proj-, service-acct
91
+ // sk-svcacct-).
92
+ {
93
+ category: 'API_KEY_OPENAI',
94
+ pattern: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}\b/g,
95
+ },
96
+ // Google API keys (Maps, Gemini, Cloud).
97
+ {
98
+ category: 'API_KEY_GOOGLE',
99
+ pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g,
100
+ },
101
+ // GitHub PATs (classic + fine-grained).
102
+ {
103
+ category: 'API_KEY_GITHUB',
104
+ pattern: /\b(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b/g,
105
+ },
106
+ // AWS access keys.
107
+ {
108
+ category: 'API_KEY_AWS',
109
+ pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
110
+ },
111
+ // JWT (3-segment dot-delimited base64url).
112
+ {
113
+ category: 'JWT',
114
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
115
+ },
116
+ // Bearer token. The credential heuristic in `containsActiveCredential`
117
+ // ALSO fires on this prefix to refuse the upload entirely.
118
+ {
119
+ category: 'BEARER_TOKEN',
120
+ pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{16,}/g,
121
+ },
122
+ // Email.
123
+ {
124
+ category: 'EMAIL',
125
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
126
+ },
127
+ // E.164 + permissive US/EU phone. International prefix optional;
128
+ // separators allowed (-, space, parens).
129
+ {
130
+ category: 'PHONE',
131
+ pattern: /(?<![A-Za-z0-9.])(?:\+?\d{1,3}[\s-])?(?:\(\d{1,4}\)\s?)?\d{2,4}[\s-]\d{2,4}(?:[\s-]\d{2,9})?(?![A-Za-z0-9.])/g,
132
+ validate: (m) => {
133
+ const digits = m.replace(/\D+/g, '');
134
+ return digits.length >= 7 && digits.length <= 15;
135
+ },
136
+ },
137
+ // IPv4 with bounds check. Order: AFTER all alphanumeric-prefixed rules
138
+ // so a version string like `4.5.6.7` inside a longer SHA-key match
139
+ // never reaches us here.
140
+ {
141
+ category: 'IPV4',
142
+ pattern: /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
143
+ validate: ipv4Valid,
144
+ },
145
+ ];
146
+ /**
147
+ * Redact PII from a Markdown transcript. The output substitutes high-
148
+ * signal patterns with `[REDACTED:<CATEGORY>:<HASH8>]` tokens. Findings
149
+ * are aggregated by category so the privacy gate can surface a
150
+ * compact "Redacted 3 PII spans (2 EMAIL, 1 API_KEY_OPENAI)" line.
151
+ *
152
+ * Idempotency: re-running over an already-redacted transcript will not
153
+ * double-redact because the token form `[REDACTED:...]` matches none of
154
+ * the patterns. This makes `--redact --preview` followed by `--redact`
155
+ * safe — operator can inspect first, then commit to the upload, and the
156
+ * second redact pass is a no-op.
157
+ */
158
+ export function redactPii(input) {
159
+ if (input.length === 0) {
160
+ return { output: '', findings: [], totalSpans: 0 };
161
+ }
162
+ let output = input;
163
+ const counts = new Map();
164
+ for (const rule of RULES) {
165
+ output = output.replace(rule.pattern, (match) => {
166
+ if (rule.validate && !rule.validate(match))
167
+ return match;
168
+ counts.set(rule.category, (counts.get(rule.category) ?? 0) + 1);
169
+ return token(rule.category, match);
170
+ });
171
+ }
172
+ const findings = [];
173
+ for (const [category, count] of counts.entries()) {
174
+ findings.push({ category, count });
175
+ }
176
+ // Stable order so the gate banner is deterministic across runs.
177
+ findings.sort((a, b) => b.count !== a.count ? b.count - a.count : a.category.localeCompare(b.category));
178
+ const totalSpans = findings.reduce((acc, f) => acc + f.count, 0);
179
+ return { output, findings, totalSpans };
180
+ }
181
+ /**
182
+ * Heuristic: does the transcript carry an active credential token that
183
+ * MUST refuse upload regardless of `--redact`? Surfaces as a hard gate
184
+ * before any upload path even with redaction enabled — the operator's
185
+ * intent to share a credential is itself a footgun (the credential
186
+ * leaves their machine before the redactor runs). The privacy gate calls
187
+ * this BEFORE running `redactPii`.
188
+ *
189
+ * The check is intentionally narrower than the redactor catalog: we only
190
+ * refuse on `Bearer ` prefix (the most common live-auth-header form) so
191
+ * we do not block a legitimate share that contains an old expired API
192
+ * key referenced in a code comment. Operators can disable the heuristic
193
+ * with `--allow-credentials` (NOT in scope for L20 — the refusal is
194
+ * absolute today).
195
+ */
196
+ export function containsActiveCredential(input) {
197
+ if (input.length === 0)
198
+ return false;
199
+ return /Bearer\s+[A-Za-z0-9._~+/=-]{16,}/.test(input);
200
+ }
201
+ /**
202
+ * Format the findings array as a short human-readable summary used in
203
+ * the privacy gate banner. Example output:
204
+ *
205
+ * "Redacted 3 PII spans (2 EMAIL, 1 API_KEY_OPENAI)"
206
+ *
207
+ * Falls back to "Redacted 0 PII spans" when nothing matched — surfaces
208
+ * a clean gate so the operator knows the redact pass did run.
209
+ */
210
+ export function summariseFindings(result) {
211
+ if (result.totalSpans === 0) {
212
+ return 'Redacted 0 PII spans (transcript appears clean).';
213
+ }
214
+ const top = result.findings
215
+ .slice(0, 4)
216
+ .map((f) => `${f.count} ${f.category}`)
217
+ .join(', ');
218
+ const tail = result.findings.length > 4 ? `, ${result.findings.length - 4} more` : '';
219
+ return `Redacted ${result.totalSpans} PII spans (${top}${tail}).`;
220
+ }
221
+ //# sourceMappingURL=redactor.js.map