@pugi/cli 0.1.0-beta.3 → 0.1.0-beta.31

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 (219) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  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/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/artifact-chain/dispatcher.js +148 -0
  10. package/dist/core/artifact-chain/exporter.js +164 -0
  11. package/dist/core/artifact-chain/state.js +243 -0
  12. package/dist/core/artifact-chain/steps.js +169 -0
  13. package/dist/core/auth/env-provider.js +238 -0
  14. package/dist/core/auto-update/channels.js +122 -0
  15. package/dist/core/auto-update/checker.js +241 -0
  16. package/dist/core/auto-update/state.js +235 -0
  17. package/dist/core/bare-mode/index.js +107 -0
  18. package/dist/core/checkpoint/resumer.js +149 -0
  19. package/dist/core/checkpoint/rewinder.js +291 -0
  20. package/dist/core/compact/auto-trigger.js +96 -0
  21. package/dist/core/compact/buffer-rewriter.js +115 -0
  22. package/dist/core/compact/summarizer.js +208 -0
  23. package/dist/core/compact/token-counter.js +108 -0
  24. package/dist/core/consensus/diff-capture.js +73 -0
  25. package/dist/core/context/index.js +7 -0
  26. package/dist/core/context/markdown-traverse.js +255 -0
  27. package/dist/core/cost/rate-card.js +129 -0
  28. package/dist/core/cost/tracker.js +221 -0
  29. package/dist/core/denial-tracking/index.js +8 -0
  30. package/dist/core/denial-tracking/state.js +264 -0
  31. package/dist/core/diagnostics/probe-runner.js +93 -0
  32. package/dist/core/diagnostics/probes/api.js +46 -0
  33. package/dist/core/diagnostics/probes/auth.js +86 -0
  34. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  35. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  36. package/dist/core/diagnostics/probes/config.js +72 -0
  37. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  38. package/dist/core/diagnostics/probes/disk.js +81 -0
  39. package/dist/core/diagnostics/probes/git.js +65 -0
  40. package/dist/core/diagnostics/probes/mcp.js +75 -0
  41. package/dist/core/diagnostics/probes/node.js +59 -0
  42. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  43. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  44. package/dist/core/diagnostics/probes/session.js +74 -0
  45. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  46. package/dist/core/diagnostics/probes/workspace.js +63 -0
  47. package/dist/core/diagnostics/types.js +70 -0
  48. package/dist/core/dispatch/cache-cleanup.js +197 -0
  49. package/dist/core/dispatch/cache-handoff.js +295 -0
  50. package/dist/core/edits/dispatch.js +218 -2
  51. package/dist/core/edits/journal.js +199 -0
  52. package/dist/core/edits/layer-d-ast.js +557 -14
  53. package/dist/core/edits/verify-hook.js +273 -0
  54. package/dist/core/edits/worktree.js +111 -18
  55. package/dist/core/engine/anvil-client.js +115 -5
  56. package/dist/core/engine/budgets.js +89 -0
  57. package/dist/core/engine/context-prefix.js +155 -0
  58. package/dist/core/engine/intent.js +260 -0
  59. package/dist/core/engine/native-pugi.js +852 -210
  60. package/dist/core/engine/prompts.js +89 -6
  61. package/dist/core/engine/strip-internal-fields.js +124 -0
  62. package/dist/core/engine/tool-bridge.js +972 -33
  63. package/dist/core/feedback/queue.js +177 -0
  64. package/dist/core/feedback/submitter.js +145 -0
  65. package/dist/core/file-cache.js +113 -1
  66. package/dist/core/hooks/events.js +44 -0
  67. package/dist/core/hooks/index.js +15 -0
  68. package/dist/core/hooks/registry.js +213 -0
  69. package/dist/core/hooks/runner.js +236 -0
  70. package/dist/core/init/scaffold.js +195 -0
  71. package/dist/core/lsp/cache.js +105 -0
  72. package/dist/core/lsp/client.js +174 -29
  73. package/dist/core/lsp/language-detect.js +66 -0
  74. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  75. package/dist/core/mcp/client.js +75 -6
  76. package/dist/core/mcp/http-server.js +553 -0
  77. package/dist/core/mcp/permission.js +190 -0
  78. package/dist/core/mcp/registry.js +24 -2
  79. package/dist/core/mcp/server-tools.js +219 -0
  80. package/dist/core/mcp/server.js +397 -0
  81. package/dist/core/memory/dual-write.js +416 -0
  82. package/dist/core/memory/dual-write.spec.js +297 -0
  83. package/dist/core/memory/phase1-kinds.js +20 -0
  84. package/dist/core/memory-sync/queue.js +158 -0
  85. package/dist/core/memory-sync/queue.spec.js +105 -0
  86. package/dist/core/onboarding/marker.js +111 -0
  87. package/dist/core/onboarding/telemetry-state.js +108 -0
  88. package/dist/core/output-style/presets.js +176 -0
  89. package/dist/core/output-style/state.js +185 -0
  90. package/dist/core/permissions/gate.js +187 -0
  91. package/dist/core/permissions/index.js +18 -0
  92. package/dist/core/permissions/mode.js +102 -0
  93. package/dist/core/permissions/state.js +215 -0
  94. package/dist/core/permissions/tool-class.js +93 -0
  95. package/dist/core/prd-check/parser.js +215 -0
  96. package/dist/core/prd-check/reporter.js +127 -0
  97. package/dist/core/prd-check/session-review.js +557 -0
  98. package/dist/core/prd-check/verifiers.js +223 -0
  99. package/dist/core/pugi-md/context-injector.js +76 -0
  100. package/dist/core/pugi-md/walk-up.js +207 -0
  101. package/dist/core/release-notes/parser.js +241 -0
  102. package/dist/core/release-notes/state.js +116 -0
  103. package/dist/core/repl/codebase-survey.js +308 -0
  104. package/dist/core/repl/history.js +11 -1
  105. package/dist/core/repl/init-interview.js +457 -0
  106. package/dist/core/repl/model-pricing.js +135 -0
  107. package/dist/core/repl/onboarding-state.js +297 -0
  108. package/dist/core/repl/session.js +1529 -30
  109. package/dist/core/repl/slash-commands.js +361 -13
  110. package/dist/core/repl/store/session-store.js +31 -2
  111. package/dist/core/repl/workspace-context.js +22 -0
  112. package/dist/core/repo-map/build.js +125 -0
  113. package/dist/core/repo-map/cache.js +185 -0
  114. package/dist/core/repo-map/extractor.js +254 -0
  115. package/dist/core/repo-map/formatter.js +145 -0
  116. package/dist/core/repo-map/scanner.js +211 -0
  117. package/dist/core/retry-budget/budget.js +284 -0
  118. package/dist/core/retry-budget/index.js +5 -0
  119. package/dist/core/session.js +44 -0
  120. package/dist/core/settings.js +80 -0
  121. package/dist/core/share/formatter.js +271 -0
  122. package/dist/core/share/redactor.js +221 -0
  123. package/dist/core/share/uploader.js +267 -0
  124. package/dist/core/skills/defaults.js +457 -0
  125. package/dist/core/subagents/dispatcher-real.js +600 -0
  126. package/dist/core/subagents/dispatcher.js +113 -24
  127. package/dist/core/subagents/index.js +18 -5
  128. package/dist/core/subagents/isolation-matrix.js +213 -0
  129. package/dist/core/subagents/spawn.js +19 -4
  130. package/dist/core/telemetry/emitter.js +229 -0
  131. package/dist/core/telemetry/queue.js +251 -0
  132. package/dist/core/theme/context.js +91 -0
  133. package/dist/core/theme/presets.js +228 -0
  134. package/dist/core/theme/state.js +181 -0
  135. package/dist/core/todos/invariant.js +10 -0
  136. package/dist/core/todos/state.js +177 -0
  137. package/dist/core/transport/version-interceptor.js +166 -0
  138. package/dist/core/vim/keymap.js +288 -0
  139. package/dist/core/vim/state.js +92 -0
  140. package/dist/index.js +28 -0
  141. package/dist/runtime/bootstrap.js +190 -0
  142. package/dist/runtime/cli.js +2603 -278
  143. package/dist/runtime/commands/chain.js +489 -0
  144. package/dist/runtime/commands/compact.js +297 -0
  145. package/dist/runtime/commands/cost.js +199 -0
  146. package/dist/runtime/commands/delegate.js +312 -0
  147. package/dist/runtime/commands/dispatch.js +126 -0
  148. package/dist/runtime/commands/doctor.js +390 -0
  149. package/dist/runtime/commands/feedback.js +184 -0
  150. package/dist/runtime/commands/hooks.js +184 -0
  151. package/dist/runtime/commands/lsp.js +212 -28
  152. package/dist/runtime/commands/mcp.js +824 -0
  153. package/dist/runtime/commands/memory.js +508 -0
  154. package/dist/runtime/commands/memory.spec.js +174 -0
  155. package/dist/runtime/commands/model.js +237 -0
  156. package/dist/runtime/commands/onboarding.js +275 -0
  157. package/dist/runtime/commands/patch.js +17 -0
  158. package/dist/runtime/commands/permissions.js +87 -0
  159. package/dist/runtime/commands/plan.js +143 -0
  160. package/dist/runtime/commands/prd-check.js +285 -0
  161. package/dist/runtime/commands/release-notes.js +229 -0
  162. package/dist/runtime/commands/repo-map.js +95 -0
  163. package/dist/runtime/commands/report.js +299 -0
  164. package/dist/runtime/commands/resume.js +118 -0
  165. package/dist/runtime/commands/review-consensus.js +17 -2
  166. package/dist/runtime/commands/rewind.js +333 -0
  167. package/dist/runtime/commands/roster.js +117 -0
  168. package/dist/runtime/commands/sessions.js +163 -0
  169. package/dist/runtime/commands/share.js +316 -0
  170. package/dist/runtime/commands/status.js +178 -0
  171. package/dist/runtime/commands/stickers.js +82 -0
  172. package/dist/runtime/commands/style.js +194 -0
  173. package/dist/runtime/commands/theme.js +196 -0
  174. package/dist/runtime/commands/update.js +289 -0
  175. package/dist/runtime/commands/vim.js +140 -0
  176. package/dist/runtime/commands/worktree.js +50 -6
  177. package/dist/runtime/headless.js +543 -0
  178. package/dist/runtime/load-hooks-or-exit.js +71 -0
  179. package/dist/runtime/plan-decompose.js +531 -0
  180. package/dist/runtime/version.js +65 -0
  181. package/dist/tools/agent-tool.js +229 -0
  182. package/dist/tools/apply-patch.js +281 -39
  183. package/dist/tools/ask-user-question.js +213 -0
  184. package/dist/tools/ask-user.js +115 -0
  185. package/dist/tools/file-tools.js +85 -14
  186. package/dist/tools/mcp-tool.js +260 -0
  187. package/dist/tools/multi-edit.js +361 -0
  188. package/dist/tools/registry.js +30 -2
  189. package/dist/tools/skill-tool.js +96 -0
  190. package/dist/tools/tasks.js +208 -0
  191. package/dist/tools/todo-write.js +184 -0
  192. package/dist/tools/web-fetch.js +147 -2
  193. package/dist/tools/web-search.js +458 -0
  194. package/dist/tui/agent-progress-card.js +111 -0
  195. package/dist/tui/agent-tree.js +10 -0
  196. package/dist/tui/ask-modal.js +2 -2
  197. package/dist/tui/ask-user-question-prompt.js +192 -0
  198. package/dist/tui/compact-banner.js +81 -0
  199. package/dist/tui/conversation-pane.js +82 -8
  200. package/dist/tui/cost-table.js +111 -0
  201. package/dist/tui/doctor-table.js +46 -0
  202. package/dist/tui/feedback-prompt.js +156 -0
  203. package/dist/tui/input-box.js +46 -2
  204. package/dist/tui/markdown-render.js +4 -4
  205. package/dist/tui/onboarding-wizard.js +240 -0
  206. package/dist/tui/repl-render.js +293 -35
  207. package/dist/tui/repl-splash.js +2 -2
  208. package/dist/tui/repl.js +45 -13
  209. package/dist/tui/splash.js +1 -1
  210. package/dist/tui/status-bar.js +94 -16
  211. package/dist/tui/status-table.js +7 -0
  212. package/dist/tui/stickers-art.js +136 -0
  213. package/dist/tui/style-table.js +28 -0
  214. package/dist/tui/theme-table.js +29 -0
  215. package/dist/tui/tool-stream-pane.js +7 -0
  216. package/dist/tui/update-banner.js +20 -2
  217. package/dist/tui/vim-input.js +267 -0
  218. package/docs/examples/codegraph.mcp.json +10 -0
  219. package/package.json +9 -6
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Keep-a-Changelog parser — Leak L24 (2026-05-27).
3
+ *
4
+ * Parses a `CHANGELOG.md` written в the Keep-a-Changelog v1.1 layout
5
+ * (https://keepachangelog.com) into an ordered list of version
6
+ * sections. The parser is intentionally minimal — it understands the
7
+ * `## [<version>] - <date>` header marker and the section body up к
8
+ * the next header, and ignores every other Markdown construct (links,
9
+ * footnotes, sub-sub-headers). That is enough к drive the
10
+ * `pugi release-notes` diff between last-seen and current.
11
+ *
12
+ * # Module contract
13
+ *
14
+ * - Pure function. Takes the raw CHANGELOG text + returns parsed
15
+ * sections. Zero IO; the file read happens at the call site so the
16
+ * spec can pin fixtures without touching disk.
17
+ *
18
+ * - Header grammar: `## [<version>] - <YYYY-MM-DD>`. The leading
19
+ * `## ` is required (h2). The version is everything between the
20
+ * square brackets — semver-shaped strings are not validated
21
+ * beyond non-emptiness so pre-release tags like `0.1.0-beta.21`
22
+ * parse correctly. The date is captured verbatim; the comparator
23
+ * does not parse it — version ordering is enough.
24
+ *
25
+ * - Section body: every line from the header (exclusive) up к the
26
+ * next `## [...] - ...` header (exclusive). Lines are joined
27
+ * verbatim with `\n`; leading + trailing blank lines are trimmed
28
+ * so the renderer can paste sections back-to-back without double
29
+ * blank lines piling up.
30
+ *
31
+ * - Pre-header content (the leading `# Changelog` + introduction
32
+ * blurb) is discarded. The parser only surfaces version sections;
33
+ * callers that want к render the introduction read the source
34
+ * file directly.
35
+ *
36
+ * - Sections appear в the order they are written in the file. The
37
+ * Keep-a-Changelog convention is newest-first; the parser does
38
+ * NOT re-sort. The slicing helpers (`sliceVersionsBetween`) trust
39
+ * the input order so a malformed CHANGELOG with shuffled
40
+ * versions produces a deterministic — if surprising — diff
41
+ * instead of silently swallowing entries.
42
+ *
43
+ * - Semver comparison (`compareSemver`) handles the canonical
44
+ * `MAJOR.MINOR.PATCH[-PRERELEASE]` shape. The pre-release tail
45
+ * is compared lexicographically by dot-separated identifier per
46
+ * semver §11. Unknown / malformed input compares lower than any
47
+ * valid semver so an accidental `unknown` last-seen marker
48
+ * never blocks the operator from seeing new notes.
49
+ */
50
+ const SECTION_HEADER_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/u;
51
+ /**
52
+ * Parse the raw `CHANGELOG.md` text into ordered version sections.
53
+ *
54
+ * Returns sections in the same order they appear in the source. The
55
+ * Keep-a-Changelog convention is newest-first; the parser does not
56
+ * re-sort.
57
+ */
58
+ export function parseChangelog(raw) {
59
+ const lines = raw.split(/\r?\n/u);
60
+ const sections = [];
61
+ let current = null;
62
+ const flush = () => {
63
+ if (!current)
64
+ return;
65
+ sections.push({
66
+ version: current.version,
67
+ date: current.date,
68
+ body: trimBlankEdges(current.lines).join('\n'),
69
+ });
70
+ current = null;
71
+ };
72
+ for (const line of lines) {
73
+ const match = SECTION_HEADER_RE.exec(line);
74
+ if (match) {
75
+ flush();
76
+ const version = (match[1] ?? '').trim();
77
+ const date = (match[2] ?? '').trim();
78
+ if (version.length === 0) {
79
+ // Malformed `## []` header — skip entirely instead of
80
+ // emitting a zero-version row that would corrupt the diff.
81
+ continue;
82
+ }
83
+ current = { version, date, lines: [] };
84
+ continue;
85
+ }
86
+ if (current) {
87
+ current.lines.push(line);
88
+ }
89
+ }
90
+ flush();
91
+ return sections;
92
+ }
93
+ /**
94
+ * Slice the section list к those strictly newer than `lastSeen` and
95
+ * up к (and including) `current`. Both bounds are matched on the
96
+ * verbatim version string — the comparator runs on every section к
97
+ * decide membership.
98
+ *
99
+ * Semantics:
100
+ *
101
+ * - If `lastSeen` is null OR empty OR matches no section, every
102
+ * section ≤ current is returned. This is the first-run path —
103
+ * the operator has never run the command before, so the entire
104
+ * bundled changelog is fair game.
105
+ *
106
+ * - If `lastSeen` equals `current`, the empty array is returned.
107
+ * The caller renders the "no new release notes" copy.
108
+ *
109
+ * - If `lastSeen` is newer than `current`, the empty array is
110
+ * returned. This is the dev-build path — operators running a
111
+ * local build of `0.1.0-beta.30` against a registry that
112
+ * publishes `0.1.0-beta.22` would otherwise see the stale
113
+ * bundled notes; the caller still surfaces the same no-op copy.
114
+ *
115
+ * - Otherwise: sections strictly newer than `lastSeen` and ≤
116
+ * `current`, in the source order (newest-first by convention).
117
+ *
118
+ * The function is pure — no IO, no clock — so the spec can pin every
119
+ * branch with hand-rolled fixtures.
120
+ */
121
+ export function sliceVersionsBetween(sections, lastSeen, current) {
122
+ if (sections.length === 0)
123
+ return [];
124
+ // Treat а blank / sentinel last-seen as "never seen".
125
+ const lastSeenValue = typeof lastSeen === 'string' && lastSeen.trim().length > 0 && lastSeen !== 'none'
126
+ ? lastSeen.trim()
127
+ : null;
128
+ // Dev-build path: operator's last-seen marker is strictly newer than
129
+ // the installed CLI version. This happens when running а local build
130
+ // older than the registry, or when the operator manually edited the
131
+ // marker. Either way, return the empty array — re-rendering the
132
+ // bundled notes would be misleading. The renderer surfaces the same
133
+ // "no new release notes" copy as the up-to-date branch.
134
+ if (lastSeenValue !== null && compareSemver(lastSeenValue, current) > 0) {
135
+ return [];
136
+ }
137
+ // Diff path: surface sections strictly newer than the marker and ≤
138
+ // current. The comparator drives every section — а marker that does
139
+ // not appear in the bundled changelog (operator on а stale build,
140
+ // hand-edited marker, version no longer published) still bisects
141
+ // correctly because every comparison runs against the marker value
142
+ // directly, not the matching-section guard.
143
+ const out = [];
144
+ for (const section of sections) {
145
+ // Anything newer than current is а future entry that should not
146
+ // surface until the operator actually upgrades — guards against а
147
+ // dev build of CHANGELOG.md leaking unreleased notes к а customer
148
+ // install.
149
+ if (compareSemver(section.version, current) > 0)
150
+ continue;
151
+ if (lastSeenValue !== null && compareSemver(section.version, lastSeenValue) <= 0) {
152
+ continue;
153
+ }
154
+ out.push(section);
155
+ }
156
+ return out;
157
+ }
158
+ /**
159
+ * Semver comparator. Returns a negative number when `a < b`, zero
160
+ * when equal, and a positive number when `a > b`. Pre-release tags
161
+ * compare lexicographically by dot-separated identifier per semver §11.
162
+ *
163
+ * Unknown / malformed input compares lower than any valid semver so
164
+ * an accidental sentinel like `unknown` or `none` never accidentally
165
+ * blocks the diff from surfacing newer entries.
166
+ */
167
+ export function compareSemver(a, b) {
168
+ const left = parseSemver(a);
169
+ const right = parseSemver(b);
170
+ if (!left && !right)
171
+ return 0;
172
+ if (!left)
173
+ return -1;
174
+ if (!right)
175
+ return 1;
176
+ for (let i = 0; i < 3; i += 1) {
177
+ const cmp = (left.core[i] ?? 0) - (right.core[i] ?? 0);
178
+ if (cmp !== 0)
179
+ return cmp;
180
+ }
181
+ // A version without a pre-release tag is newer than the same core
182
+ // with a pre-release tag (per semver §11: 1.0.0 > 1.0.0-rc).
183
+ if (left.pre.length === 0 && right.pre.length === 0)
184
+ return 0;
185
+ if (left.pre.length === 0)
186
+ return 1;
187
+ if (right.pre.length === 0)
188
+ return -1;
189
+ const len = Math.max(left.pre.length, right.pre.length);
190
+ for (let i = 0; i < len; i += 1) {
191
+ const li = left.pre[i];
192
+ const ri = right.pre[i];
193
+ if (li === undefined)
194
+ return -1;
195
+ if (ri === undefined)
196
+ return 1;
197
+ const ln = Number.parseInt(li, 10);
198
+ const rn = Number.parseInt(ri, 10);
199
+ const liNumeric = Number.isFinite(ln) && String(ln) === li;
200
+ const riNumeric = Number.isFinite(rn) && String(rn) === ri;
201
+ if (liNumeric && riNumeric) {
202
+ if (ln !== rn)
203
+ return ln - rn;
204
+ continue;
205
+ }
206
+ if (liNumeric)
207
+ return -1; // numeric < alphanumeric per §11
208
+ if (riNumeric)
209
+ return 1;
210
+ if (li < ri)
211
+ return -1;
212
+ if (li > ri)
213
+ return 1;
214
+ }
215
+ return 0;
216
+ }
217
+ function parseSemver(raw) {
218
+ if (typeof raw !== 'string')
219
+ return null;
220
+ const trimmed = raw.trim();
221
+ if (trimmed.length === 0)
222
+ return null;
223
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/u.exec(trimmed);
224
+ if (!match)
225
+ return null;
226
+ const major = Number.parseInt(match[1] ?? '0', 10);
227
+ const minor = Number.parseInt(match[2] ?? '0', 10);
228
+ const patch = Number.parseInt(match[3] ?? '0', 10);
229
+ const pre = match[4] ? match[4].split('.') : [];
230
+ return { core: [major, minor, patch], pre };
231
+ }
232
+ function trimBlankEdges(lines) {
233
+ let start = 0;
234
+ let end = lines.length;
235
+ while (start < end && (lines[start] ?? '').trim().length === 0)
236
+ start += 1;
237
+ while (end > start && (lines[end - 1] ?? '').trim().length === 0)
238
+ end -= 1;
239
+ return lines.slice(start, end);
240
+ }
241
+ //# sourceMappingURL=parser.js.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `~/.pugi/.last-seen-version` state I/O — Leak L24 (2026-05-27).
3
+ *
4
+ * The `pugi release-notes` command renders the diff between the
5
+ * version the operator last saw notes for and the currently
6
+ * installed CLI version. This module owns the on-disk marker that
7
+ * tracks the last-seen value.
8
+ *
9
+ * # Module contract
10
+ *
11
+ * - File path: `<home>/.pugi/.last-seen-version`. The leading dot
12
+ * keeps it out of casual `ls` output; the file is plain ASCII
13
+ * (one line, the version string) so operators can edit it by
14
+ * hand when reproducing scenarios. Missing parent dir is
15
+ * created on write.
16
+ *
17
+ * - Reads: missing file → null. Unreadable / blank file → null.
18
+ * The caller treats null as "operator has never run the command"
19
+ * and surfaces every bundled section. Read failures NEVER throw —
20
+ * the command surface is informational and must keep working on
21
+ * a read-only mount or a misconfigured permission bit.
22
+ *
23
+ * - Writes: best-effort. EACCES / EROFS / ENOSPC log а warning к
24
+ * the caller-supplied logger and return the failure code so the
25
+ * command renderer can footer the output with "could not persist
26
+ * last-seen — re-run will show the same notes". The command
27
+ * itself stays exit 0 because the value of the render did not
28
+ * depend on the write succeeding.
29
+ *
30
+ * - The helpers are pure I/O wrappers — no clock, no random, no
31
+ * env reads. Callers pass `home` explicitly so the spec can
32
+ * pin a tmp dir without monkey-patching `os.homedir`.
33
+ */
34
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
35
+ import { resolve } from 'node:path';
36
+ /** Filename inside `<home>/.pugi/` that holds the last-seen marker. */
37
+ export const LAST_SEEN_VERSION_FILE = '.last-seen-version';
38
+ /**
39
+ * Resolve the absolute path of the last-seen marker file inside the
40
+ * passed home directory. Pure helper — no IO.
41
+ */
42
+ export function lastSeenVersionPath(home) {
43
+ return resolve(home, '.pugi', LAST_SEEN_VERSION_FILE);
44
+ }
45
+ /**
46
+ * Read the last-seen marker. Returns null when the file is missing,
47
+ * unreadable, or empty. Never throws.
48
+ */
49
+ export function readLastSeenVersion(home) {
50
+ const path = lastSeenVersionPath(home);
51
+ try {
52
+ if (!existsSync(path))
53
+ return null;
54
+ const raw = readFileSync(path, 'utf8').trim();
55
+ if (raw.length === 0)
56
+ return null;
57
+ return raw;
58
+ }
59
+ catch {
60
+ // Read-only mount, permission denied, race with another process
61
+ // unlinking the file — every read failure degrades к null so the
62
+ // command treats the operator as a first-time viewer instead of
63
+ // dropping out of the slash dispatcher with an unhandled error.
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Persist the last-seen marker. Creates `<home>/.pugi/` if it does
69
+ * not exist. Returns a structured envelope describing success or the
70
+ * failure reason so the renderer can footer а warning when the write
71
+ * could not be made durable.
72
+ */
73
+ export function writeLastSeenVersion(home, version) {
74
+ const path = lastSeenVersionPath(home);
75
+ try {
76
+ const dir = resolve(home, '.pugi');
77
+ if (!existsSync(dir)) {
78
+ mkdirSync(dir, { recursive: true });
79
+ }
80
+ // Trailing newline so `cat ~/.pugi/.last-seen-version` reads
81
+ // nicely in shells that do not auto-append one for the prompt.
82
+ writeFileSync(path, `${version}\n`, { encoding: 'utf8', mode: 0o600 });
83
+ return { status: 'ok', path };
84
+ }
85
+ catch (error) {
86
+ return {
87
+ status: 'failed',
88
+ path,
89
+ reason: error instanceof Error ? error.message : String(error),
90
+ };
91
+ }
92
+ }
93
+ /**
94
+ * Clear the last-seen marker — used by `pugi release-notes --reset`
95
+ * so the operator can force the full bundled changelog к re-render.
96
+ * Returns `absent` when the marker did not exist, `cleared` on
97
+ * success, `failed` on permission errors.
98
+ */
99
+ export function clearLastSeenVersion(home) {
100
+ const path = lastSeenVersionPath(home);
101
+ try {
102
+ if (!existsSync(path)) {
103
+ return { status: 'absent', path };
104
+ }
105
+ unlinkSync(path);
106
+ return { status: 'cleared', path };
107
+ }
108
+ catch (error) {
109
+ return {
110
+ status: 'failed',
111
+ path,
112
+ reason: error instanceof Error ? error.message : String(error),
113
+ };
114
+ }
115
+ }
116
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Codebase survey for the `/init` interview - Phase 2.
3
+ *
4
+ * Inspired by Claude Code's /init Phase 2 (the upstream spawns a
5
+ * Task-tool subagent to read manifest files + CI config + existing
6
+ * agent rules and produce a structured "what this repo is" digest).
7
+ * Independent implementation: Pugi's subagent infra is admin-api
8
+ * scheduled and not available in the local REPL boot path, so the
9
+ * survey runs as a direct filesystem scan instead. The shape of the
10
+ * output matches the upstream pattern - manifest, languages, build /
11
+ * test / lint commands, existing AI-tool configs - so the downstream
12
+ * Phase 3 / Phase 4 logic stays portable.
13
+ *
14
+ * # Design notes
15
+ *
16
+ * - Pure fs reads. No spawn, no network, no LLM call. Safe to run on
17
+ * every `/init` invocation without rate-limit concern.
18
+ * - Bounded: every read caps at 16 KB to defend against an enormous
19
+ * manifest pinning memory. Real package.json / pyproject.toml are
20
+ * well under that.
21
+ * - Defensive: a missing or unreadable file maps to `undefined` in the
22
+ * returned record. Phase 3 treats unknowns as "ask the operator".
23
+ * - Manifest grammar is closed: package.json (Node) and pyproject.toml
24
+ * (Python) are recognised explicitly because Pugi customers ship one
25
+ * of those nine times out of ten. Cargo.toml / go.mod / pom.xml are
26
+ * detected by filename only - we surface "rust"/"go"/"java" as the
27
+ * language hint but do not parse them, because the Phase 3 question
28
+ * set asks for build commands directly when the manifest is opaque.
29
+ *
30
+ * # What we collect
31
+ *
32
+ * 1. `manifest`: which manifest file was found (closed enum).
33
+ * 2. `languages`: deduped list inferred from manifest + file extension
34
+ * heuristics under the workspace root (one-level deep scan).
35
+ * 3. `packageManager`: pnpm / npm / yarn (Node only) or `unknown`.
36
+ * 4. `buildCommand` / `testCommand` / `lintCommand`: parsed out of
37
+ * `package.json` scripts when a Node manifest is present.
38
+ * 5. `aiToolConfigs`: a record of which sibling-agent config files
39
+ * already exist (CLAUDE.md, AGENTS.md, .cursorrules,
40
+ * .github/copilot-instructions.md, .windsurfrules, .clinerules,
41
+ * .mcp.json). Phase 4 mines these for "important parts" without
42
+ * duplicating them into PUGI.md.
43
+ * 6. `hasReadme`, `hasGit`, `hasCi`: simple booleans for the
44
+ * gap-question logic.
45
+ * 7. `hasExistingPugiMd`: true when re-running `/init` against a
46
+ * workspace that already produced PUGI.md.
47
+ *
48
+ * # Why not spawn a Pugi subagent
49
+ *
50
+ * The Pugi subagent dispatcher (apps/pugi-cli/src/core/subagents/)
51
+ * speaks to admin-api over the SSE transport. Running the codebase
52
+ * survey through that path would (a) burn a tenant token quota on
53
+ * every `/init`, (b) require an online connection, and (c) round-trip
54
+ * structured data through the persona prompt - which is the wrong
55
+ * tool for "list which files exist". A direct fs scan is faster,
56
+ * deterministic, and works offline. The upstream Task-tool decision
57
+ * makes sense in a hosted product where every operation is metered;
58
+ * Pugi runs locally so we keep the survey local too.
59
+ */
60
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
61
+ import { join } from 'node:path';
62
+ /**
63
+ * Maximum bytes the survey reads from any single file. Real manifests
64
+ * are tiny (<8 KB); this cap defends against a hostile or accidental
65
+ * gigabyte JSON pinning the REPL boot.
66
+ */
67
+ const MAX_READ_BYTES = 16 * 1024;
68
+ /**
69
+ * Top-level directory scan budget. The survey looks at the workspace
70
+ * root + at most this many entries when inferring languages from file
71
+ * extensions. Deep walks are not needed - the manifest is the source
72
+ * of truth for the active stack.
73
+ */
74
+ const MAX_TOP_LEVEL_ENTRIES = 200;
75
+ /**
76
+ * Run a codebase survey against `workspaceRoot`. Pure: returns a
77
+ * snapshot record; the caller decides how to render it.
78
+ */
79
+ export function surveyCodebase(workspaceRoot) {
80
+ const errors = [];
81
+ const manifest = detectManifest(workspaceRoot);
82
+ const packageJson = manifest === 'package.json'
83
+ ? safeReadJson(join(workspaceRoot, 'package.json'), errors)
84
+ : undefined;
85
+ const packageManager = inferPackageManager(workspaceRoot, packageJson);
86
+ const languages = inferLanguages(workspaceRoot, manifest, errors);
87
+ const scripts = (packageJson && typeof packageJson === 'object' && packageJson !== null
88
+ ? packageJson.scripts
89
+ : undefined) ?? {};
90
+ const buildCommand = pickScript(scripts, ['build', 'compile']);
91
+ const testCommand = pickScript(scripts, ['test', 'tests']);
92
+ const lintCommand = pickScript(scripts, ['lint', 'check']);
93
+ const formatCommand = pickScript(scripts, ['format', 'fmt', 'prettier']);
94
+ const aiToolConfigs = scanAiToolConfigs(workspaceRoot);
95
+ return {
96
+ workspaceRoot,
97
+ manifest,
98
+ packageManager,
99
+ languages,
100
+ buildCommand,
101
+ testCommand,
102
+ lintCommand,
103
+ formatCommand,
104
+ aiToolConfigs,
105
+ hasReadme: existsSafe(join(workspaceRoot, 'README.md')) ||
106
+ existsSafe(join(workspaceRoot, 'readme.md')),
107
+ hasGit: existsSafe(join(workspaceRoot, '.git')),
108
+ hasCi: detectCi(workspaceRoot),
109
+ hasExistingPugiMd: aiToolConfigs['PUGI.md'],
110
+ readErrors: errors,
111
+ };
112
+ }
113
+ /* ------------------------------------------------------------------ */
114
+ /* Manifest detection */
115
+ /* ------------------------------------------------------------------ */
116
+ const MANIFEST_PROBE_ORDER = Object.freeze([
117
+ 'package.json',
118
+ 'pyproject.toml',
119
+ 'Cargo.toml',
120
+ 'go.mod',
121
+ 'pom.xml',
122
+ 'Gemfile',
123
+ 'composer.json',
124
+ ]);
125
+ function detectManifest(root) {
126
+ for (const candidate of MANIFEST_PROBE_ORDER) {
127
+ if (existsSafe(join(root, candidate)))
128
+ return candidate;
129
+ }
130
+ return 'unknown';
131
+ }
132
+ function inferPackageManager(root, packageJson) {
133
+ // package.json `packageManager` field wins when present (corepack convention).
134
+ if (packageJson &&
135
+ typeof packageJson === 'object' &&
136
+ packageJson !== null &&
137
+ 'packageManager' in packageJson) {
138
+ const declared = packageJson.packageManager;
139
+ if (typeof declared === 'string') {
140
+ if (declared.startsWith('pnpm@'))
141
+ return 'pnpm';
142
+ if (declared.startsWith('yarn@'))
143
+ return 'yarn';
144
+ if (declared.startsWith('npm@'))
145
+ return 'npm';
146
+ if (declared.startsWith('bun@'))
147
+ return 'bun';
148
+ }
149
+ }
150
+ // Lockfile fallback.
151
+ if (existsSafe(join(root, 'pnpm-lock.yaml')))
152
+ return 'pnpm';
153
+ if (existsSafe(join(root, 'yarn.lock')))
154
+ return 'yarn';
155
+ if (existsSafe(join(root, 'bun.lockb')) || existsSafe(join(root, 'bun.lock')))
156
+ return 'bun';
157
+ if (existsSafe(join(root, 'package-lock.json')))
158
+ return 'npm';
159
+ return 'unknown';
160
+ }
161
+ /* ------------------------------------------------------------------ */
162
+ /* Language inference */
163
+ /* ------------------------------------------------------------------ */
164
+ const EXT_TO_LANG = Object.freeze({
165
+ '.ts': 'typescript',
166
+ '.tsx': 'typescript',
167
+ '.js': 'javascript',
168
+ '.jsx': 'javascript',
169
+ '.mjs': 'javascript',
170
+ '.cjs': 'javascript',
171
+ '.py': 'python',
172
+ '.rs': 'rust',
173
+ '.go': 'go',
174
+ '.java': 'java',
175
+ '.kt': 'kotlin',
176
+ '.swift': 'swift',
177
+ '.rb': 'ruby',
178
+ '.php': 'php',
179
+ '.cs': 'csharp',
180
+ '.cpp': 'cpp',
181
+ '.c': 'c',
182
+ });
183
+ const MANIFEST_TO_LANG = Object.freeze({
184
+ 'package.json': ['javascript'],
185
+ 'pyproject.toml': ['python'],
186
+ 'Cargo.toml': ['rust'],
187
+ 'go.mod': ['go'],
188
+ 'pom.xml': ['java'],
189
+ 'Gemfile': ['ruby'],
190
+ 'composer.json': ['php'],
191
+ 'unknown': [],
192
+ });
193
+ function inferLanguages(root, manifest, errors) {
194
+ const collected = new Set(MANIFEST_TO_LANG[manifest]);
195
+ // Top-level extension scan, bounded.
196
+ try {
197
+ const entries = readdirSync(root);
198
+ let scanned = 0;
199
+ for (const entry of entries) {
200
+ if (scanned >= MAX_TOP_LEVEL_ENTRIES)
201
+ break;
202
+ scanned += 1;
203
+ // Skip dotfiles + common dependency dirs - they pollute the
204
+ // language inference with build/cache content.
205
+ if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist')
206
+ continue;
207
+ const dot = entry.lastIndexOf('.');
208
+ if (dot <= 0)
209
+ continue;
210
+ const ext = entry.slice(dot);
211
+ const lang = EXT_TO_LANG[ext];
212
+ if (lang)
213
+ collected.add(lang);
214
+ }
215
+ }
216
+ catch (error) {
217
+ errors.push(`readdir ${root}: ${normalizeError(error)}`);
218
+ }
219
+ // `typescript` implies `javascript` runtime; keep both so the
220
+ // interview can ask "compiled-with vs run-with" if needed.
221
+ return Array.from(collected).sort();
222
+ }
223
+ /* ------------------------------------------------------------------ */
224
+ /* Script picker */
225
+ /* ------------------------------------------------------------------ */
226
+ function pickScript(scripts, candidates) {
227
+ for (const key of candidates) {
228
+ const value = scripts[key];
229
+ if (typeof value === 'string' && value.trim().length > 0) {
230
+ // Surface the npm-style invocation so Phase 4 can quote it
231
+ // verbatim. The package manager name is filled in by the caller
232
+ // once it has resolved `packageManager`.
233
+ return key;
234
+ }
235
+ }
236
+ return undefined;
237
+ }
238
+ /* ------------------------------------------------------------------ */
239
+ /* AI tool config scan */
240
+ /* ------------------------------------------------------------------ */
241
+ const AI_TOOL_CONFIG_PATHS = Object.freeze([
242
+ 'CLAUDE.md',
243
+ 'CLAUDE.local.md',
244
+ 'AGENTS.md',
245
+ '.cursorrules',
246
+ '.cursor/rules',
247
+ '.github/copilot-instructions.md',
248
+ '.windsurfrules',
249
+ '.clinerules',
250
+ '.mcp.json',
251
+ 'PUGI.md',
252
+ 'PUGI.local.md',
253
+ ]);
254
+ function scanAiToolConfigs(root) {
255
+ const result = {};
256
+ for (const rel of AI_TOOL_CONFIG_PATHS) {
257
+ result[rel] = existsSafe(join(root, rel));
258
+ }
259
+ return Object.freeze(result);
260
+ }
261
+ /* ------------------------------------------------------------------ */
262
+ /* CI detection */
263
+ /* ------------------------------------------------------------------ */
264
+ const CI_PROBE_PATHS = Object.freeze([
265
+ '.github/workflows',
266
+ '.gitlab-ci.yml',
267
+ '.circleci/config.yml',
268
+ 'azure-pipelines.yml',
269
+ '.travis.yml',
270
+ '.buildkite',
271
+ ]);
272
+ function detectCi(root) {
273
+ return CI_PROBE_PATHS.some((rel) => existsSafe(join(root, rel)));
274
+ }
275
+ /* ------------------------------------------------------------------ */
276
+ /* Safe IO helpers */
277
+ /* ------------------------------------------------------------------ */
278
+ function existsSafe(path) {
279
+ try {
280
+ return existsSync(path);
281
+ }
282
+ catch {
283
+ return false;
284
+ }
285
+ }
286
+ function safeReadJson(path, errors) {
287
+ try {
288
+ const stats = statSync(path);
289
+ if (!stats.isFile())
290
+ return undefined;
291
+ if (stats.size > MAX_READ_BYTES) {
292
+ errors.push(`oversize ${path}: ${stats.size} bytes`);
293
+ return undefined;
294
+ }
295
+ const raw = readFileSync(path, 'utf8');
296
+ return JSON.parse(raw);
297
+ }
298
+ catch (error) {
299
+ errors.push(`read ${path}: ${normalizeError(error)}`);
300
+ return undefined;
301
+ }
302
+ }
303
+ function normalizeError(error) {
304
+ if (error instanceof Error)
305
+ return error.message;
306
+ return String(error);
307
+ }
308
+ //# sourceMappingURL=codebase-survey.js.map
@@ -31,6 +31,7 @@
31
31
  * keys stay readable English (`brief`, `ts`). No forbidden words.
32
32
  */
33
33
  import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync, unlinkSync, } from 'node:fs';
34
+ import { randomBytes } from 'node:crypto';
34
35
  import { homedir } from 'node:os';
35
36
  import { dirname, join } from 'node:path';
36
37
  /** Cap on stored entries per workspace. Drops oldest on overflow. */
@@ -77,7 +78,16 @@ export function append(input) {
77
78
  // sibling guarantees that). P2 fix from PR #335 triple-review.
78
79
  if (existing.length + 1 > MAX_HISTORY_ENTRIES) {
79
80
  const trimmed = [...existing.slice(existing.length + 1 - MAX_HISTORY_ENTRIES), entry];
80
- const tmpPath = `${path}.tmp`;
81
+ // β1b #52 (2026-05-26): unique-per-call tmp suffix.
82
+ // Previous form was a fixed `${path}.tmp`, which means two CLI
83
+ // processes hitting the overflow rewrite at the same moment race
84
+ // on the same sibling file. Whichever writeFileSync lands second
85
+ // can corrupt the renameSync target's content (one process's
86
+ // serialized buffer overwrites the other mid-flight). Append a
87
+ // pid + monotonic-ish timestamp + 8 hex random bytes so the tmp
88
+ // names are collision-proof across PIDs, concurrent calls inside
89
+ // one PID, and rapid re-runs that share the same ms timestamp.
90
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`;
81
91
  try {
82
92
  writeFileSync(tmpPath, trimmed.map(serialize).join('\n') + '\n', { mode: 0o600 });
83
93
  renameSync(tmpPath, path);