@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,235 @@
1
+ /**
2
+ * Leak L27 (2026-05-27) — Auto-update channel + last-check persistence.
3
+ *
4
+ * Two pieces of disk state are managed here:
5
+ *
6
+ * 1. **Channel selection** — `~/.pugi/config.json::updateChannel`.
7
+ * Persisted across sessions so `pugi update` keeps polling the
8
+ * same track the operator opted into via `pugi update --channel
9
+ * <name>`. Mirrors the read/write pattern used by
10
+ * `core/permissions/state.ts::getGlobalDefaultMode` (passthrough
11
+ * schema, atomic tmp+rename, defensive parse).
12
+ *
13
+ * 2. **Last-check timestamp** — `~/.pugi/.last-update-check` (ISO
14
+ * string, single-line). Read by the cold-start banner gate so
15
+ * operators only see the "update available" hint once per
16
+ * `UPDATE_CHECK_INTERVAL_HOURS` (default 24h). Living on its own
17
+ * file (NOT a JSON object inside config.json) is intentional:
18
+ * the timestamp is a hot path — every CLI invocation touches it —
19
+ * and a single-line read+write is materially faster than the
20
+ * JSON parse + serialise of the broader config doc, with no
21
+ * schema coupling cost.
22
+ *
23
+ * Module contract:
24
+ *
25
+ * - Every file path resolver accepts a `homeDir` override so the
26
+ * test suite can drive the module through a per-test mkdtemp
27
+ * directory without polluting the real `~/.pugi/`.
28
+ *
29
+ * - Parse / read helpers NEVER throw on a malformed file. A
30
+ * corrupted JSON blob, a missing field, or an unreadable file all
31
+ * collapse to "no persisted value" so the next layer (the CLI
32
+ * flag or the hard default `beta`) takes over. A future-self
33
+ * debugging an update flow against a corrupt config never has the
34
+ * CLI crash on them.
35
+ *
36
+ * - Write helpers use the atomic tmp+rename idiom so a kill mid-
37
+ * write never produces a half-flushed JSON document. The
38
+ * timestamp file is small enough that POSIX `rename` is itself
39
+ * atomic in practice, but we keep the idiom uniform with the
40
+ * config write so reviewers do not have to context-switch.
41
+ */
42
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
43
+ import { homedir } from 'node:os';
44
+ import { resolve, dirname } from 'node:path';
45
+ import { z } from 'zod';
46
+ import { DEFAULT_UPDATE_CHANNEL, UPDATE_CHANNELS, } from './channels.js';
47
+ /**
48
+ * Default rate-limit window between registry probes. Operators see the
49
+ * cold-start banner at most once per window. Override per call via
50
+ * `shouldCheckForUpdate({ intervalHours })` — the cron-style scheduler
51
+ * passes 0 to force a check on every invocation, the doctor probe
52
+ * passes 24 to match the operator-visible cadence.
53
+ */
54
+ export const UPDATE_CHECK_INTERVAL_HOURS = 24;
55
+ /** Filename of the per-user channel + misc config. Mirrors L6 / L25. */
56
+ const CONFIG_FILE = '.pugi/config.json';
57
+ /** Filename of the standalone last-check ISO timestamp. */
58
+ const LAST_CHECK_FILE = '.pugi/.last-update-check';
59
+ /**
60
+ * Zod schema for the channel slice of `~/.pugi/config.json`. The
61
+ * passthrough lets sibling skills (L6 `defaultPermissionMode`, L25
62
+ * onboarding marker, etc.) coexist in the same JSON document without
63
+ * dropping their fields on a channel write.
64
+ */
65
+ const channelConfigSchema = z
66
+ .object({
67
+ updateChannel: z.enum(['stable', 'beta', 'canary']).optional(),
68
+ })
69
+ .partial()
70
+ .passthrough();
71
+ /**
72
+ * Resolve the absolute path of the per-user config file. Defaults to
73
+ * the real home dir, but every caller in the spec passes an explicit
74
+ * tmpdir so the persisted writes never escape the test sandbox.
75
+ */
76
+ export function configPath(homeDir = homedir()) {
77
+ return resolve(homeDir, CONFIG_FILE);
78
+ }
79
+ /**
80
+ * Resolve the absolute path of the single-line last-check file.
81
+ */
82
+ export function lastCheckPath(homeDir = homedir()) {
83
+ return resolve(homeDir, LAST_CHECK_FILE);
84
+ }
85
+ /**
86
+ * Read the persisted channel selection. Returns `null` when the
87
+ * config file is absent, the field is unset, or the file is unparse-
88
+ * able. The caller layers in the CLI flag + the hard default
89
+ * `DEFAULT_UPDATE_CHANNEL`.
90
+ *
91
+ * Defensive parse is intentional — a half-written config from a
92
+ * crashed session should never block `pugi update` from finishing the
93
+ * channel switch.
94
+ */
95
+ export function getUpdateChannel(homeDir = homedir()) {
96
+ const path = configPath(homeDir);
97
+ if (!existsSync(path))
98
+ return null;
99
+ try {
100
+ const raw = readFileSync(path, 'utf8');
101
+ const parsed = channelConfigSchema.parse(JSON.parse(raw));
102
+ return parsed.updateChannel ?? null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * Resolve the effective channel for an invocation. Resolution order:
110
+ *
111
+ * 1. `cliFlag` (when provided + parses to a known channel).
112
+ * 2. `~/.pugi/config.json::updateChannel`.
113
+ * 3. `DEFAULT_UPDATE_CHANNEL` (currently `beta`).
114
+ *
115
+ * An invalid `cliFlag` (e.g. `--channel yolo`) falls through to the
116
+ * next layer rather than crashing — the dispatcher already validates
117
+ * the flag up front and surfaces a deterministic error for unknown
118
+ * names. This helper exists for code paths (the doctor probe, the
119
+ * cold-start banner) where no CLI flag is in play and a silent fall-
120
+ * through is the correct behaviour.
121
+ */
122
+ export function resolveEffectiveChannel(options = {}) {
123
+ const cli = options.cliFlag;
124
+ if (cli && typeof cli === 'string') {
125
+ const trimmed = cli.trim().toLowerCase();
126
+ for (const channel of UPDATE_CHANNELS) {
127
+ if (channel === trimmed)
128
+ return channel;
129
+ }
130
+ }
131
+ const persisted = getUpdateChannel(options.homeDir ?? homedir());
132
+ if (persisted)
133
+ return persisted;
134
+ return DEFAULT_UPDATE_CHANNEL;
135
+ }
136
+ /**
137
+ * Persist the channel to `~/.pugi/config.json::updateChannel`. Creates
138
+ * `~/.pugi/` when missing; preserves any unrelated keys in the file
139
+ * (passthrough schema). Atomic tmp+rename so a kill mid-write never
140
+ * leaves the config half-flushed.
141
+ */
142
+ export function setUpdateChannel(channel, homeDir = homedir()) {
143
+ const path = configPath(homeDir);
144
+ mkdirSync(dirname(path), { recursive: true });
145
+ const existing = existsSync(path)
146
+ ? safeParseObject(readFileSync(path, 'utf8'))
147
+ : {};
148
+ const next = { ...existing, updateChannel: channel };
149
+ const tmpPath = `${path}.tmp`;
150
+ writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, {
151
+ encoding: 'utf8',
152
+ mode: 0o600,
153
+ });
154
+ renameSync(tmpPath, path);
155
+ }
156
+ /**
157
+ * Read the ISO timestamp of the most recent registry probe. Returns
158
+ * `null` when the file is absent or the contents do not parse as a
159
+ * valid Date. The caller treats `null` as "never checked" and runs an
160
+ * immediate probe.
161
+ */
162
+ export function readLastCheckedAt(homeDir = homedir()) {
163
+ const path = lastCheckPath(homeDir);
164
+ if (!existsSync(path))
165
+ return null;
166
+ try {
167
+ const raw = readFileSync(path, 'utf8').trim();
168
+ if (raw.length === 0)
169
+ return null;
170
+ const ts = Date.parse(raw);
171
+ if (!Number.isFinite(ts))
172
+ return null;
173
+ return new Date(ts);
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ }
179
+ /**
180
+ * Persist the timestamp of the most recent registry probe. Atomic
181
+ * tmp+rename for the same reasons as `setUpdateChannel` — the file is
182
+ * small but we keep the idiom uniform.
183
+ */
184
+ export function writeLastCheckedAt(when, homeDir = homedir()) {
185
+ const path = lastCheckPath(homeDir);
186
+ mkdirSync(dirname(path), { recursive: true });
187
+ const tmpPath = `${path}.tmp`;
188
+ writeFileSync(tmpPath, `${when.toISOString()}\n`, {
189
+ encoding: 'utf8',
190
+ mode: 0o600,
191
+ });
192
+ renameSync(tmpPath, path);
193
+ }
194
+ /**
195
+ * Decide whether the cold-start hint should run a fresh registry
196
+ * probe. Returns true when the last probe was more than
197
+ * `intervalHours` ago OR the timestamp file is missing entirely.
198
+ *
199
+ * Pass `intervalHours = 0` to force a probe on every call (used by
200
+ * the `pugi update --check` JSON surface where the operator is
201
+ * explicitly asking for a fresh result).
202
+ */
203
+ export function shouldCheckForUpdate(options = {}) {
204
+ const now = options.now ? options.now() : Date.now();
205
+ const intervalHours = options.intervalHours ?? UPDATE_CHECK_INTERVAL_HOURS;
206
+ if (intervalHours <= 0)
207
+ return true;
208
+ const last = readLastCheckedAt(options.homeDir ?? homedir());
209
+ if (!last)
210
+ return true;
211
+ const ageMs = now - last.getTime();
212
+ const windowMs = intervalHours * 60 * 60 * 1_000;
213
+ return ageMs >= windowMs;
214
+ }
215
+ /**
216
+ * Defensive helper — parse JSON to an object; non-object payloads
217
+ * (top-level array, primitive) collapse to an empty object so the
218
+ * channel-write merge does not surface a TypeError. Mirrors the
219
+ * `safeParseObject` in `core/permissions/state.ts` — duplicating the
220
+ * 10 lines is cheaper than threading a shared util module through
221
+ * two unrelated leak surfaces.
222
+ */
223
+ function safeParseObject(raw) {
224
+ try {
225
+ const parsed = JSON.parse(raw);
226
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
227
+ return parsed;
228
+ }
229
+ return {};
230
+ }
231
+ catch {
232
+ return {};
233
+ }
234
+ }
235
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Leak L22 (2026-05-27) — `--bare` mode predicate.
3
+ *
4
+ * Mirror of Claude Code's `--bare` flag: when active the CLI behaves
5
+ * like a plain LLM frontend with NO project auto-discovery. Useful for:
6
+ *
7
+ * - headless scripting where the operator wants deterministic, repo-
8
+ * independent behavior (`pugi --bare --print "..."`),
9
+ * - dropping into a workspace without auto-creating `.pugi/`,
10
+ * - REPL sessions that should NOT inject ambient `PUGI.md` / `CLAUDE.md`
11
+ * into the model prompt,
12
+ * - support / triage flows where the engineer needs the CLI to act
13
+ * like a fresh install regardless of where it's invoked.
14
+ *
15
+ * Discovery surfaces gated by `isBareMode()`:
16
+ *
17
+ * 1. `PUGI.md` / `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` parent-dir
18
+ * walk-up (`loadTraversedMarkdown` in `core/context/markdown-traverse.ts`).
19
+ * 2. Workspace-root markdown context (`loadMarkdownContext` consumers).
20
+ * 3. Auto-init `.pugi/` scaffold on REPL boot in untouched dirs.
21
+ * 4. Persona / skill auto-load from `.pugi/skills/`.
22
+ * 5. Workspace summary (`readPugiSummary`) read on REPL session start.
23
+ *
24
+ * Activation precedence — the bare bit is "sticky" once set so any
25
+ * subprocess the CLI spawns inherits it without re-passing the flag:
26
+ *
27
+ * 1. Top-level `--bare` arg parsed by `parseArgs` in `runtime/cli.ts`.
28
+ * The parser sets `process.env.PUGI_BARE='1'` BEFORE the dispatch
29
+ * flows so callsites checking the env see the activated state.
30
+ * 2. `PUGI_BARE=1` env var (any value matching `/^(1|true|yes|on)$/i`).
31
+ * 3. Default: bare mode OFF — full auto-discovery as before.
32
+ *
33
+ * This mirrors the existing `PUGI_SKIP_SPLASH` / `PUGI_NO_AUTO_INIT`
34
+ * env-flag pattern so the bare module fits the rest of the runtime
35
+ * configuration grammar without inventing a new wire.
36
+ *
37
+ * Test surface: `apps/pugi-cli/test/bare-mode.spec.ts` exercises the
38
+ * env precedence, value parsing, and the explicit-set / clear helpers.
39
+ */
40
+ /**
41
+ * Env var consulted by `isBareMode()`. Kept as an export so the spec
42
+ * + the runtime CLI can use the same constant — no string-typing of
43
+ * the wire name across modules.
44
+ */
45
+ export const PUGI_BARE_ENV = 'PUGI_BARE';
46
+ /**
47
+ * Truthy values recognised on the `PUGI_BARE` env. Anything else
48
+ * (empty string, `0`, `false`, `no`, `off`, `disabled`, undefined) is
49
+ * treated as bare-mode OFF. The list is intentionally short — the
50
+ * value is set by the CLI parser and is not customer-typed prose, so
51
+ * we do not need a permissive boolean coercion.
52
+ */
53
+ const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
54
+ /**
55
+ * Return true when bare mode is active for the current process. Reads
56
+ * `process.env[PUGI_BARE_ENV]` and applies the truthy-value match.
57
+ *
58
+ * Safe to call from any module (no FS, no side-effects). The runtime
59
+ * cost is a single env-var lookup + lower-case + set membership, so
60
+ * gating hot-path callsites with `if (isBareMode()) return ...` adds
61
+ * effectively zero overhead in the default (non-bare) case.
62
+ */
63
+ export function isBareMode(env = process.env) {
64
+ const raw = env[PUGI_BARE_ENV];
65
+ if (typeof raw !== 'string' || raw.length === 0)
66
+ return false;
67
+ return TRUTHY.has(raw.toLowerCase());
68
+ }
69
+ /**
70
+ * Explicitly activate bare mode for the current process. Called by
71
+ * `parseArgs` in `runtime/cli.ts` when `--bare` is seen on the command
72
+ * line so downstream modules (engine, REPL bootstrap, doctor probe)
73
+ * see a consistent activated state via `isBareMode()` regardless of
74
+ * whether the operator set the env var manually or used the flag.
75
+ *
76
+ * Subprocess inheritance is the reason we mutate `process.env` rather
77
+ * than threading a `bare: boolean` field through every call signature
78
+ * — every Node child_process spawn inherits `process.env` by default,
79
+ * so the bare bit propagates to MCP servers / hook scripts / git
80
+ * subprocesses without ceremony.
81
+ */
82
+ export function setBareMode(env = process.env) {
83
+ env[PUGI_BARE_ENV] = '1';
84
+ }
85
+ /**
86
+ * Clear bare mode for the current process. Provided primarily for the
87
+ * spec so adjacent tests do not leak state between cases. Production
88
+ * code does NOT call this — bare mode is a one-shot per process.
89
+ */
90
+ export function clearBareMode(env = process.env) {
91
+ delete env[PUGI_BARE_ENV];
92
+ }
93
+ /**
94
+ * Human-readable one-line banner printed by the dispatcher when bare
95
+ * mode is active and the invocation is NOT JSON-only. Kept as a single
96
+ * constant so the spec can assert the exact wording and downstream
97
+ * tools (status bars, doctor row, REPL header) stay in lockstep.
98
+ */
99
+ export const BARE_MODE_BANNER = 'Pugi --bare mode: project auto-discovery disabled.';
100
+ /**
101
+ * Short label rendered inside the `pugi doctor` table when bare mode
102
+ * is active. The doctor probe surfaces `BARE MODE` as a separate row
103
+ * so operators triaging "why is Pugi ignoring my PUGI.md" see the
104
+ * cause without grep'ing the env.
105
+ */
106
+ export const BARE_MODE_DOCTOR_LABEL = 'BARE MODE';
107
+ //# sourceMappingURL=index.js.map
@@ -367,7 +367,7 @@ const WRITE_WORKSPACE_PREFIXES = [
367
367
  * the class is `write_protected` regardless of the operation type.
368
368
  *
369
369
  * Wildcards are handled as substring matches (e.g. `/.ssh/` matches
370
- * `~/.ssh/foo` and `/Users/x/.ssh/bar`).
370
+ * `~/.ssh/foo` and `[HOME]/USER/.ssh/bar`).
371
371
  */
372
372
  const PROTECTED_PATH_SUBSTRINGS = [
373
373
  '/.ssh/',
@@ -388,6 +388,40 @@ const PROTECTED_PATH_SUBSTRINGS = [
388
388
  '/usr/',
389
389
  '/var/',
390
390
  ];
391
+ /**
392
+ * Protected basename triggers — files whose CONTENT must never leak
393
+ * through the bash surface, even when the literal path is workspace-
394
+ * local. Mirrors `permission.ts::protectedBasenames` and `.env.*`
395
+ * pattern so the read-tool gate (which fires on `read .env`) and the
396
+ * bash gate (which fires on `cat .env`) stay symmetric.
397
+ *
398
+ * P0 fix 2026-05-28 (Codex audit): before this list existed, the
399
+ * engine model could circumvent the `read` tool's `protectedTargetReason`
400
+ * check by emitting `bash cat .env` — the classifier saw `cat` (read
401
+ * token) + `.env` (not in PROTECTED_PATH_SUBSTRINGS) and returned class
402
+ * `read`, which the permission matrix allows under every mode. The
403
+ * `local-first-invariants` spec proved the leak: `pugi explain .env`
404
+ * surfaced `SECRET=should_never_leak` in the engine summary.
405
+ *
406
+ * Match shape: the substring must touch a `.` boundary (`/.env`,
407
+ * ` .env`, `.env\b`) or appear as the full token so a path like
408
+ * `apps/codeforge/file.env-template` (no real secret) does not
409
+ * over-trigger.
410
+ */
411
+ const PROTECTED_BASENAME_PATTERNS = [
412
+ // `.env`, `.env.production`, `.env.local` — anywhere in the command.
413
+ // Boundary on the left is start/whitespace/quote/`/`, on the right
414
+ // start/whitespace/end/quote/`>`/`|`/`;`.
415
+ /(^|[\s'"\/=])\.env(\.[A-Za-z0-9_-]+)?($|[\s'"<>|;&])/,
416
+ // SSH key basenames (covers both `id_rsa` and `id_ed25519` even
417
+ // outside `~/.ssh/`). The `/.ssh/` substring above gates the
418
+ // directory case; this catches a key file copied to the workspace.
419
+ /(^|[\s'"\/])id_(rsa|ed25519|ecdsa|dsa)(\.pub)?($|[\s'"<>|;&])/,
420
+ // Other credential basenames mirrored from permission.ts.
421
+ /(^|[\s'"\/])\.npmrc($|[\s'"<>|;&])/,
422
+ /(^|[\s'"\/])\.pypirc($|[\s'"<>|;&])/,
423
+ /(^|[\s'"\/])\.gitconfig($|[\s'"<>|;&])/,
424
+ ];
391
425
  /**
392
426
  * Obfuscation triggers — any of these forces the `unknown` class so
393
427
  * the permission engine can fail closed.
@@ -469,6 +503,26 @@ function classifyComponent(cmd, ctx) {
469
503
  matched: protectedRead.matched,
470
504
  };
471
505
  }
506
+ // 4a-bis. Parent-traversal in read arguments. The file-tools layer
507
+ // refuses `..` segments via `resolveWorkspacePath`, but the bash
508
+ // surface had no equivalent gate — the engine could emit
509
+ // `cat ../README.md` or `ls ..` to enumerate / read outside the
510
+ // workspace, sidestepping the path-security check that the `read`
511
+ // and `glob` tools enforce.
512
+ //
513
+ // P0 fix 2026-05-28 (Codex audit): treat `..` as a path segment
514
+ // (`../`, ` ..`, `..\n`) in any read-class command as a workspace
515
+ // escape. We classify it as `write_protected` so the auto/dontAsk
516
+ // modes refuse, mirroring the `Path escapes workspace` semantics
517
+ // the file-tools layer already provides.
518
+ const traversal = detectParentTraversalRead(trimmed);
519
+ if (traversal) {
520
+ return {
521
+ class: 'write_protected',
522
+ reason: traversal.reason,
523
+ matched: traversal.matched,
524
+ };
525
+ }
472
526
  // 4b. .env writes are always protected, even inside the workspace
473
527
  // (CEO directive feedback_never_delete_untracked_env.md).
474
528
  const envWrite = detectEnvWrite(trimmed);
@@ -785,6 +839,59 @@ function detectProtectedRead(cmd) {
785
839
  };
786
840
  }
787
841
  }
842
+ // P0 fix 2026-05-28: extend protected-read detection to credential
843
+ // basenames (`cat .env`, `head id_rsa`, `grep TOKEN .env.production`).
844
+ // Without this branch, the engine model can bypass the `read` tool's
845
+ // `protectedTargetReason` gate by emitting a bash `cat` — the read
846
+ // tool refuses, the model falls back to bash, and the classifier
847
+ // (which only knew about full-path substrings) classified `cat .env`
848
+ // as benign `read`. The `local-first-invariants` spec proved the leak.
849
+ for (const pattern of PROTECTED_BASENAME_PATTERNS) {
850
+ const match = cmd.match(pattern);
851
+ if (match) {
852
+ return {
853
+ reason: `Read from protected basename: ${match[0].trim()}`,
854
+ matched: match[0].trim(),
855
+ };
856
+ }
857
+ }
858
+ return null;
859
+ }
860
+ /**
861
+ * Detect parent-traversal segments (`..`) inside read-class commands.
862
+ * The file-tools layer (`resolveWorkspacePath`) refuses these for the
863
+ * `read`/`glob`/`grep` tools, but bash had no equivalent gate. We
864
+ * trigger on the SAME shape `path-security.ts` rejects: a `..` segment
865
+ * separated by `/` or whitespace. Quoted/escaped variants get the same
866
+ * treatment.
867
+ *
868
+ * Returns null on the safe path (no `..` segment) so the caller falls
869
+ * through to the regular read classification.
870
+ */
871
+ function detectParentTraversalRead(cmd) {
872
+ const firstToken = cmd.split(/\s+/)[0] ?? '';
873
+ const isReadTool = READ_TOKENS.has(firstToken) ||
874
+ READ_PREFIX_TOKENS.has(firstToken) ||
875
+ firstToken === 'sed' ||
876
+ firstToken === 'awk' ||
877
+ firstToken === 'find';
878
+ if (!isReadTool)
879
+ return null;
880
+ // Match `..` as a path segment: preceded by start/whitespace/quote/`/`
881
+ // and followed by `/`, end-of-string, whitespace, or shell metas.
882
+ // Avoids over-matching `v1..v2` (range syntax inside a single token)
883
+ // and `1..5` (numeric ranges) because those lack the path boundary.
884
+ const traversalPattern = /(^|[\s'"\/])\.\.(\/|$|[\s'"<>|;&])/;
885
+ const m = cmd.match(traversalPattern);
886
+ if (m) {
887
+ return {
888
+ reason: 'Read command escapes workspace via parent traversal',
889
+ matched: '..',
890
+ };
891
+ }
892
+ // Absolute path read of /etc, /usr, /var, etc is already covered by
893
+ // PROTECTED_PATH_SUBSTRINGS in detectProtectedRead — no extra branch
894
+ // needed here.
788
895
  return null;
789
896
  }
790
897
  function detectEnvWrite(cmd) {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Resumer — read SessionStore events for a session, apply the L8
3
+ * compact mask + the L9 rewind mask, and return the visible transcript
4
+ * the REPL bootstrap (or a programmatic consumer) should render.
5
+ *
6
+ * Separation of concerns:
7
+ *
8
+ * - This module owns the READ path: list sessions, load events,
9
+ * reconstruct a clean transcript stream. No writes.
10
+ * - The WRITE path (append a rewind-marker, undo-rewind) lives in
11
+ * `./rewinder.ts`.
12
+ * - The REPL session lifecycle (lockfile, Ink mount, dispatch FSM)
13
+ * stays in `core/repl/session.ts`. We do NOT spin up the REPL here.
14
+ *
15
+ * Why route resume through this module at all (vs. operators using
16
+ * `core/repl/store/*` directly):
17
+ *
18
+ * The store returns RAW events. Most consumers want masked events —
19
+ * i.e. the chronological list after compact-boundary masking AND
20
+ * rewind-marker masking. Doing both passes inline at every call site
21
+ * would scatter the mask logic; centralising it here means a future
22
+ * third mask (named checkpoints? selective edit?) lands in one place.
23
+ */
24
+ import { homedir } from 'node:os';
25
+ import { applyCompactMask } from '../compact/buffer-rewriter.js';
26
+ import { SqliteSessionStore, resolveProjectStoreDir, } from '../repl/store/index.js';
27
+ import { applyRewindMask, findLatestActiveRewind } from './rewinder.js';
28
+ /**
29
+ * Composed mask: compact-mask first (collapses summarised slices into
30
+ * boundary markers + kept tail), then rewind-mask (drops everything
31
+ * inside an active rewind range, including any compaction markers that
32
+ * fell inside it).
33
+ *
34
+ * Order matters: compact-mask reads `coversUntilOffset` against the
35
+ * RAW event indices. Running rewind-mask first would shift indices and
36
+ * break the compact replay anchor. The result is the chronological
37
+ * stream the operator should SEE, with infra rows (rewind markers)
38
+ * stripped.
39
+ */
40
+ export function applyAllMasks(events) {
41
+ return applyRewindMask(applyCompactMask(events));
42
+ }
43
+ /**
44
+ * List sessions a `pugi resume` invocation could open. Uses the
45
+ * READ-ONLY store view so the call never takes the lockfile — safe to
46
+ * run alongside a live REPL. Each row carries derived metadata
47
+ * (`visibleEventCount`, `hasActiveRewind`) so the renderer does not
48
+ * need to re-walk events.
49
+ *
50
+ * Returns an empty array when the project store does not exist (no
51
+ * sessions ever started for this project slug). Callers surface a
52
+ * "nothing to resume" message in that branch.
53
+ */
54
+ export async function listResumableSessions(input) {
55
+ const dir = input.storeDir ?? resolveProjectStoreDir(input.projectSlug, input.home ?? homedir());
56
+ const limit = clampLimit(input.limit ?? 10, 1, 50);
57
+ let view;
58
+ try {
59
+ view = await SqliteSessionStore.openReadOnly(dir);
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ try {
65
+ const rows = await view.list({ project: input.projectSlug, limit });
66
+ const out = [];
67
+ for (const row of rows) {
68
+ const events = await view.events(row.id);
69
+ const visible = applyAllMasks(events);
70
+ const latest = findLatestActiveRewind(events);
71
+ out.push({
72
+ row,
73
+ visibleEventCount: visible.length,
74
+ hasActiveRewind: latest !== null,
75
+ updatedAt: row.updatedAt,
76
+ });
77
+ }
78
+ return out;
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ finally {
84
+ await view.close();
85
+ }
86
+ }
87
+ /**
88
+ * Load one session for replay. The caller (REPL bootstrap, tests,
89
+ * future programmatic exporters) gets BOTH the raw event stream and
90
+ * the masked view so it can choose its rendering strategy. Returns
91
+ * null when the session does not exist; throws when the store cannot
92
+ * be opened (the caller surfaces a one-line error).
93
+ *
94
+ * The PID lockfile contention is NOT relevant here — we use the
95
+ * read-only view. Concurrent writers from a live REPL are safe.
96
+ */
97
+ export async function loadSessionForReplay(input) {
98
+ const dir = input.storeDir ?? resolveProjectStoreDir(input.projectSlug, input.home ?? homedir());
99
+ const view = await SqliteSessionStore.openReadOnly(dir);
100
+ try {
101
+ const row = await view.get(input.sessionId);
102
+ if (!row)
103
+ return null;
104
+ const rawEvents = await view.events(row.id);
105
+ const visibleEvents = applyAllMasks(rawEvents);
106
+ const latest = findLatestActiveRewind(rawEvents);
107
+ return {
108
+ row,
109
+ rawEvents,
110
+ visibleEvents,
111
+ hasActiveRewind: latest !== null,
112
+ };
113
+ }
114
+ finally {
115
+ await view.close();
116
+ }
117
+ }
118
+ /**
119
+ * Load raw + masked events through an already-open SessionStore.
120
+ *
121
+ * Used by the in-REPL `/rewind` slash handler — the live REPL already
122
+ * holds the writer lock, so we cannot open the read-only view in the
123
+ * same process. The store reference IS the active write handle; we
124
+ * just call `loadEvents` and run the masks.
125
+ *
126
+ * Same shape as `loadSessionForReplay` minus the read-only-view setup.
127
+ */
128
+ export async function loadFromStore(store, sessionId) {
129
+ const row = await store.getSession(sessionId);
130
+ if (!row)
131
+ return null;
132
+ const rawEvents = await store.loadEvents(sessionId);
133
+ const visibleEvents = applyAllMasks(rawEvents);
134
+ const latest = findLatestActiveRewind(rawEvents);
135
+ return {
136
+ row,
137
+ rawEvents,
138
+ visibleEvents,
139
+ hasActiveRewind: latest !== null,
140
+ };
141
+ }
142
+ function clampLimit(raw, min, max) {
143
+ if (!Number.isFinite(raw) || raw < min)
144
+ return min;
145
+ if (raw > max)
146
+ return max;
147
+ return Math.floor(raw);
148
+ }
149
+ //# sourceMappingURL=resumer.js.map