@gavin-hu/my-pi-agent 0.18.0 → 0.20.0

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 (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +561 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -0,0 +1,54 @@
1
+ # Signals, and what confirms one
2
+
3
+ Each row is a reason to look, never a finding. A signal becomes a candidate only when
4
+ the evidence column has something in it — a path with line numbers, a counted
5
+ duplicate, a measured size, an import edge, or a named test gap. If you cannot point
6
+ at it, drop it and write why in the rejected list.
7
+
8
+ ## The table
9
+
10
+ | Signal | What to look for | Evidence that confirms it |
11
+ |---|---|---|
12
+ | Duplication | The same body or a near-identical block in several places; copy-paste with small edits; a helper that already exists in a shared location | Paths and line ranges of each copy, and the shared location it should use |
13
+ | Size and responsibility | A module or function past the project's stated limit, or a relative outlier; unrelated exports; deep nesting; long parameter lists | The measured size or the responsibility list, and the seams where it could split |
14
+ | Coupling | Circular or layer-crossing imports, high fan-in or fan-out, feature envy, repeated type switches | The import edge or the repeated branch, with both sides named |
15
+ | Cohesion and drift | A grab-bag utility module; a name that no longer matches behavior; dead exports; commented-out code; two implementations of one concept | The mismatch or unused export, with its callers |
16
+ | Change pressure | High recent churn combined with complexity or weak tests | The churn count over a stated window, plus the complexity or the test gap |
17
+ | Shotgun surgery | One concept edited in several files every time it changes — a new field added in five places, a new enum member handled in four | Two or more recent commits touching the same set of files for one change, named by commit |
18
+ | Test friction | Behavior with no direct test; tests that mock the unit heavily or assert internals; duplicated setup that hides a missing seam | The test path, and the missing or brittle assertion |
19
+ | Boundary erosion | Leaking internal types, unchecked casts, mode-selecting boolean parameters, null-heavy returns | The boundary location and the caller that must know the internals |
20
+ | Doc-versus-code drift | A README, guide, or instruction file that promises something the code no longer does | The doc line and the code line that disagree; the change is a promise, not a preference |
21
+
22
+ ## What the inventory script measures, and what it does not
23
+
24
+ `scripts/inventory.ts` prints, for the scope: file and code-line counts, the size
25
+ distribution and its p90, exports, fan-in from resolvable relative imports, churn over
26
+ a stated window, test proximity by convention, and the files above p90 in any of those
27
+ columns. That is the cheap half.
28
+
29
+ It does **not** measure anything per symbol: nesting depth, parameter counts, function
30
+ length, or complexity are not in the report. Read the outlier files for those, and say
31
+ in the report which ones you read.
32
+
33
+ It reads imports and exports for TypeScript, JavaScript, Python, Go, and Rust. Any
34
+ other language is counted in the `skipped` column rather than guessed at — so in a
35
+ Ruby, PHP, Java, Kotlin, Swift, C, or C# tree, fan-in and export counts are not
36
+ evidence and belong in "not examined" unless you measured them by hand.
37
+
38
+ Two more honest limits, both worth repeating in the report when they apply:
39
+
40
+ - **A shallow clone has no churn history.** The script says `no history` rather than
41
+ zero, because zero and unknown rank a file very differently.
42
+ - **An unresolved specifier is not a missing import.** It means the audit could not
43
+ resolve it — a path alias, a generated module, a language the scanner does not read.
44
+ Name the count, do not treat it as an orphan.
45
+
46
+ ## Confirm before keeping
47
+
48
+ Read the callers and the tests, not just the definition. Duplication may be deliberate
49
+ for a protocol version; a large module may be cohesive; an "unused" export may be
50
+ public API; a hot file may be hot because it is the only place that already works.
51
+
52
+ Keep pure refactors separate from behavior changes and bugs. Report a bug as a bug.
53
+ Prefer a candidate with a test that would catch a regression; when the behavior is
54
+ untested, mark the candidate "tests first" instead of dropping it.
@@ -0,0 +1,587 @@
1
+ /**
2
+ * Codebase inventory, for the `refactor` skill.
3
+ *
4
+ * The skill's judgement is the model's; the measuring is not. The audit used to ask
5
+ * for "file and function sizes, nesting depth, parameter counts, export counts,
6
+ * fan-in and fan-out" in prose, which no one can do honestly by hand past three files.
7
+ * This prints those facts for one scope, so a candidate's evidence is a number with a
8
+ * source instead of an impression.
9
+ *
10
+ * It prints facts, never a verdict and never a score: a numeric score would be this
11
+ * script inventing the threshold the skill tells the model not to invent. The
12
+ * "outliers" list is a rule, not a ranking — a file is there because it is above the
13
+ * 90th percentile in lines, fan-in, or churn, or among the largest with no test — and
14
+ * every column is printed so the reader can see which rule put it there.
15
+ *
16
+ * What it cannot measure, it says: a language it cannot read is `skipped` by
17
+ * extension, a specifier it cannot resolve is counted, and a shallow clone has no
18
+ * churn history rather than a churn of zero.
19
+ *
20
+ * It ships inside the skill and resolves against the skill's own directory, so it
21
+ * imports nothing from the package's `lib/`: an installed copy has `skills/` and
22
+ * nothing else.
23
+ *
24
+ * Usage: `bun <skill>/scripts/inventory.ts [path ...] [--churn-days N] [--limit N]`
25
+ */
26
+
27
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
28
+ import { join, posix, relative, sep } from "node:path";
29
+
30
+ /** Extensions this script measures. Everything else is reported as `skipped`. */
31
+ export const SOURCE_LANGUAGES = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs"] as const;
32
+
33
+ /** Directories a walk outside a repository never descends into. */
34
+ export const SKIP_DIRS = [
35
+ ".git",
36
+ "node_modules",
37
+ "dist",
38
+ "build",
39
+ "out",
40
+ "target",
41
+ "vendor",
42
+ ".venv",
43
+ "venv",
44
+ "__pycache__",
45
+ ".next",
46
+ "coverage",
47
+ ];
48
+
49
+ /** Defaults the report states, so the audit's budget is visible rather than implied. */
50
+ export const DEFAULT_CHURN_DAYS = 90;
51
+ export const DEFAULT_LIMIT = 8;
52
+
53
+ /** One source file, measured. `path` is relative to the scope root, forward slashes. */
54
+ export interface FileFacts {
55
+ path: string;
56
+ language: string;
57
+ /** Physical lines. */
58
+ lines: number;
59
+ /** Lines that are neither blank nor a comment. */
60
+ codeLines: number;
61
+ exports: number;
62
+ /** Specifiers as written, before resolution. */
63
+ imports: string[];
64
+ /** TODO and FIXME markers, which are drift the store reports rather than fixes. */
65
+ markers: number;
66
+ }
67
+
68
+ /** What one file's history says: how often it changed, and how much of it. */
69
+ export interface Churn {
70
+ commits: number;
71
+ insertions: number;
72
+ deletions: number;
73
+ }
74
+
75
+ /** How the scope was measured: sizes, edges, history, and tests. */
76
+ export interface Report {
77
+ languages: { name: string; count: number }[];
78
+ skipped: { name: string; count: number }[];
79
+ sizes: { median: number; p90: number; max: { path: string; lines: number } };
80
+ fanIn: { max: { path: string; count: number }; external: number; unresolved: number };
81
+ churn: {
82
+ available: boolean;
83
+ window: number;
84
+ charged: number;
85
+ median: number;
86
+ p90: number;
87
+ max?: { path: string; commits: number };
88
+ };
89
+ tests: { sourceFiles: number; coLocated: number; largestUntested: string[] };
90
+ /** Above the 90th percentile in one column, or among the largest with no test. */
91
+ outliers: { path: string; lines: number; exports: number; fanIn: number; commits: number; test: string }[];
92
+ }
93
+
94
+ /** Options a caller may override; the report prints both defaults. */
95
+ export interface Options {
96
+ churnDays?: number;
97
+ limit?: number;
98
+ }
99
+
100
+ /** The extension without its dot, lowercased; the language key every table uses. */
101
+ export function languageOf(path: string): string {
102
+ const name = path.split("/").pop() ?? path;
103
+ const dot = name.lastIndexOf(".");
104
+ return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
105
+ }
106
+
107
+ /** Whether this script measures the language, by extension alone. */
108
+ function measured(language: string): boolean {
109
+ return (SOURCE_LANGUAGES as readonly string[]).includes(language);
110
+ }
111
+
112
+ /** A comment line in any of the measured languages; `*` continues a block. */
113
+ const COMMENT = /^\s*(?:\/\/|\/\*|\*|#)/;
114
+ /** An exported declaration, per language family. */
115
+ const EXPORTS: Record<string, RegExp> = {
116
+ es: /^\s*(?:export\s|(?:module\.)?exports[.\s=])/,
117
+ py: /^(?:def|class)\s+\w/,
118
+ go: /^(?:func\s+[A-Z]|type\s+[A-Z])/,
119
+ rs: /^\s*pub\s+(?:fn|struct|enum|trait|mod|const|static|type)\b/,
120
+ };
121
+ const MARKER = /\b(?:TODO|FIXME|XXX)\b/;
122
+
123
+ /** The export/import family a language belongs to, which is what the scanners key on. */
124
+ function familyOf(language: string): "es" | "py" | "go" | "rs" | undefined {
125
+ if (["ts", "tsx", "js", "jsx", "mjs", "cjs"].includes(language)) return "es";
126
+ if (language === "py") return "py";
127
+ if (language === "go") return "go";
128
+ if (language === "rs") return "rs";
129
+ return undefined;
130
+ }
131
+
132
+ /** Every import specifier on one line, for the line's family. */
133
+ function specifiersOf(line: string, family: string): string[] {
134
+ if (family === "es") {
135
+ return [
136
+ ...line.matchAll(/(?:from|require\(|import\()\s*["']([^"']+)["']/g),
137
+ ...line.matchAll(/^\s*import\s+["']([^"']+)["']/g),
138
+ ].map((match) => match[1]);
139
+ }
140
+ if (family === "py") {
141
+ const from = /^\s*from\s+([\w.]+)\s+import\b/.exec(line);
142
+ if (from !== null) return [from[1]];
143
+ const plain = /^\s*import\s+([\w.,\s]+)$/.exec(line);
144
+ return plain === null ? [] : plain[1].split(",").map((part) => part.trim().split(/\s+/)[0]);
145
+ }
146
+ if (family === "rs") {
147
+ const use = /^\s*use\s+([\w:]+)/.exec(line);
148
+ return use === null ? [] : [use[1]];
149
+ }
150
+ return [];
151
+ }
152
+
153
+ /** Measure one file's text. A language this script cannot read still gets a row, so the
154
+ * report can name what it skipped instead of dropping it silently. */
155
+ export function scanFile(path: string, text: string): FileFacts {
156
+ const language = languageOf(path);
157
+ const family = familyOf(language);
158
+ const lines = text.split("\n");
159
+ const facts: FileFacts = { path, language, lines: lines.length, codeLines: 0, exports: 0, imports: [], markers: 0 };
160
+ if (lines[lines.length - 1] === "") facts.lines--;
161
+ if (family === undefined) return facts;
162
+
163
+ let inGoImport = false;
164
+ for (const line of lines) {
165
+ const blank = line.trim() === "";
166
+ if (!blank && !COMMENT.test(line)) facts.codeLines++;
167
+ if (MARKER.test(line)) facts.markers++;
168
+ if (EXPORTS[family].test(line)) facts.exports++;
169
+
170
+ if (family === "go") {
171
+ if (/^\s*import\s*\(/.test(line)) {
172
+ inGoImport = true;
173
+ continue;
174
+ }
175
+ if (inGoImport && /^\s*\)/.test(line)) {
176
+ inGoImport = false;
177
+ continue;
178
+ }
179
+ if (inGoImport) {
180
+ const quoted = /"([^"]+)"/.exec(line);
181
+ if (quoted !== null) facts.imports.push(quoted[1]);
182
+ continue;
183
+ }
184
+ const single = /^\s*import\s+(?:[\w.]+\s+)?"([^"]+)"/.exec(line);
185
+ if (single !== null) facts.imports.push(single[1]);
186
+ continue;
187
+ }
188
+ facts.imports.push(...specifiersOf(line, family));
189
+ }
190
+ return facts;
191
+ }
192
+
193
+ /** Extensions a relative specifier may omit, per family. */
194
+ const EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
195
+
196
+ /** Candidate files a specifier could mean, most specific first. */
197
+ function candidatesOf(from: string, specifier: string): string[] {
198
+ const base = posix.join(posix.dirname(from), specifier);
199
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
200
+ const stripped = base.replace(/\.(?:js|jsx|mjs|cjs)$/, "");
201
+ return [
202
+ base,
203
+ ...EXTENSIONS.map((extension) => `${stripped}${extension}`),
204
+ ...EXTENSIONS.map((extension) => posix.join(base, `index${extension}`)),
205
+ posix.join(base, "index.ts"),
206
+ `${base}.py`,
207
+ posix.join(base, "__init__.py"),
208
+ ];
209
+ }
210
+ return [];
211
+ }
212
+
213
+ /**
214
+ * In-edges from relative imports that resolve to a file in the scope, plus what could
215
+ * not be measured: `external` package specifiers and `unresolved` relative ones.
216
+ */
217
+ export function resolveImports(
218
+ files: readonly FileFacts[],
219
+ knownPaths?: Iterable<string>,
220
+ ): {
221
+ fanIn: Map<string, number>;
222
+ external: number;
223
+ unresolved: number;
224
+ } {
225
+ const known = new Set(knownPaths ?? files.map((file) => file.path));
226
+ const fanIn = new Map<string, number>();
227
+ let external = 0;
228
+ let unresolved = 0;
229
+
230
+ for (const file of files) {
231
+ for (const specifier of file.imports) {
232
+ if (!specifier.startsWith(".")) {
233
+ external++;
234
+ continue;
235
+ }
236
+ const target = candidatesOf(file.path, specifier).find((candidate) => known.has(candidate));
237
+ if (target === undefined) {
238
+ unresolved++;
239
+ continue;
240
+ }
241
+ fanIn.set(target, (fanIn.get(target) ?? 0) + 1);
242
+ }
243
+ }
244
+ return { fanIn, external, unresolved };
245
+ }
246
+
247
+ /** Test files a source file's convention implies, in the order the convention is tried. */
248
+ function testCandidatesOf(path: string): string[] {
249
+ const directory = posix.dirname(path);
250
+ const base = posix.basename(path).replace(/\.[^.]+$/, "");
251
+ const up = posix.join(directory, "..");
252
+ return [
253
+ posix.join(directory, `${base}.test.ts`),
254
+ posix.join(directory, `${base}.test.tsx`),
255
+ posix.join(directory, `${base}.test.js`),
256
+ posix.join(directory, `${base}.spec.ts`),
257
+ posix.join(directory, `${base}.spec.js`),
258
+ posix.join(directory, `${base}_test.go`),
259
+ posix.join(directory, `${base}_test.rs`),
260
+ posix.join(directory, `test_${base}.py`),
261
+ posix.join(directory, `${base}_test.py`),
262
+ posix.join(directory, "tests", `test_${base}.py`),
263
+ posix.join(up, "test", `${base}.test.ts`),
264
+ posix.join(up, "tests", `${base}_test.go`),
265
+ ];
266
+ }
267
+
268
+ /** Whether a path is itself a test, by the same conventions proximity looks for. */
269
+ export function isTestFile(path: string): boolean {
270
+ const name = path.split("/").pop() ?? path;
271
+ return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(name) || /_test\.(?:go|rs|py)$/.test(name) || /^test_.*\.py$/.test(name);
272
+ }
273
+
274
+ /** The test file each source file finds by convention, or none. */
275
+ export function testProximity(files: readonly FileFacts[]): Map<string, string> {
276
+ const known = new Set(files.map((file) => file.path));
277
+ const found = new Map<string, string>();
278
+ for (const file of files) {
279
+ const test = testCandidatesOf(file.path).find((candidate) => known.has(candidate));
280
+ if (test !== undefined) found.set(file.path, test);
281
+ }
282
+ return found;
283
+ }
284
+
285
+ /** A rename's destination, so history aggregates on the path a reader will open. */
286
+ function numstatPath(path: string): string {
287
+ const arrow = path.indexOf(" => ");
288
+ if (arrow === -1) return path;
289
+ const prefix = path.slice(0, path.lastIndexOf("/", arrow) + 1) || "";
290
+ const destination = path.slice(arrow + 4);
291
+ return `${prefix}${destination.replace(/}$/, "")}`.replace(/[{}]/g, "");
292
+ }
293
+
294
+ /**
295
+ * `git log --numstat` output into per-file churn. Any line that is not a numstat row is
296
+ * a commit boundary, so the caller's `--format` needs no shared constant; a binary
297
+ * change prints `-` for its counts, so it counts as a commit and no lines.
298
+ */
299
+ export function parseNumstat(text: string): Map<string, Churn> {
300
+ const churn = new Map<string, Churn>();
301
+ const touched = new Set<string>();
302
+ const close = (): void => {
303
+ for (const path of touched) churn.get(path)!.commits++;
304
+ touched.clear();
305
+ };
306
+ for (const line of text.split("\n")) {
307
+ if (line.trim() === "") continue;
308
+ const columns = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
309
+ if (columns === null) {
310
+ close();
311
+ continue;
312
+ }
313
+ const path = numstatPath(columns[3]);
314
+ const entry = churn.get(path) ?? { commits: 0, insertions: 0, deletions: 0 };
315
+ if (columns[1] !== "-") entry.insertions += Number(columns[1]);
316
+ if (columns[2] !== "-") entry.deletions += Number(columns[2]);
317
+ churn.set(path, entry);
318
+ touched.add(path);
319
+ }
320
+ close();
321
+ return churn;
322
+ }
323
+
324
+ /** The value at the given percentile, nearest-rank, over a sorted copy. */
325
+ export function percentile(values: readonly number[], rank: number): number {
326
+ if (values.length === 0) return 0;
327
+ const sorted = [...values].sort((left, right) => left - right);
328
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((rank / 100) * sorted.length) - 1));
329
+ return sorted[index];
330
+ }
331
+
332
+ /** The top `limit` entries by count, with a stable tie-break on path. */
333
+ function head(entries: readonly { path: string; count: number }[], limit: number): { path: string; count: number }[] {
334
+ return [...entries]
335
+ .sort((left, right) => right.count - left.count || left.path.localeCompare(right.path))
336
+ .slice(0, limit);
337
+ }
338
+
339
+ /** What `summarize` reads: the scope, the graph universe, the history, and the budget. */
340
+ export interface Input {
341
+ /** In scope, measured or not — an unmeasured language is named in `skipped`. */
342
+ files: readonly FileFacts[];
343
+ /** Repo-wide, for the import graph, so an edge leaving the scope still resolves. */
344
+ universe?: readonly FileFacts[];
345
+ /** Every file the project lists, so a specifier to a non-source neighbour resolves. */
346
+ knownPaths?: readonly string[];
347
+ churn?: Map<string, Churn>;
348
+ options?: Options;
349
+ /** False when no history was read, which is not the same as a churn of zero. */
350
+ history?: boolean;
351
+ }
352
+
353
+ /**
354
+ * Summarize a scope: distribution, the maxima, the churn window, test proximity, and
355
+ * the outlier rule. A test file is measured but never a candidate: its size is code
356
+ * volume, and "it has no test" would be a tautology.
357
+ */
358
+ export function summarize(input: Input): Report {
359
+ const { files, universe = files, knownPaths, churn = new Map<string, Churn>(), history = true } = input;
360
+ const options = input.options ?? {};
361
+ const limit = options.limit ?? DEFAULT_LIMIT;
362
+ const churnDays = options.churnDays ?? DEFAULT_CHURN_DAYS;
363
+ const measuredFiles = files.filter((file) => measured(file.language));
364
+ const candidates = measuredFiles.filter((file) => !isTestFile(file.path));
365
+ // Fan-in is repo-wide, because a file's dependents do not live in the scope; the
366
+ // external and unresolved counts are the scope's own, because they are its edges.
367
+ const { fanIn } = resolveImports(
368
+ universe.filter((file) => measured(file.language)),
369
+ knownPaths,
370
+ );
371
+ const { external, unresolved } = resolveImports(measuredFiles, knownPaths);
372
+ const tests = testProximity(files);
373
+ const fanInOf = (path: string): number => fanIn.get(path) ?? 0;
374
+ const commitsOf = (path: string): number => churn.get(path)?.commits ?? 0;
375
+
376
+ const languages = [...new Set(measuredFiles.map((file) => file.language))]
377
+ .map((name) => ({ name, count: measuredFiles.filter((file) => file.language === name).length }))
378
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
379
+ const skipped = [...new Set(files.filter((file) => !measured(file.language)).map((file) => file.language))]
380
+ .map((name) => ({ name, count: files.filter((file) => file.language === name).length }))
381
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
382
+
383
+ const lines = measuredFiles.map((file) => file.lines);
384
+ const largest = [...measuredFiles].sort(
385
+ (left, right) => right.lines - left.lines || left.path.localeCompare(right.path),
386
+ )[0];
387
+ const busiest = [...measuredFiles].sort(
388
+ (left, right) => fanInOf(right.path) - fanInOf(left.path) || left.path.localeCompare(right.path),
389
+ )[0];
390
+ const charged = [...churn.entries()].filter(([path]) => measuredFiles.some((file) => file.path === path));
391
+ const busiestChurn = [...charged].sort((left, right) => right[1].commits - left[1].commits)[0];
392
+
393
+ const linesBar = percentile(
394
+ candidates.map((file) => file.lines),
395
+ 90,
396
+ );
397
+ const fanInBar = percentile(
398
+ candidates.map((file) => fanInOf(file.path)),
399
+ 90,
400
+ );
401
+ const churnBar = percentile(
402
+ candidates.map((file) => commitsOf(file.path)),
403
+ 90,
404
+ );
405
+ const largestUntested = head(
406
+ candidates.filter((file) => !tests.has(file.path)).map((file) => ({ path: file.path, count: file.lines })),
407
+ 3,
408
+ ).map((entry) => entry.path);
409
+
410
+ const outliers = candidates
411
+ .filter((file) => file.lines >= linesBar || fanInOf(file.path) >= fanInBar || commitsOf(file.path) >= churnBar)
412
+ .filter(() => linesBar > 0 || fanInBar > 0 || churnBar > 0)
413
+ .sort((left, right) => right.lines - left.lines || left.path.localeCompare(right.path))
414
+ .slice(0, limit)
415
+ .map((file) => ({
416
+ path: file.path,
417
+ lines: file.lines,
418
+ exports: file.exports,
419
+ fanIn: fanInOf(file.path),
420
+ commits: commitsOf(file.path),
421
+ test: tests.has(file.path) ? "test" : "no test",
422
+ }));
423
+
424
+ return {
425
+ languages,
426
+ skipped,
427
+ sizes: {
428
+ median: percentile(lines, 50),
429
+ p90: percentile(lines, 90),
430
+ max: largest === undefined ? { path: "none", lines: 0 } : { path: largest.path, lines: largest.lines },
431
+ },
432
+ fanIn: {
433
+ max: busiest === undefined ? { path: "none", count: 0 } : { path: busiest.path, count: fanInOf(busiest.path) },
434
+ external,
435
+ unresolved,
436
+ },
437
+ churn: {
438
+ available: history,
439
+ window: churnDays,
440
+ charged: charged.length,
441
+ median: percentile(
442
+ charged.map(([, entry]) => entry.commits),
443
+ 50,
444
+ ),
445
+ p90: percentile(
446
+ charged.map(([, entry]) => entry.commits),
447
+ 90,
448
+ ),
449
+ ...(busiestChurn === undefined ? {} : { max: { path: busiestChurn[0], commits: busiestChurn[1].commits } }),
450
+ },
451
+ tests: {
452
+ sourceFiles: candidates.length,
453
+ coLocated: candidates.filter((file) => tests.has(file.path)).length,
454
+ largestUntested,
455
+ },
456
+ outliers,
457
+ };
458
+ }
459
+
460
+ /** A trim with an ellipsis, so one long path cannot wrap the report. */
461
+ function clip(text: string, width: number): string {
462
+ return text.length > width ? `${text.slice(0, width - 1)}…` : text;
463
+ }
464
+
465
+ /** The report. Every line is a fact with its scope, in the order an audit uses them. */
466
+ export function formatReport(scope: string, files: readonly FileFacts[], report: Report, note?: string): string {
467
+ const out: string[] = [];
468
+ const skipped = report.skipped.map((entry) => `${entry.count} ${entry.name}`).join(" · ");
469
+ out.push(`scope ${scope} · ${files.length} files${skipped === "" ? "" : ` · skipped ${skipped}`}`);
470
+ if (note !== undefined) out.push(`note ${note}`);
471
+ out.push(
472
+ `languages ${report.languages.map((entry) => `${entry.name} ${entry.count}`).join(" · ") || "none measured"}`,
473
+ );
474
+ out.push(
475
+ `sizes median ${report.sizes.median} lines · p90 ${report.sizes.p90} · max ${report.sizes.max.lines} ${report.sizes.max.path}`,
476
+ );
477
+ out.push(
478
+ `fan-in max ${report.fanIn.max.count} ${report.fanIn.max.path} · external ${report.fanIn.external} · unresolved ${report.fanIn.unresolved}`,
479
+ );
480
+ out.push(
481
+ report.churn.available
482
+ ? `churn ${report.churn.window}d charged ${report.churn.charged} ${report.churn.charged === 1 ? "file" : "files"} · median ${report.churn.median} · p90 ${report.churn.p90}${
483
+ report.churn.max === undefined ? "" : ` · max ${report.churn.max.commits} ${report.churn.max.path}`
484
+ }`
485
+ : `churn ${report.churn.window}d no history: a shallow clone or a directory outside git`,
486
+ );
487
+ out.push(
488
+ `tests co-located ${report.tests.coLocated}/${report.tests.sourceFiles} source files · largest without one ${
489
+ report.tests.largestUntested[0] ?? "none"
490
+ }`,
491
+ );
492
+ out.push("outliers above p90 in lines, fan-in, or churn");
493
+ for (const entry of report.outliers) {
494
+ out.push(
495
+ ` ${clip(entry.path, 60).padEnd(60)} ${String(entry.lines).padStart(6)} lines · ${String(entry.exports).padStart(3)} exports · fan-in ${entry.fanIn} · ${entry.commits} commits · ${entry.test}`,
496
+ );
497
+ }
498
+ if (report.outliers.length === 0) out.push(" none");
499
+ return `${out.join("\n")}\n`;
500
+ }
501
+
502
+ /** Every file under `root` in a directory outside git, with the tree's own noise skipped. */
503
+ function walk(root: string): string[] {
504
+ const found: string[] = [];
505
+ const visit = (current: string): void => {
506
+ for (const name of readdirSync(current)) {
507
+ const path = join(current, name);
508
+ const stats = statSync(path);
509
+ if (stats.isDirectory()) {
510
+ if (!SKIP_DIRS.includes(name)) visit(path);
511
+ continue;
512
+ }
513
+ found.push(relative(root, path).split(sep).join("/"));
514
+ }
515
+ };
516
+ visit(root);
517
+ return found;
518
+ }
519
+
520
+ /** Run one git command, returning its stdout or a reason it produced none. */
521
+ function git(args: readonly string[], cwd: string): { text?: string; error?: string } {
522
+ const result = Bun.spawnSync({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" });
523
+ if (result.exitCode !== 0) return { error: result.stderr.toString().trim() || `git ${args[0]} failed` };
524
+ return { text: result.stdout.toString() };
525
+ }
526
+
527
+ /** The paths under any of `targets`: a file by name, a directory by prefix, `.` for all. */
528
+ function within(path: string, targets: readonly string[]): boolean {
529
+ if (targets.length === 0) return true;
530
+ return targets.some((target) => target === "." || path === target || path.startsWith(`${target}/`));
531
+ }
532
+
533
+ /** Arguments: paths, a churn window, and a cap. A flag that needs a number says so. */
534
+ export function parseArgs(argv: readonly string[]): { targets: string[]; options: Options } {
535
+ const targets: string[] = [];
536
+ const options: Options = {};
537
+ for (let index = 0; index < argv.length; index++) {
538
+ const argument = argv[index];
539
+ const name = argument.startsWith("--churn-days")
540
+ ? "churnDays"
541
+ : argument.startsWith("--limit")
542
+ ? "limit"
543
+ : undefined;
544
+ if (name === undefined) {
545
+ targets.push(argument.replace(/\/+$/, ""));
546
+ continue;
547
+ }
548
+ const inline = argument.split("=")[1];
549
+ const raw = inline ?? argv[++index];
550
+ const value = Number(raw);
551
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`${argument.split("=")[0]} needs a positive number`);
552
+ options[name] = Math.floor(value);
553
+ }
554
+ return { targets: targets.length === 0 ? ["."] : targets, options };
555
+ }
556
+
557
+ if (import.meta.main) {
558
+ const { targets, options } = parseArgs(process.argv.slice(2));
559
+ const cwd = process.cwd();
560
+ const inside = git(["rev-parse", "--is-inside-work-tree"], cwd).text?.trim() === "true";
561
+ const shallow = inside && git(["rev-parse", "--is-shallow-repository"], cwd).text?.trim() === "true";
562
+ const listed = inside ? git(["ls-files", "--cached", "--others", "--exclude-standard"], cwd) : undefined;
563
+ const note = inside
564
+ ? shallow
565
+ ? "shallow clone: churn history is partial"
566
+ : undefined
567
+ : "no git: ignore rules not applied, and no churn history";
568
+
569
+ const paths = (listed?.text?.split("\n") ?? walk(cwd))
570
+ .map((path) => path.trim())
571
+ .filter((path) => path !== "" && existsSync(join(cwd, ...path.split("/"))));
572
+ const read = (path: string): FileFacts => scanFile(path, readFileSync(join(cwd, ...path.split("/")), "utf-8"));
573
+
574
+ // The graph is repo-wide, so an edge that leaves the scope still resolves; the
575
+ // report itself is scoped, so a neighbour never becomes a candidate.
576
+ const universe = paths.filter((path) => measured(languageOf(path))).map(read);
577
+ const files = paths.filter((path) => within(path, targets)).map(read);
578
+ const window = options.churnDays ?? DEFAULT_CHURN_DAYS;
579
+ const log =
580
+ inside && !shallow
581
+ ? git(["log", `--since=${window} days ago`, "--numstat", "--format=%H", "--", ...targets], cwd)
582
+ : undefined;
583
+ const churn = log?.text === undefined ? new Map<string, Churn>() : parseNumstat(log.text);
584
+
585
+ const report = summarize({ files, universe, knownPaths: paths, churn, options, history: log?.text !== undefined });
586
+ process.stdout.write(formatReport(targets.join(" "), files, report, note));
587
+ }
@@ -1,40 +0,0 @@
1
- /**
2
- * The Extensions section's focused-row pane.
3
- *
4
- * The row, the summary, and the key hints are the shared list grammar; what is
5
- * specific here is the doctor's findings, including the resource path when a row
6
- * has no verdict.
7
- */
8
-
9
- import type { Theme } from "@earendil-works/pi-coding-agent";
10
- import { sanitizeLine } from "../../../../lib/format.ts";
11
- import { clip } from "../../../../lib/tui/columns.ts";
12
- import { paint } from "../../../../lib/tui/tokens.ts";
13
- import { detailLines as reportDetailLines } from "../../doctor/format.ts";
14
- import { rowBadge } from "../format.ts";
15
- import type { PanelRow } from "../types.ts";
16
- import { savedLine } from "./frame.ts";
17
-
18
- /** The focused extension's findings and the pending-changes note. */
19
- export function extensionDetail(
20
- theme: Theme,
21
- width: number,
22
- row: PanelRow | undefined,
23
- budget: number,
24
- expanded: boolean,
25
- dirty: boolean,
26
- ): string[] {
27
- const lines: string[] = [];
28
- const badge = row ? rowBadge(row) : undefined;
29
- // The focused row directly above already carries the entry's name and verdict,
30
- // so the pane starts with what the row cannot say: the findings, or the
31
- // resource path when nothing was reported.
32
- if (badge?.text === undefined && row?.item) {
33
- lines.push(clip(` ${paint(theme, "meta", sanitizeLine(row.item.path))}`, width));
34
- }
35
- if (row?.report && budget > 0) {
36
- lines.push(...reportDetailLines(row.report, theme, width, budget, expanded));
37
- }
38
- lines.push(...savedLine(theme, width, dirty));
39
- return lines;
40
- }