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,449 @@
1
+ // THE discovery of the house repos on this disk (houseRepos), and what is
2
+ // read of each one: which folders VS Code has open, which Claude Code
3
+ // sessions run in which repo, its open asks, its distance to the last
4
+ // release, its declared live url, its run record and its hub port. The
5
+ // project gutter and the live strip both list repos through houseRepos, so
6
+ // the two surfaces show the same repos by construction. The parsers read
7
+ // text they are handed, so sample output pins them; project-gutter-route.ts
8
+ // and other-repos.ts own the spawns, the reads and the caches.
9
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
10
+ import { basename, dirname, join, resolve, sep } from 'path'
11
+ import { fileURLToPath } from 'url'
12
+ import { gitRoot } from '../core/git-root.ts'
13
+ import { DB_FILE } from '../ledger/db.ts'
14
+ import { defaultHubPort } from './hub-port.ts'
15
+ import { LAYOUT } from '../core/layout.ts'
16
+
17
+ export type WiredRepo = { name: string; path: string; icon: string | null }
18
+
19
+ /** the files a repo carries its includes in */
20
+ const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
21
+
22
+ /** the absolute md file of every `@<path>.md` include line a repo's CLAUDE
23
+ * files carry, in order. A sentence that mentions a path is no include */
24
+ export function includeTargets(repo: string, home: string): string[] {
25
+ const out: string[] = []
26
+ for (const f of CLAUDE_FILES) {
27
+ let text: string
28
+ try {
29
+ text = readFileSync(join(repo, f), 'utf8')
30
+ } catch {
31
+ continue
32
+ }
33
+ for (const line of text.split('\n')) {
34
+ const raw = /^@(\S+\.md)$/.exec(line.trim())?.[1]
35
+ if (raw != null) out.push(raw.startsWith('~/') ? join(home, raw.slice(2)) : resolve(repo, raw))
36
+ }
37
+ }
38
+ return out
39
+ }
40
+
41
+ /** the house wiki index this repo includes: the first include outside the
42
+ * repo, else (the repo that hosts the house) the first one that exists and
43
+ * is not another CLAUDE file */
44
+ export function houseIndex(repo: string, home: string): string | null {
45
+ const targets = includeTargets(repo, home).filter((t) => existsSync(t))
46
+ return targets.find((t) => !inside(t, repo)) ?? targets.find((t) => !basename(t).startsWith('CLAUDE')) ?? null
47
+ }
48
+
49
+ /** a repo's own icon, first hit wins. The identity kit writes the first one */
50
+ export const ICON_FILES = ['public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
51
+
52
+ export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
53
+
54
+ /** every direct child of `parent` that includes `index`, sorted by name */
55
+ export function wiredRepos(parent: string, index: string, home: string): WiredRepo[] {
56
+ let names: string[]
57
+ try {
58
+ names = readdirSync(parent)
59
+ } catch {
60
+ return []
61
+ }
62
+ const out: WiredRepo[] = []
63
+ for (const name of names.sort()) {
64
+ if (name.startsWith('.')) continue
65
+ const path = join(parent, name)
66
+ if (!includeTargets(path, home).includes(index)) continue
67
+ out.push({ name, path, icon: ICON_FILES.find((f) => existsSync(join(path, f))) ?? null })
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** the house repos around the repo at `root`: its main checkout (`home`),
73
+ * the house index its own includes name, and every sibling that includes the
74
+ * same index, `home` among them. No index = no repos */
75
+ export function houseRepos(root: string, userHome: string): { home: string; index: string | null; repos: WiredRepo[] } {
76
+ const home = homeRepo(root)
77
+ const index = houseIndex(home, userHome)
78
+ return { home, index, repos: index == null ? [] : wiredRepos(dirname(home), index, userHome) }
79
+ }
80
+
81
+ /** the checkout a linked worktree belongs to, so a hub started in a worktree
82
+ * still lists its siblings and marks its own repo */
83
+ export function homeRepo(root: string): string {
84
+ const g = gitRoot(root)
85
+ if (g == null || basename(g.commonDir) !== '.git') return root
86
+ return dirname(g.commonDir)
87
+ }
88
+
89
+ /** the version stamp an icon url carries, so the browser caches it until the
90
+ * file changes */
91
+ export const iconStamp = (file: string): number => {
92
+ try {
93
+ return Math.floor(statSync(file).mtimeMs)
94
+ } catch {
95
+ return 0
96
+ }
97
+ }
98
+
99
+ // ── VS Code ───────────────────────────────────────────────────────────────
100
+
101
+ /** where VS Code keeps its window state, or null on a platform it has none
102
+ * we know of */
103
+ export function vscodeStoragePath(platform: string, home: string): string | null {
104
+ if (platform === 'darwin') return join(home, 'Library/Application Support/Code/User/globalStorage/storage.json')
105
+ if (platform === 'linux') return join(home, '.config/Code/User/globalStorage/storage.json')
106
+ return null
107
+ }
108
+
109
+ type Win = { folder?: unknown }
110
+
111
+ /** the folders VS Code has open, as absolute paths. A workspace file window
112
+ * or a remote folder carries no local folder and is skipped */
113
+ export function openFolders(storageJson: string): string[] {
114
+ const j = JSON.parse(storageJson) as { windowsState?: { lastActiveWindow?: Win; openedWindows?: Win[] } }
115
+ const ws = j.windowsState
116
+ const wins: Win[] = [...(ws?.openedWindows ?? []), ...(ws?.lastActiveWindow != null ? [ws.lastActiveWindow] : [])]
117
+ const out = new Set<string>()
118
+ for (const w of wins) {
119
+ if (typeof w.folder !== 'string' || !w.folder.startsWith('file://')) continue
120
+ try {
121
+ out.add(fileURLToPath(w.folder).replace(/\/+$/, ''))
122
+ } catch {
123
+ // a malformed uri names no folder
124
+ }
125
+ }
126
+ return [...out]
127
+ }
128
+
129
+ /** true when `p` is `dir` or sits inside it */
130
+ export const inside = (p: string, dir: string): boolean => p === dir || p.startsWith(dir + sep)
131
+
132
+ // ── Claude Code sessions ──────────────────────────────────────────────────
133
+
134
+ /** the Claude Code pids in `ps -axo pid=,comm=`: a command named exactly
135
+ * `claude`, bare or as a path. BSD pgrep would be cheaper, but it hides its
136
+ * own ancestors, so a hub started from a session could never count it */
137
+ export function parseClaudePids(ps: string): number[] {
138
+ const out: number[] = []
139
+ for (const l of ps.split('\n')) {
140
+ const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(l)
141
+ if (m != null && basename(m[2]!) === 'claude') out.push(Number(m[1]))
142
+ }
143
+ return out
144
+ }
145
+
146
+ /** `lsof -a -p <pids> -d cwd -Fpn`: a `p<pid>` line, then `f` and `n<path>`
147
+ * lines for that process */
148
+ export function parseLsofCwd(out: string): Map<number, string> {
149
+ const cwd = new Map<number, string>()
150
+ let pid: number | null = null
151
+ for (const l of out.split('\n')) {
152
+ if (l.startsWith('p')) pid = Number(l.slice(1))
153
+ else if (l.startsWith('n') && pid != null && Number.isFinite(pid)) cwd.set(pid, l.slice(1))
154
+ }
155
+ return cwd
156
+ }
157
+
158
+ /** sessions per repo path: a cwd counts for the repo it sits in */
159
+ export function countSessions(cwds: Iterable<string>, repos: string[]): Map<string, number> {
160
+ const n = new Map<string, number>(repos.map((r) => [r, 0]))
161
+ for (const c of cwds) {
162
+ const r = repos.find((p) => inside(c, p))
163
+ if (r != null) n.set(r, n.get(r)! + 1)
164
+ }
165
+ return n
166
+ }
167
+
168
+ // ── asks, release, live ───────────────────────────────────────────────────
169
+ // a repo's config is READ as text, never imported: importing runs that
170
+ // repo's code in the hub process, on every poll
171
+
172
+ /** the body of `<key>: { … }` in a config source, line comments dropped, or
173
+ * null when the key declares no object */
174
+ export function configBlock(src: string, key: string): string | null {
175
+ const clean = src.replace(/(^|\s)\/\/.*$/gm, '$1')
176
+ const m = new RegExp(`\\b${key}\\s*:\\s*\\{`).exec(clean)
177
+ if (m == null) return null
178
+ let depth = 1
179
+ for (let i = m.index + m[0].length; i < clean.length; i++) {
180
+ if (clean[i] === '{') depth++
181
+ else if (clean[i] === '}' && --depth === 0) return clean.slice(m.index + m[0].length, i)
182
+ }
183
+ return null
184
+ }
185
+
186
+ const stringKey = (block: string | null, key: string): string | null =>
187
+ block == null ? null : (new RegExp(`\\b${key}\\s*:\\s*['"\`]([^'"\`]+)['"\`]`).exec(block)?.[1] ?? null)
188
+
189
+ /** `asks: { dir }`, the queue's folder, `.shipkit/asks` when the config names
190
+ * none. A repo that has not run the new shipkit yet still keeps its queue in
191
+ * `asks/` (this read dies once `shipkit fleet` shows no repo on the old layout) */
192
+ export const asksDirOf = (configSrc: string, repo?: string): string =>
193
+ stringKey(configBlock(configSrc, 'asks'), 'dir') ?? (repo != null && !existsSync(join(repo, LAYOUT.asks)) && existsSync(join(repo, 'asks')) ? 'asks' : LAYOUT.asks)
194
+
195
+ /** `smoke: { base }`, the one deployed url a repo declares in a machine
196
+ * readable place, or null */
197
+ export function liveUrlOf(configSrc: string): string | null {
198
+ const u = stringKey(configBlock(configSrc, 'smoke'), 'base')
199
+ return u != null && /^https?:\/\//.test(u) ? u : null
200
+ }
201
+
202
+ /** an ask file is open until its frontmatter says `status: done` */
203
+ export const isOpenAsk = (md: string): boolean => !/^status:\s*done\b/m.test(md)
204
+
205
+ /** the open asks in a queue folder: its top level md files, the done/
206
+ * archive and every other folder left out. Read only: a missing folder is 0 */
207
+ export const openAskCount = (dir: string): number => openAsks(dir).length
208
+
209
+ /** an ask's title: its first heading, else its first line of prose, the
210
+ * frontmatter skipped */
211
+ export function askTitle(md: string): string | null {
212
+ const body = md.replace(/^---\n[\s\S]*?\n---\n?/, '')
213
+ const lines = body.split('\n').map((l) => l.trim()).filter((l) => l !== '')
214
+ const heading = lines.find((l) => /^#{1,6}\s+\S/.test(l))
215
+ const title = heading?.replace(/^#{1,6}\s+/, '') ?? lines[0]
216
+ return title == null || title === '' ? null : title
217
+ }
218
+
219
+ /** the titles of the open asks in a queue folder, by file name order, the
220
+ * file name when an ask has no text */
221
+ export function openAsks(dir: string): string[] {
222
+ let files: string[]
223
+ try {
224
+ files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort()
225
+ } catch {
226
+ return []
227
+ }
228
+ const out: string[] = []
229
+ for (const f of files) {
230
+ try {
231
+ const md = readFileSync(join(dir, f), 'utf8')
232
+ if (isOpenAsk(md)) out.push(askTitle(md) ?? f.replace(/\.md$/, ''))
233
+ } catch {
234
+ // a folder named like an md file, or a file gone mid read
235
+ }
236
+ }
237
+ return out
238
+ }
239
+
240
+ // ── the card facts: description, version, branch, last gate ─────────────
241
+
242
+ /** a one line summary cut at `max` characters, on a word when it can */
243
+ export const clip = (text: string, max = 90): string => {
244
+ const t = text.replace(/\s+/g, ' ').trim()
245
+ if (t.length <= max) return t
246
+ const cut = t.slice(0, max - 1)
247
+ const at = cut.lastIndexOf(' ')
248
+ return `${(at > max / 2 ? cut.slice(0, at) : cut).replace(/[\s,;:.]+$/, '')}…`
249
+ }
250
+
251
+ /** what the repo says it is: package.json `description`, else the first line
252
+ * of prose in its README after the title (badges, headings, html,
253
+ * blockquotes and a bare url skipped, a markdown link kept as its text) */
254
+ export function descriptionOf(packageJson: string, readme: string): string | null {
255
+ try {
256
+ const d = (JSON.parse(packageJson) as { description?: unknown }).description
257
+ if (typeof d === 'string' && d.trim() !== '') return clip(d)
258
+ } catch {
259
+ // no package.json, or not json: the README answers
260
+ }
261
+ const body = readme.replace(/^---\n[\s\S]*?\n---\n?/, '').replace(/<!--[\s\S]*?-->/g, '')
262
+ for (const raw of body.split('\n')) {
263
+ const l = raw.trim()
264
+ if (l === '' || /^(#|>|<|!\[|\[!\[|```|---|===|\|)/.test(l) || /^<?https?:\/\/\S+>?$/.test(l)) continue
265
+ return clip(l.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|__|`/g, ''))
266
+ }
267
+ return null
268
+ }
269
+
270
+ /** package.json `version`, or null */
271
+ export function versionOf(packageJson: string): string | null {
272
+ try {
273
+ const v = (JSON.parse(packageJson) as { version?: unknown }).version
274
+ return typeof v === 'string' && v.trim() !== '' ? v.trim() : null
275
+ } catch {
276
+ return null
277
+ }
278
+ }
279
+
280
+ /** the branch `.git/HEAD` names, or `detached <sha>` for a detached head */
281
+ export function branchOf(head: string): string | null {
282
+ const t = head.trim()
283
+ const ref = /^ref: refs\/heads\/(.+)$/.exec(t)?.[1]
284
+ if (ref != null) return ref
285
+ return /^[0-9a-f]{7,40}$/.test(t) ? `detached ${t.slice(0, 7)}` : null
286
+ }
287
+
288
+ /** `git status --porcelain`: one line per changed or untracked path */
289
+ export const dirtyCount = (porcelain: string): number => porcelain.split('\n').filter((l) => l.trim() !== '').length
290
+
291
+ /** the last gate as its record holds it: green, or the checks whose last
292
+ * outcome failed. No row at all = unknown */
293
+ export type Gate = 'green' | { reds: string[] } | null
294
+ export function gateOf(rows: { id: string; status: string }[]): Gate {
295
+ if (rows.length === 0) return null
296
+ const reds = rows.filter((r) => r.status === 'fail').map((r) => r.id).sort()
297
+ return reds.length === 0 ? 'green' : { reds }
298
+ }
299
+
300
+ /** the tag shapes a release leaves: `v1.2.3`, `name@1.2.3` (the shipkit
301
+ * defaults) and a bare `1.2.3`. A date or a merge marker is no release */
302
+ export const RELEASE_TAGS = ['v[0-9]*', '*@[0-9]*', '[0-9]*.[0-9]*']
303
+
304
+ /** `git describe --tags --long`: `<tag>-<commits since>-g<sha>` */
305
+ export function parseDescribe(out: string): { tag: string; ahead: number } | null {
306
+ const m = /^(.+)-(\d+)-g[0-9a-f]+$/.exec(out.trim())
307
+ return m == null ? null : { tag: m[1]!, ahead: Number(m[2]) }
308
+ }
309
+
310
+ /** what moves when a commit or a tag lands, read with a few stats, so the
311
+ * release distance is asked of git only when it can have changed. null for
312
+ * a checkout whose `.git` is not a folder */
313
+ export function gitStamp(repo: string): string | null {
314
+ const git = join(repo, '.git')
315
+ const mt = (p: string): number => {
316
+ try {
317
+ return statSync(join(git, p)).mtimeMs
318
+ } catch {
319
+ return 0
320
+ }
321
+ }
322
+ try {
323
+ if (!statSync(git).isDirectory()) return null
324
+ const head = readFileSync(join(git, 'HEAD'), 'utf8').trim()
325
+ const ref = /^ref: (.+)$/.exec(head)?.[1]
326
+ // the index moves on every add, commit, stash and checkout, which is
327
+ // when the uncommitted count is asked again
328
+ return [head, ref == null ? 0 : mt(ref), mt('packed-refs'), mt('refs/tags'), mt('index')].join('|')
329
+ } catch {
330
+ return null
331
+ }
332
+ }
333
+
334
+ // ── last activity ─────────────────────────────────────────────────────────
335
+
336
+ /** the newest of the stamps that exist, or null when none does */
337
+ export const lastActivity = (...stamps: (number | null | undefined)[]): number | null => {
338
+ const known = stamps.filter((s): s is number => s != null && Number.isFinite(s) && s > 0)
339
+ return known.length === 0 ? null : Math.max(...known)
340
+ }
341
+
342
+ const mtimeOf = (file: string): number | null => {
343
+ try {
344
+ return statSync(file).mtimeMs
345
+ } catch {
346
+ return null
347
+ }
348
+ }
349
+
350
+ /** when the repo's own run record was last written: every gate and health
351
+ * run writes it, so its mtime is the last shipkit run */
352
+ export const lastRunAt = (repo: string): number | null => {
353
+ const record = recordOf(repo)
354
+ return record == null ? null : lastActivity(mtimeOf(record), mtimeOf(`${record}-wal`))
355
+ }
356
+
357
+ /** where a repo's run record lives, without asking git: a `.git` directory
358
+ * holds it. A `.git` FILE (a linked worktree) is left out: its record sits
359
+ * with the main checkout, which is on the list itself */
360
+ export function recordOf(dir: string): string | null {
361
+ const git = join(dir, '.git')
362
+ try {
363
+ if (!statSync(git).isDirectory()) return null
364
+ } catch {
365
+ return null
366
+ }
367
+ return join(git, 'shipkit', DB_FILE)
368
+ }
369
+
370
+ /** what the record's files look like from outside: the db and its write
371
+ * ahead log, since a commit in WAL mode moves only the log */
372
+ export const recordStamp = (path: string): string => {
373
+ const one = (p: string): string => {
374
+ try {
375
+ const s = statSync(p)
376
+ return `${s.mtimeMs}:${s.size}`
377
+ } catch {
378
+ return '-'
379
+ }
380
+ }
381
+ return `${one(path)}|${one(`${path}-wal`)}`
382
+ }
383
+
384
+ /** the port a repo's hub listens on: `serve.port` in its repo.config.ts when
385
+ * it names one, else the port its name hashes to, as `shipkit serve` picks */
386
+ export function hubPortOf(dir: string): number {
387
+ let src = ''
388
+ try {
389
+ src = readFileSync(join(dir, 'repo.config.ts'), 'utf8')
390
+ } catch {
391
+ // no config: the directory name answers
392
+ }
393
+ const port = /\bport\s*:\s*(\d{2,5})/.exec(configBlock(src, 'serve') ?? '')?.[1]
394
+ if (port != null) return Number(port)
395
+ const name = /\bname\s*:\s*['"]([^'"]+)['"]/.exec(src)?.[1]
396
+ return defaultHubPort(name ?? basename(dir))
397
+ }
398
+
399
+ /** the folder name Claude Code keeps a directory's transcripts under */
400
+ export const transcriptSlug = (dir: string): string => dir.replace(/[^a-zA-Z0-9]/g, '-')
401
+
402
+ /** the newest transcript mtime per repo, from every `<root>/projects/<slug>/`:
403
+ * the repo's own slug, or one under a dot folder of it (`<slug>--claude-…`).
404
+ * A plain subfolder slug is not claimed, since `repo-sub` also reads as a
405
+ * sibling named `repo-sub` */
406
+ export function lastSessionAt(projectRoots: string[], repos: string[]): Map<string, number> {
407
+ const bySlug = new Map(repos.map((r) => [transcriptSlug(r), r]))
408
+ const out = new Map<string, number>()
409
+ for (const root of projectRoots) {
410
+ let slugs: string[]
411
+ try {
412
+ slugs = readdirSync(root)
413
+ } catch {
414
+ continue
415
+ }
416
+ for (const slug of slugs) {
417
+ const repo = bySlug.get(slug) ?? [...bySlug.entries()].find(([s]) => slug.startsWith(`${s}--`))?.[1]
418
+ if (repo == null) continue
419
+ let files: string[]
420
+ try {
421
+ files = readdirSync(join(root, slug)).filter((f) => f.endsWith('.jsonl'))
422
+ } catch {
423
+ continue
424
+ }
425
+ const newest = lastActivity(out.get(repo), ...files.map((f) => mtimeOf(join(root, slug, f))))
426
+ if (newest != null) out.set(repo, newest)
427
+ }
428
+ }
429
+ return out
430
+ }
431
+
432
+ // ── the rail order ────────────────────────────────────────────────────────
433
+
434
+ /** the order file: one repo name per line, `#` lines and blanks ignored,
435
+ * the first mention of a name wins */
436
+ export const parseOrder = (text: string): string[] => [...new Set(text.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('#')))]
437
+
438
+ export const ORDER_HEADER = '# the project rail order of every shipkit hub on this machine: one repo name per line, top first'
439
+
440
+ export const renderOrder = (names: string[]): string => `${ORDER_HEADER}\n${names.join('\n')}\n`
441
+
442
+ /** the rail: the saved order for the repos that still exist, then every repo
443
+ * the file does not name, in discovery order. A vanished name drops out */
444
+ export function mergeOrder(saved: string[], discovered: string[]): string[] {
445
+ const here = new Set(discovered)
446
+ const kept = saved.filter((n) => here.has(n))
447
+ const known = new Set(kept)
448
+ return [...kept, ...discovered.filter((n) => !known.has(n))]
449
+ }