rvlib-shipkit 0.38.1 → 1.0.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 (252) hide show
  1. package/CHANGELOG.md +367 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +209 -15
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,350 @@
1
+ // the project gutter, the serving half: /api/projects.json answers every
2
+ // sibling repo that includes this repo's house wiki index, measured at most
3
+ // once per POLL_MS whatever the number of tabs asking. The release distance
4
+ // and the uncommitted count ask git only when a stat says a commit, a tag or
5
+ // the index moved, a few repos per poll, and a repo's run record is opened
6
+ // only when it moved, so a steady poll spawns ps and lsof and nothing else. The icon route
7
+ // serves only the icon files a discovered repo carries, and POST
8
+ // /api/projects/open opens a discovered repo in VS Code: a request names a
9
+ // repo, never a path. The rail ORDER is one text file for the whole machine
10
+ // (ORDER_FILE), so every hub shows the same order and a hand edit is seen
11
+ // within one poll; POST /api/projects/order writes it.
12
+ import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
13
+ import { readFile, stat } from 'fs/promises'
14
+ import { homedir } from 'os'
15
+ import { dirname, join } from 'path'
16
+ import {
17
+ asksDirOf, branchOf, countSessions, descriptionOf, dirtyCount, gateOf, gitStamp, houseRepos, iconStamp, ICON_TYPES, lastActivity,
18
+ lastRunAt, lastSessionAt, liveUrlOf, mergeOrder, openAsks, openFolders, parseClaudePids, parseDescribe, parseLsofCwd, parseOrder,
19
+ recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type Gate, type WiredRepo,
20
+ } from './project-gutter.ts'
21
+ import { readLastStatusReadonly } from '../ledger/run-log.ts'
22
+
23
+ export type { Gate }
24
+
25
+ export const POLL_MS = 5_000
26
+ /** how long a release answer holds for a checkout whose `.git` is not a
27
+ * folder, since no stat can say when it moved */
28
+ export const RELEASE_TTL_MS = 60_000
29
+ /** git asks per measure, so a cold start spreads its ~20 repos over a few
30
+ * polls instead of spending half a second on the first one */
31
+ export const RELEASE_ASKS_PER_POLL = 6
32
+ /** the rail order, under the user's home: one repo name per line */
33
+ export const ORDER_FILE = '.config/shipkit/projects-order.txt'
34
+
35
+ /** the last release tag, the commits since it and when it was cut (epoch
36
+ * ms), `never` when no tag has a release shape, null when git cannot say */
37
+ export type Release = { tag: string; ahead: number; at: number } | 'never' | null
38
+
39
+ export type ProjectRow = {
40
+ name: string
41
+ path: string
42
+ /** the icon url, or null for the initial on a hashed colour */
43
+ icon: string | null
44
+ current: boolean
45
+ /** null = unknown (no window state on this machine) */
46
+ vscode: boolean | null
47
+ /** null = unknown (no ps or lsof) */
48
+ sessions: number | null
49
+ /** open asks waiting on a human in the repo's queue */
50
+ asks: number
51
+ /** their titles, in file order */
52
+ askTitles: string[]
53
+ /** package.json `description`, else the README's first line of prose */
54
+ description: string | null
55
+ /** package.json `version` */
56
+ version: string | null
57
+ /** the checked out branch, or `detached <sha>` */
58
+ branch: string | null
59
+ /** uncommitted paths as of the last git operation, null = unknown */
60
+ dirty: number | null
61
+ /** the last gate its own run record holds */
62
+ gate: Gate
63
+ release: Release
64
+ /** the deployed url `smoke.base` declares, or null */
65
+ live: string | null
66
+ /** epoch ms of the newest of: the last commit, the last shipkit run, the
67
+ * newest Claude Code transcript. null when none of them exists */
68
+ activity: number | null
69
+ }
70
+ export type ProjectsAnswer = { projects: ProjectRow[]; ms: number; at: number }
71
+
72
+ /** a command's exit code and stdout, or null when its binary is missing */
73
+ export type Run = (cmd: string[], cwd?: string) => Promise<{ code: number; out: string } | null>
74
+
75
+ export type GutterDeps = {
76
+ root: string
77
+ platform?: string
78
+ home?: string
79
+ run?: Run
80
+ /** opens a folder in VS Code; throws with the reason it could not */
81
+ open?: (path: string) => void
82
+ warn?: (line: string) => void
83
+ now?: () => number
84
+ /** where the rail order lives, default ~/ORDER_FILE */
85
+ orderFile?: string
86
+ /** the Claude Code `projects` folders, default the `projects` folder of every ~/.claude config root */
87
+ transcriptRoots?: string[]
88
+ }
89
+
90
+ const runCmd: Run = async (cmd, cwd) => {
91
+ if (Bun.which(cmd[0]!) == null) return null
92
+ const p = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'ignore' })
93
+ const out = await new Response(p.stdout).text()
94
+ return { code: await p.exited, out }
95
+ }
96
+
97
+ function openInCode(path: string): void {
98
+ const code = Bun.which('code')
99
+ if (code != null) {
100
+ Bun.spawn([code, path], { stdio: ['ignore', 'ignore', 'ignore'] }).unref()
101
+ return
102
+ }
103
+ if (process.platform === 'darwin' && Bun.which('open') != null) {
104
+ Bun.spawn(['open', '-a', 'Visual Studio Code', path], { stdio: ['ignore', 'ignore', 'ignore'] }).unref()
105
+ return
106
+ }
107
+ throw new Error('the `code` command is not on PATH: in VS Code, run "Shell Command: Install \'code\' command in PATH"')
108
+ }
109
+
110
+ const epochMs = (out: string | undefined): number | null => {
111
+ const secs = Number(out?.trim())
112
+ return Number.isFinite(secs) && secs > 0 ? secs * 1000 : null
113
+ }
114
+
115
+ /** the rows in the rail order: the saved names first, the rest after */
116
+ const inOrder = (rows: ProjectRow[], saved: string[]): ProjectRow[] => {
117
+ const by = new Map(rows.map((r) => [r.name, r]))
118
+ return mergeOrder(saved, rows.map((r) => r.name)).map((n) => by.get(n)!)
119
+ }
120
+
121
+ const readText = (file: string): string => {
122
+ try {
123
+ return readFileSync(file, 'utf8')
124
+ } catch {
125
+ return ''
126
+ }
127
+ }
128
+
129
+ export function projectGutter(d: GutterDeps) {
130
+ let home = d.root
131
+ const userHome = d.home ?? homedir()
132
+ const run = d.run ?? runCmd
133
+ const warn = d.warn ?? ((l: string) => console.warn(l))
134
+ const now = d.now ?? Date.now
135
+ const storage = vscodeStoragePath(d.platform ?? process.platform, userHome)
136
+ const warned = new Set<string>()
137
+ const warnOnce = (key: string, line: string): void => {
138
+ if (warned.has(key)) return
139
+ warned.add(key)
140
+ warn(line)
141
+ }
142
+
143
+ let repos: WiredRepo[] = []
144
+ let last: ProjectsAnswer | null = null
145
+ let inFlight: Promise<ProjectsAnswer> | null = null
146
+ let folders: { mtime: number; list: string[] } | null = null
147
+ const releases = new Map<string, { stamp: string | null; at: number; release: Release; commitAt: number | null; dirty: number | null }>()
148
+ const gates = new Map<string, { stamp: string; gate: Gate }>()
149
+ /** a repo's last gate, its record opened only when it moved */
150
+ const gateOfRepo = (path: string): Gate => {
151
+ const record = recordOf(path)
152
+ if (record == null) return null
153
+ const stamp = recordStamp(record)
154
+ const hit = gates.get(path)
155
+ if (hit != null && hit.stamp === stamp) return hit.gate
156
+ const gate = gateOf(readLastStatusReadonly(record))
157
+ gates.set(path, { stamp, gate })
158
+ return gate
159
+ }
160
+ const orderFile = d.orderFile ?? join(userHome, ORDER_FILE)
161
+ const transcriptRoots = (): string[] => {
162
+ if (d.transcriptRoots != null) return d.transcriptRoots
163
+ try {
164
+ return readdirSync(userHome).filter((n) => n.startsWith('.claude')).map((n) => join(userHome, n, 'projects'))
165
+ } catch {
166
+ return []
167
+ }
168
+ }
169
+
170
+ async function vscodeFolders(): Promise<string[] | null> {
171
+ if (storage == null) {
172
+ warnOnce('vscode', `WARN project gutter: no VS Code window state known on ${d.platform ?? process.platform}, no open dots`)
173
+ return null
174
+ }
175
+ try {
176
+ const mtime = (await stat(storage)).mtimeMs
177
+ if (folders?.mtime !== mtime) folders = { mtime, list: openFolders(await readFile(storage, 'utf8')) }
178
+ return folders.list
179
+ } catch (e) {
180
+ warnOnce('vscode', `WARN project gutter: VS Code window state unreadable at ${storage} (${(e as Error).message}), no open dots`)
181
+ return null
182
+ }
183
+ }
184
+
185
+ async function sessionCwds(): Promise<string[] | null> {
186
+ const ps = await run(['ps', '-axo', 'pid=,comm='])
187
+ if (ps == null) {
188
+ warnOnce('sessions', 'WARN project gutter: ps is not on PATH, no Claude session badges')
189
+ return null
190
+ }
191
+ const list = parseClaudePids(ps.out)
192
+ if (list.length === 0) return []
193
+ const out = await run(['lsof', '-b', '-w', '-a', '-p', list.join(','), '-d', 'cwd', '-Fpn'])
194
+ if (out == null) {
195
+ warnOnce('sessions', 'WARN project gutter: lsof is not on PATH, no Claude session badges')
196
+ return null
197
+ }
198
+ return [...parseLsofCwd(out.out).values()]
199
+ }
200
+
201
+ async function askRelease(path: string): Promise<Release> {
202
+ const desc = await run(['git', 'describe', '--tags', '--long', ...RELEASE_TAGS.map((g) => `--match=${g}`)], path)
203
+ if (desc == null) {
204
+ warnOnce('git', 'WARN project gutter: git is not on PATH, no release distance')
205
+ return null
206
+ }
207
+ const hit = desc.code === 0 ? parseDescribe(desc.out) : null
208
+ if (hit == null) {
209
+ // no tag in reach, or no commit at all: tell the two apart
210
+ const head = await run(['git', 'rev-parse', '--verify', '--quiet', 'HEAD'], path)
211
+ return head?.code === 0 ? 'never' : null
212
+ }
213
+ const log = await run(['git', 'log', '-1', '--format=%ct', hit.tag], path)
214
+ return { tag: hit.tag, ahead: hit.ahead, at: epochMs(log?.out) ?? 0 }
215
+ }
216
+
217
+ /** the release and the last commit time, asked of git together, since
218
+ * the same stat says when either can have moved */
219
+ async function askGit(path: string): Promise<{ release: Release; commitAt: number | null; dirty: number | null }> {
220
+ const release = await askRelease(path)
221
+ if (release == null) return { release, commitAt: null, dirty: null }
222
+ // --no-optional-locks: a status that rewrote the index would move the
223
+ // stamp and ask again on every poll
224
+ const [head, status] = await Promise.all([
225
+ run(['git', 'log', '-1', '--format=%ct', 'HEAD'], path),
226
+ run(['git', '--no-optional-locks', 'status', '--porcelain'], path),
227
+ ])
228
+ return { release, commitAt: head?.code === 0 ? epochMs(head.out) : null, dirty: status?.code === 0 ? dirtyCount(status.out) : null }
229
+ }
230
+
231
+ /** every repo's release, asking git for at most RELEASE_ASKS_PER_POLL of
232
+ * the stale ones per measure, the never asked first then the oldest.
233
+ * The rest keep their last answer, or unknown until their turn */
234
+ async function gitFacts(paths: string[]): Promise<{ release: Release; commitAt: number | null; dirty: number | null }[]> {
235
+ const stamps = new Map(paths.map((p) => [p, gitStamp(p)]))
236
+ const stale = paths.filter((p) => {
237
+ const k = releases.get(p)
238
+ if (k == null) return true
239
+ const s = stamps.get(p)!
240
+ return s != null ? k.stamp !== s : now() - k.at >= RELEASE_TTL_MS
241
+ })
242
+ stale.sort((a, b) => (releases.get(a)?.at ?? -1) - (releases.get(b)?.at ?? -1))
243
+ await Promise.all(stale.slice(0, RELEASE_ASKS_PER_POLL).map(async (p) => {
244
+ const g = await askGit(p)
245
+ releases.set(p, { stamp: stamps.get(p)!, at: now(), ...g })
246
+ }))
247
+ return paths.map((p) => ({ release: releases.get(p)?.release ?? null, commitAt: releases.get(p)?.commitAt ?? null, dirty: releases.get(p)?.dirty ?? null }))
248
+ }
249
+
250
+ async function measure(): Promise<ProjectsAnswer> {
251
+ const t0 = performance.now()
252
+ const house = houseRepos(d.root, userHome)
253
+ home = house.home
254
+ if (house.index == null) warnOnce('house', `WARN project gutter: ${home} includes no house wiki index, so no sibling shares one and the gutter is empty`)
255
+ repos = house.repos
256
+ const [open, cwds, facts] = await Promise.all([vscodeFolders(), sessionCwds(), gitFacts(repos.map((r) => r.path))])
257
+ const transcripts = lastSessionAt(transcriptRoots(), repos.map((r) => r.path))
258
+ const counts = cwds == null ? null : countSessions(cwds, repos.map((r) => r.path))
259
+ const projects = repos.map((r, i): ProjectRow => {
260
+ const config = readText(join(r.path, 'repo.config.ts'))
261
+ const pkg = readText(join(r.path, 'package.json'))
262
+ const asks = openAsks(join(r.path, asksDirOf(config, r.path)))
263
+ // the README is read only when package.json says nothing
264
+ const description = descriptionOf(pkg, '') ?? descriptionOf('', readText(join(r.path, 'README.md')))
265
+ return {
266
+ name: r.name,
267
+ path: r.path,
268
+ icon: r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`,
269
+ current: r.path === home,
270
+ vscode: open == null ? null : open.some((f) => f === r.path),
271
+ sessions: counts?.get(r.path) ?? null,
272
+ asks: asks.length,
273
+ askTitles: asks,
274
+ description,
275
+ version: versionOf(pkg),
276
+ branch: branchOf(readText(join(r.path, '.git', 'HEAD'))),
277
+ dirty: facts[i]!.dirty,
278
+ gate: gateOfRepo(r.path),
279
+ release: facts[i]!.release,
280
+ live: liveUrlOf(config),
281
+ activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
282
+ }
283
+ })
284
+ return { projects: inOrder(projects, parseOrder(readText(orderFile))), ms: Math.round(performance.now() - t0), at: now() }
285
+ }
286
+
287
+ /** the last answer while it is younger than POLL_MS, one measure otherwise,
288
+ * shared by every request that arrives while it runs */
289
+ async function answer(): Promise<ProjectsAnswer> {
290
+ if (last != null && now() - last.at < POLL_MS) return last
291
+ inFlight ??= measure().then((a) => {
292
+ last = a
293
+ return a
294
+ }).finally(() => { inFlight = null })
295
+ return inFlight
296
+ }
297
+
298
+ const find = (name: string): WiredRepo | undefined => repos.find((r) => r.name === name)
299
+
300
+ /** saves the rail order: the names given, then every discovered repo they
301
+ * leave out. Written whole through a temp file, so a reader never sees
302
+ * half of it */
303
+ function saveOrder(names: string[]): string[] {
304
+ const order = mergeOrder(names, repos.map((r) => r.name))
305
+ mkdirSync(dirname(orderFile), { recursive: true })
306
+ const tmp = `${orderFile}.tmp-${process.pid}`
307
+ writeFileSync(tmp, renderOrder(order))
308
+ renameSync(tmp, orderFile)
309
+ if (last != null) last = { ...last, projects: inOrder(last.projects, order) }
310
+ return order
311
+ }
312
+
313
+ /** null when the path is not this route's */
314
+ async function handle(req: Request, path: string): Promise<Response | null> {
315
+ if (path === '/api/projects.json') return Response.json(await answer())
316
+ if (path.startsWith('/api/projects/icon/')) {
317
+ if (repos.length === 0) await answer()
318
+ const r = find(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
319
+ if (r?.icon == null) return new Response('no icon', { status: 404 })
320
+ return new Response(Bun.file(join(r.path, r.icon)), {
321
+ headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
322
+ })
323
+ }
324
+ if (path === '/api/projects/order' && req.method === 'POST') {
325
+ if (repos.length === 0) await answer()
326
+ const b = (await req.json().catch(() => ({}))) as { names?: unknown }
327
+ if (!Array.isArray(b.names) || !b.names.every((n) => typeof n === 'string')) return new Response('names must be a list of repo names', { status: 400 })
328
+ try {
329
+ return Response.json({ order: saveOrder(b.names as string[]) })
330
+ } catch (e) {
331
+ return new Response(`the order could not be saved to ${orderFile}: ${(e as Error).message}`, { status: 500 })
332
+ }
333
+ }
334
+ if (path === '/api/projects/open' && req.method === 'POST') {
335
+ if (repos.length === 0) await answer()
336
+ const b = (await req.json().catch(() => ({}))) as { name?: string }
337
+ const r = find(b.name ?? '')
338
+ if (r == null) return new Response(`not a wired repo: ${b.name ?? ''}`, { status: 404 })
339
+ try {
340
+ ;(d.open ?? openInCode)(r.path)
341
+ } catch (e) {
342
+ return new Response((e as Error).message, { status: 400 })
343
+ }
344
+ return Response.json({ ok: true })
345
+ }
346
+ return null
347
+ }
348
+
349
+ return { handle, answer }
350
+ }