rvlib-shipkit 1.2.0 → 1.4.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 (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -20
@@ -5,22 +5,23 @@
5
5
  // the index moved, a few repos per poll, and a repo's run record is opened
6
6
  // only when it moved, so a steady poll spawns ps and lsof and nothing else. The icon route
7
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
8
+ // /api/projects/open opens a discovered repo in VS Code, POST
9
+ // /api/projects/fork in Fork: a request names a repo, never a path. The rail ORDER is one text file for the whole machine
10
10
  // (ORDER_FILE), so every hub shows the same order and a hand edit is seen
11
11
  // within one poll; POST /api/projects/order writes it.
12
- import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
13
13
  import { readFile, stat } from 'fs/promises'
14
14
  import { homedir } from 'os'
15
15
  import { dirname, join } from 'path'
16
16
  import {
17
- asksDirOf, branchOf, countSessions, descriptionOf, dirtyCount, gateOf, gitStamp, houseRepos, iconStamp, ICON_TYPES, lastActivity,
17
+ asksDirOf, branchOf, countSessions, descriptionOf, dirtyCount, gateFrom, checkStateOf, gitStamp, houseRepos, iconStamp, ICON_TYPES, lastActivity,
18
18
  lastRunAt, lastSessionAt, liveUrlOf, mergeOrder, openAsks, openFolders, parseClaudePids, parseDescribe, parseLsofCwd, parseOrder,
19
- recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type Gate, type WiredRepo,
19
+ recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type CheckState, type Gate, type LastRow, type WiredRepo,
20
20
  } from './project-gutter.ts'
21
21
  import { readLastStatusReadonly } from '../ledger/run-log.ts'
22
22
  import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
23
23
  import { writeIdentity } from '../core/identity.ts'
24
+ import { writeArchived } from '../core/archived.ts'
24
25
 
25
26
  export type { Gate }
26
27
 
@@ -62,6 +63,8 @@ export type ProjectRow = {
62
63
  dirty: number | null
63
64
  /** the last gate its own run record holds */
64
65
  gate: Gate
66
+ /** every check's last outcome as one colour, warnings included */
67
+ checks: CheckState
65
68
  release: Release
66
69
  /** the deployed url `smoke.base` declares, or null */
67
70
  live: string | null
@@ -70,8 +73,13 @@ export type ProjectRow = {
70
73
  /** epoch ms of the newest of: the last commit, the last shipkit run, the
71
74
  * newest Claude Code transcript. null when none of them exists */
72
75
  activity: number | null
76
+ /** epoch ms of the last commit on HEAD, null when git cannot say */
77
+ commit: number | null
73
78
  }
74
- export type ProjectsAnswer = { projects: ProjectRow[]; ms: number; at: number }
79
+ /** a repo whose repo.config.ts says `archived: true`: only the archived page lists it */
80
+ export type ArchivedRow = { name: string; icon: string | null }
81
+
82
+ export type ProjectsAnswer = { projects: ProjectRow[]; archived: ArchivedRow[]; ms: number; at: number }
75
83
 
76
84
  /** a command's exit code and stdout, or null when its binary is missing */
77
85
  export type Run = (cmd: string[], cwd?: string) => Promise<{ code: number; out: string } | null>
@@ -83,12 +91,18 @@ export type GutterDeps = {
83
91
  run?: Run
84
92
  /** opens a folder in VS Code; throws with the reason it could not */
85
93
  open?: (path: string) => void
94
+ /** opens a folder in Fork, the git client; throws with the reason it could not */
95
+ fork?: (path: string) => void
86
96
  warn?: (line: string) => void
87
97
  now?: () => number
88
98
  /** where the rail order lives, default ~/ORDER_FILE */
89
99
  orderFile?: string
90
100
  /** the Claude Code `projects` folders, default the `projects` folder of every ~/.claude config root */
91
101
  transcriptRoots?: string[]
102
+ /** the check ids a repo declares today (the daemon's plan), so a check
103
+ * it no longer declares never colours it. Absent or null = not known,
104
+ * every recorded check counts */
105
+ declared?: (name: string) => ReadonlySet<string> | null
92
106
  }
93
107
 
94
108
  const runCmd: Run = async (cmd, cwd) => {
@@ -111,6 +125,21 @@ function openInCode(path: string): void {
111
125
  throw new Error('the `code` command is not on PATH: in VS Code, run "Shell Command: Install \'code\' command in PATH"')
112
126
  }
113
127
 
128
+ /** where Fork is installed, the places macOS keeps apps */
129
+ export const FORK_APPS = ['/Applications/Fork.app', join(homedir(), 'Applications', 'Fork.app')]
130
+
131
+ /** the command that opens a folder in Fork; throws when Fork is not installed */
132
+ export function forkCommand(path: string, installed: (app: string) => boolean = existsSync, platform: string = process.platform): string[] {
133
+ if (platform !== 'darwin') throw new Error('opening in Fork is macOS only here')
134
+ const app = FORK_APPS.find(installed)
135
+ if (app == null) throw new Error(`Fork is not installed (looked in ${FORK_APPS.join(', ')})`)
136
+ return ['open', '-a', app, path]
137
+ }
138
+
139
+ function openInFork(path: string): void {
140
+ Bun.spawn(forkCommand(path), { stdio: ['ignore', 'ignore', 'ignore'] }).unref()
141
+ }
142
+
114
143
  const epochMs = (out: string | undefined): number | null => {
115
144
  const secs = Number(out?.trim())
116
145
  return Number.isFinite(secs) && secs > 0 ? secs * 1000 : null
@@ -130,6 +159,15 @@ const readText = (file: string): string => {
130
159
  }
131
160
  }
132
161
 
162
+ /** the house repos in the rail's order, archived ones left out: the rows of
163
+ * the project list without the facts the list measures, for a caller that
164
+ * needs only names and paths (`shipkit cross-repo-activity`) */
165
+ export function railRepos(root: string, userHome: string = homedir(), orderFile: string = join(userHome, ORDER_FILE)): WiredRepo[] {
166
+ const repos = houseRepos(root, userHome).repos
167
+ const by = new Map(repos.map((r) => [r.name, r]))
168
+ return mergeOrder(parseOrder(readText(orderFile)), repos.map((r) => r.name)).map((n) => by.get(n)!)
169
+ }
170
+
133
171
  export function projectGutter(d: GutterDeps) {
134
172
  let home = d.root
135
173
  const userHome = d.home ?? homedir()
@@ -145,21 +183,25 @@ export function projectGutter(d: GutterDeps) {
145
183
  }
146
184
 
147
185
  let repos: WiredRepo[] = []
186
+ let archivedRepos: WiredRepo[] = []
148
187
  let last: ProjectsAnswer | null = null
149
188
  let inFlight: Promise<ProjectsAnswer> | null = null
150
189
  let folders: { mtime: number; list: string[] } | null = null
151
190
  const releases = new Map<string, { stamp: string | null; at: number; release: Release; commitAt: number | null; dirty: number | null }>()
152
- const gates = new Map<string, { stamp: string; gate: Gate }>()
153
- /** a repo's last gate, its record opened only when it moved */
154
- const gateOfRepo = (path: string): Gate => {
155
- const record = recordOf(path)
156
- if (record == null) return null
191
+ const records = new Map<string, { stamp: string; rows: LastRow[] }>()
192
+ /** a repo's last gate and check colour, its record opened only when it
193
+ * moved, counted over the checks it declares today */
194
+ const gateOfRepo = (r: WiredRepo): { gate: Gate; checks: CheckState } => {
195
+ const record = recordOf(r.path)
196
+ if (record == null) return { gate: null, checks: null }
157
197
  const stamp = recordStamp(record)
158
- const hit = gates.get(path)
159
- if (hit != null && hit.stamp === stamp) return hit.gate
160
- const gate = gateOf(readLastStatusReadonly(record))
161
- gates.set(path, { stamp, gate })
162
- return gate
198
+ let hit = records.get(r.path)
199
+ if (hit == null || hit.stamp !== stamp) {
200
+ hit = { stamp, rows: readLastStatusReadonly(record) }
201
+ records.set(r.path, hit)
202
+ }
203
+ const checks = checkStateOf(hit.rows, d.declared?.(r.name) ?? null)
204
+ return { gate: gateFrom(checks), checks }
163
205
  }
164
206
  const orderFile = d.orderFile ?? join(userHome, ORDER_FILE)
165
207
  const transcriptRoots = (): string[] => {
@@ -251,12 +293,15 @@ export function projectGutter(d: GutterDeps) {
251
293
  return paths.map((p) => ({ release: releases.get(p)?.release ?? null, commitAt: releases.get(p)?.commitAt ?? null, dirty: releases.get(p)?.dirty ?? null }))
252
294
  }
253
295
 
296
+ const iconUrl = (r: WiredRepo): string | null => (r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`)
297
+
254
298
  async function measure(): Promise<ProjectsAnswer> {
255
299
  const t0 = performance.now()
256
300
  const house = houseRepos(d.root, userHome)
257
301
  home = house.home
258
302
  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`)
259
303
  repos = house.repos
304
+ archivedRepos = house.archived
260
305
  const [open, cwds, facts] = await Promise.all([vscodeFolders(), sessionCwds(), gitFacts(repos.map((r) => r.path))])
261
306
  const transcripts = lastSessionAt(transcriptRoots(), repos.map((r) => r.path))
262
307
  const counts = cwds == null ? null : countSessions(cwds, repos.map((r) => r.path))
@@ -266,10 +311,11 @@ export function projectGutter(d: GutterDeps) {
266
311
  const asks = openAsks(join(r.path, asksDirOf(config, r.path)))
267
312
  // the README is read only when package.json says nothing
268
313
  const description = descriptionOf(pkg, '') ?? descriptionOf('', readText(join(r.path, 'README.md')))
314
+ const state = gateOfRepo(r)
269
315
  return {
270
316
  name: r.name,
271
317
  path: r.path,
272
- icon: r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`,
318
+ icon: iconUrl(r),
273
319
  current: r.path === home,
274
320
  vscode: open == null ? null : open.some((f) => f === r.path),
275
321
  sessions: counts?.get(r.path) ?? null,
@@ -279,14 +325,17 @@ export function projectGutter(d: GutterDeps) {
279
325
  version: versionOf(pkg),
280
326
  branch: branchOf(readText(join(r.path, '.git', 'HEAD'))),
281
327
  dirty: facts[i]!.dirty,
282
- gate: gateOfRepo(r.path),
328
+ gate: state.gate,
329
+ checks: state.checks,
283
330
  release: facts[i]!.release,
284
331
  live: liveUrlOf(config),
285
332
  identity: identityOfText(config),
286
333
  activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
334
+ commit: facts[i]!.commitAt,
287
335
  }
288
336
  })
289
- return { projects: inOrder(projects, parseOrder(readText(orderFile))), ms: Math.round(performance.now() - t0), at: now() }
337
+ const archived = archivedRepos.map((r) => ({ name: r.name, icon: iconUrl(r) }))
338
+ return { projects: inOrder(projects, parseOrder(readText(orderFile))), archived, ms: Math.round(performance.now() - t0), at: now() }
290
339
  }
291
340
 
292
341
  /** the last answer while it is younger than POLL_MS, one measure otherwise,
@@ -301,6 +350,8 @@ export function projectGutter(d: GutterDeps) {
301
350
  }
302
351
 
303
352
  const find = (name: string): WiredRepo | undefined => repos.find((r) => r.name === name)
353
+ /** a live repo or an archived one: the icon and the archive toggle reach both */
354
+ const findAny = (name: string): WiredRepo | undefined => find(name) ?? archivedRepos.find((r) => r.name === name)
304
355
 
305
356
  /** saves the rail order: the names given, then every discovered repo they
306
357
  * leave out. Written whole through a temp file, so a reader never sees
@@ -320,7 +371,7 @@ export function projectGutter(d: GutterDeps) {
320
371
  if (path === '/api/projects.json') return Response.json(await answer())
321
372
  if (path.startsWith('/api/projects/icon/')) {
322
373
  if (repos.length === 0) await answer()
323
- const r = find(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
374
+ const r = findAny(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
324
375
  if (r?.icon == null) return new Response('no icon', { status: 404 })
325
376
  return new Response(Bun.file(join(r.path, r.icon)), {
326
377
  headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
@@ -342,6 +393,21 @@ export function projectGutter(d: GutterDeps) {
342
393
  return new Response((e as Error).message, { status: 409 })
343
394
  }
344
395
  }
396
+ if (path === '/api/projects/archived' && req.method === 'POST') {
397
+ if (repos.length === 0) await answer()
398
+ const b = (await req.json().catch(() => ({}))) as { name?: unknown; archived?: unknown }
399
+ const r = findAny(String(b.name))
400
+ if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
401
+ if (typeof b.archived !== 'boolean') return new Response('archived must be true or false', { status: 400 })
402
+ try {
403
+ const on = writeArchived(r.path, b.archived)
404
+ // the repo moves between the two lists: the next answer measures again
405
+ last = null
406
+ return Response.json({ archived: on })
407
+ } catch (e) {
408
+ return new Response((e as Error).message, { status: 409 })
409
+ }
410
+ }
345
411
  if (path === '/api/projects/order' && req.method === 'POST') {
346
412
  if (repos.length === 0) await answer()
347
413
  const b = (await req.json().catch(() => ({}))) as { names?: unknown }
@@ -352,6 +418,20 @@ export function projectGutter(d: GutterDeps) {
352
418
  return new Response(`the order could not be saved to ${orderFile}: ${(e as Error).message}`, { status: 500 })
353
419
  }
354
420
  }
421
+ if (path === '/api/projects/fork' && req.method === 'POST') {
422
+ if (repos.length === 0) await answer()
423
+ const b = (await req.json().catch(() => ({}))) as { name?: unknown }
424
+ const name = typeof b.name === 'string' ? b.name : ''
425
+ if (archivedRepos.some((r) => r.name === name)) return new Response(`${name} is archived: unarchive it first`, { status: 409 })
426
+ const r = find(name)
427
+ if (r == null) return new Response(`not a house repo: ${JSON.stringify(b.name ?? '')}`, { status: 404 })
428
+ try {
429
+ ;(d.fork ?? openInFork)(r.path)
430
+ } catch (e) {
431
+ return new Response((e as Error).message, { status: 400 })
432
+ }
433
+ return Response.json({ ok: true })
434
+ }
355
435
  if (path === '/api/projects/open' && req.method === 'POST') {
356
436
  if (repos.length === 0) await answer()
357
437
  const b = (await req.json().catch(() => ({}))) as { name?: string }
@@ -2,7 +2,7 @@
2
2
  // read of each one: which folders VS Code has open, which Claude Code
3
3
  // sessions run in which repo, its open asks, its distance to the last
4
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
5
+ // project gutter and the dock both list repos through houseRepos, so
6
6
  // the two surfaces show the same repos by construction. The parsers read
7
7
  // text they are handed, so sample output pins them; project-gutter-route.ts
8
8
  // and other-repos.ts own the spawns, the reads and the caches.
@@ -13,11 +13,11 @@ import { gitRoot } from '../core/git-root.ts'
13
13
  import { DB_FILE } from '../ledger/db.ts'
14
14
  import { defaultHubPort } from './hub-port.ts'
15
15
  import { LAYOUT } from '../core/layout.ts'
16
+ import { isArchived } from '../core/archived.ts'
17
+ import { CLAUDE_FILES } from '../home/pm-inputs.ts'
16
18
 
17
19
  export type WiredRepo = { name: string; path: string; icon: string | null }
18
20
 
19
- /** the files a repo carries its includes in */
20
- const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
21
21
 
22
22
  /** the absolute md file of every `@<path>.md` include line a repo's CLAUDE
23
23
  * files carry, in order. A sentence that mentions a path is no include */
@@ -72,11 +72,16 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
72
72
 
73
73
  /** the house repos around the repo at `root`: its main checkout (`home`),
74
74
  * the house index its own includes name, and every sibling that includes the
75
- * same index, `home` among them. No index = no repos */
76
- export function houseRepos(root: string, userHome: string): { home: string; index: string | null; repos: WiredRepo[] } {
75
+ * same index, `home` among them. No index = no repos. An archived repo
76
+ * (`archived: true`, core/archived.ts) is never in `repos`, so nothing that
77
+ * lists through here processes it: it is in `archived`, which only the
78
+ * archived page reads */
79
+ export function houseRepos(root: string, userHome: string): { home: string; index: string | null; repos: WiredRepo[]; archived: WiredRepo[] } {
77
80
  const home = homeRepo(root)
78
81
  const index = houseIndex(home, userHome)
79
- return { home, index, repos: index == null ? [] : wiredRepos(dirname(home), index, userHome) }
82
+ const all = index == null ? [] : wiredRepos(dirname(home), index, userHome)
83
+ const archived = all.filter((r) => isArchived(r.path))
84
+ return { home, index, repos: all.filter((r) => !archived.includes(r)), archived }
80
85
  }
81
86
 
82
87
  /** the checkout a linked worktree belongs to, so a hub started in a worktree
@@ -289,15 +294,31 @@ export function branchOf(head: string): string | null {
289
294
  /** `git status --porcelain`: one line per changed or untracked path */
290
295
  export const dirtyCount = (porcelain: string): number => porcelain.split('\n').filter((l) => l.trim() !== '').length
291
296
 
292
- /** the last gate as its record holds it: green, or the checks whose last
293
- * outcome failed. No row at all = unknown */
294
- export type Gate = 'green' | { reds: string[] } | null
295
- export function gateOf(rows: { id: string; status: string }[]): Gate {
296
- if (rows.length === 0) return null
297
- const reds = rows.filter((r) => r.status === 'fail').map((r) => r.id).sort()
298
- return reds.length === 0 ? 'green' : { reds }
297
+ /** a check's last outcome as the run record keeps it */
298
+ export type LastRow = { id: string; status: string; at?: number }
299
+
300
+ /** every check's last outcome in one colour: red when one failed, orange
301
+ * when one only warned, green when all passed or skipped, with when each
302
+ * red or warning was recorded. Only the ids the repo declares today count
303
+ * (`declared`; null = not known, every row counts), so a renamed or deleted
304
+ * check never holds a repo red. No row left = unknown */
305
+ export type CheckState = { state: 'red' | 'orange' | 'green'; reds: string[]; warns: string[]; since: Record<string, number> } | null
306
+ export function checkStateOf(rows: LastRow[], declared: ReadonlySet<string> | null = null): CheckState {
307
+ const live = declared == null ? rows : rows.filter((r) => declared.has(r.id))
308
+ if (live.length === 0) return null
309
+ const reds = live.filter((r) => r.status === 'fail').map((r) => r.id).sort()
310
+ const warns = live.filter((r) => r.status === 'warn').map((r) => r.id).sort()
311
+ const since: Record<string, number> = {}
312
+ for (const r of live) if ((r.status === 'fail' || r.status === 'warn') && typeof r.at === 'number') since[r.id] = r.at
313
+ return { state: reds.length > 0 ? 'red' : warns.length > 0 ? 'orange' : 'green', reds, warns, since }
299
314
  }
300
315
 
316
+ /** the last gate: green, or the checks whose last outcome failed, read off
317
+ * the same state. Unknown when no declared row is left */
318
+ export type Gate = 'green' | { reds: string[] } | null
319
+ export const gateFrom = (c: CheckState): Gate => (c == null ? null : c.reds.length === 0 ? 'green' : { reds: c.reds })
320
+ export const gateOf = (rows: LastRow[], declared: ReadonlySet<string> | null = null): Gate => gateFrom(checkStateOf(rows, declared))
321
+
301
322
  /** the tag shapes a release leaves: `v1.2.3`, `name@1.2.3` (the shipkit
302
323
  * defaults) and a bare `1.2.3`. A date or a merge marker is no release */
303
324
  export const RELEASE_TAGS = ['v[0-9]*', '*@[0-9]*', '[0-9]*.[0-9]*']
@@ -0,0 +1,57 @@
1
+ // the repo's README for the hub page's empty state, and the images it points
2
+ // at. A file answers only when it is an image AND sits inside the repo root
3
+ // once symlinks resolve: the route must never become a way to read the disk
4
+ import { existsSync, realpathSync, statSync } from 'fs'
5
+ import { extname, isAbsolute, join, relative, resolve } from 'path'
6
+
7
+ const README_NAMES = ['README.md', 'readme.md', 'Readme.md', 'README.markdown', 'README']
8
+
9
+ const IMAGE_TYPES: Record<string, string> = {
10
+ '.png': 'image/png',
11
+ '.jpg': 'image/jpeg',
12
+ '.jpeg': 'image/jpeg',
13
+ '.gif': 'image/gif',
14
+ '.webp': 'image/webp',
15
+ '.svg': 'image/svg+xml',
16
+ '.avif': 'image/avif',
17
+ '.ico': 'image/x-icon',
18
+ }
19
+
20
+ /** the README file name at the root, null when the repo has none */
21
+ export function findReadme(root: string): string | null {
22
+ return README_NAMES.find((n) => existsSync(join(root, n)) && statSync(join(root, n)).isFile()) ?? null
23
+ }
24
+
25
+ /** an image under the root, or null for anything else: another type, a
26
+ * missing file, `..`, an absolute path, a symlink that leaves the root */
27
+ export function readmeAsset(root: string, rel: string): { path: string; type: string } | null {
28
+ if (rel === '' || isAbsolute(rel) || rel.includes('\0')) return null
29
+ const type = IMAGE_TYPES[extname(rel).toLowerCase()]
30
+ if (type == null) return null
31
+ const abs = resolve(root, rel)
32
+ if (!existsSync(abs)) return null
33
+ const real = realpathSync(abs)
34
+ const inside = relative(realpathSync(root), real)
35
+ if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) return null
36
+ if (!statSync(real).isFile()) return null
37
+ return { path: real, type }
38
+ }
39
+
40
+ /** `/api/readme.json` and `/api/readme-file/<path>`, null for any other path */
41
+ export async function readmeRoutes(root: string, path: string): Promise<Response | null> {
42
+ if (path === '/api/readme.json') {
43
+ const file = findReadme(root)
44
+ return Response.json({ file, md: file == null ? null : await Bun.file(join(root, file)).text() })
45
+ }
46
+ const prefix = '/api/readme-file/'
47
+ if (!path.startsWith(prefix)) return null
48
+ let rel: string
49
+ try {
50
+ rel = decodeURIComponent(path.slice(prefix.length))
51
+ } catch {
52
+ return new Response('bad path encoding', { status: 400 })
53
+ }
54
+ const asset = readmeAsset(root, rel)
55
+ if (asset == null) return new Response(`not an image inside this repo: ${rel}`, { status: 404 })
56
+ return new Response(Bun.file(asset.path), { headers: { 'content-type': asset.type, 'cache-control': 'no-cache' } })
57
+ }
@@ -20,6 +20,7 @@ import type { ProseView } from '../prose/prose-view.ts'
20
20
  import type { Timeline } from '../ledger/run-timeline.ts'
21
21
  import type { TestCheckView, TestsRootView } from '../collections/test-time.ts'
22
22
  import type { LiveView } from '../ledger/live-timeline.ts'
23
+ import type { HouseView } from '../ledger/house-runs.ts'
23
24
 
24
25
  export type { LiveView }
25
26
  import type { PlanRow } from '../plan/planner.ts'
@@ -32,6 +33,7 @@ import { projectGutter } from './project-gutter-route.ts'
32
33
  import { sameOrigin } from './same-origin.ts'
33
34
  import { LAYOUT } from '../core/layout.ts'
34
35
  import { WarmCell } from './warm-cell.ts'
36
+ import { readmeRoutes } from './readme-route.ts'
35
37
 
36
38
  export { sameOrigin }
37
39
 
@@ -85,11 +87,11 @@ export type HubDeps = {
85
87
  /** where the test checks' time goes: the `tests` page (check null) or one
86
88
  * test check's page (collections/test-time.ts). Absent = 404 */
87
89
  tests?: (check: string | null) => Promise<TestsRootView | TestCheckView>
88
- /** the live strip's view: the recent runs and the ones in flight, here
89
- * and in the other house repos, shaped on the server clock */
90
+ /** this repo's live view: the recent runs and the ones in flight, shaped
91
+ * on the server clock */
90
92
  live?: () => LiveView
91
- /** another house repo's hub url when it serves, null when it does not */
92
- repoHub?: (repo: string) => Promise<string | null>
93
+ /** the dock's view: every house repo's recent runs (house-runs.ts) */
94
+ house?: () => HouseView
93
95
  /** every human-action item, red/yellow, gathered per request from the
94
96
  * collection providers (run-serve builds it) */
95
97
  needs: () => Promise<NeedItem[]>
@@ -323,12 +325,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
323
325
  }
324
326
  }
325
327
  if (path === '/api/live.json') {
326
- if (d.live == null) return Response.json({ now: Date.now(), historyMs: 0, cap: 0, busy: 0, invocations: [], repos: [], runs: [], queued: 0, blocked: [] } satisfies LiveView)
328
+ if (d.live == null) return Response.json({ now: Date.now(), historyMs: 0, cap: 0, busy: 0, invocations: [], runs: [], queued: 0, blocked: [] } satisfies LiveView)
327
329
  return Response.json(d.live(), { headers: { 'cache-control': 'no-store' } })
328
330
  }
329
- if (path === '/api/repo-hub') {
330
- const repo = new URL(req.url).searchParams.get('repo') ?? ''
331
- return Response.json({ url: d.repoHub == null ? null : await d.repoHub(repo) })
331
+ if (path === '/api/house-runs.json') {
332
+ if (d.house == null) return new Response('this hub draws no house runs', { status: 404 })
333
+ return Response.json(d.house(), { headers: { 'cache-control': 'no-store' } })
332
334
  }
333
335
  if (path === '/api/timeline.json') {
334
336
  if (timeline == null) return new Response('this hub keeps no run record', { status: 404 })
@@ -347,6 +349,9 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
347
349
  return new Response(`the test times could not be shaped: ${(e as Error).message}`, { status: 500 })
348
350
  }
349
351
  }
352
+ // the empty page shows the README, its images served from the repo
353
+ const readme = await readmeRoutes(d.root, path)
354
+ if (readme != null) return readme
350
355
  if (path === '/api/status.json') {
351
356
  const p = join(d.root, LAYOUT.status)
352
357
  return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
@@ -2,7 +2,8 @@
2
2
  // It only says THAT something moved; the plan (plan/planner.ts) then asks the
3
3
  // ledger which checks that moved, so a missed file event costs a stale
4
4
  // problems pane until the next save, never a green.
5
- import { watch, type FSWatcher } from 'fs'
5
+ import { statSync, watch, type FSWatcher } from 'fs'
6
+ import { join } from 'path'
6
7
  import { GENERATED_IGNORE_LINES } from '../rules/untrack-generated.ts'
7
8
  import { gitBin } from '../core/git-bin.ts'
8
9
  import { LAYOUT } from '../core/layout.ts'
@@ -46,6 +47,13 @@ export async function gitIgnored(root: string, paths: string[]): Promise<Set<str
46
47
  }
47
48
  }
48
49
 
50
+ /** a commit, a fetch or a tag moved a ref: the one write inside .git a check
51
+ * keyed on the refs (SK009) must hear. Lock files are git mid write */
52
+ export function isRefMove(rel: string): boolean {
53
+ if (rel.endsWith('.lock')) return false
54
+ return rel === '.git/HEAD' || rel === '.git/packed-refs' || rel.startsWith('.git/refs/')
55
+ }
56
+
49
57
  /** calls onChange whenever a file the owner edits moves under root: tracked,
50
58
  * or untracked and not ignored. What the gate itself writes (GATE_WRITES,
51
59
  * the ledger in .git) never counts, ignored or not, so a run never triggers
@@ -74,6 +82,7 @@ export function watchTracked(root: string, onChange: () => void, o: { ignored?:
74
82
  w = watch(root, { recursive: true }, (_event, name) => {
75
83
  if (name == null) return onChange()
76
84
  const rel = String(name).replace(/\\/g, '/')
85
+ if (isRefMove(rel)) return onChange()
77
86
  if (rel === '.git' || rel.startsWith('.git/') || rel.split('/').includes('node_modules') || isGateWrite(rel)) return
78
87
  pending.add(rel)
79
88
  void flush()
@@ -86,3 +95,26 @@ export function watchTracked(root: string, onChange: () => void, o: { ignored?:
86
95
  }
87
96
  return { close: () => w?.close(), error }
88
97
  }
98
+
99
+ /** did the owner edit anything after `since`: a tracked file, or an untracked
100
+ * one git does not ignore, written later, or a ref that moved. What the gate
101
+ * writes never counts. A boot asks it once per repo, so a burst the previous
102
+ * image had not asked about is picked up (plan/repo-source.ts pending) */
103
+ export async function movedSince(root: string, since: number): Promise<boolean> {
104
+ let out = ''
105
+ try {
106
+ const p = Bun.spawn([gitBin(), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
107
+ out = await new Response(p.stdout).text()
108
+ if ((await p.exited) !== 0) return false
109
+ } catch {
110
+ return false
111
+ }
112
+ const later = (rel: string): boolean => {
113
+ try { return statSync(join(root, rel)).mtimeMs > since } catch { return false }
114
+ }
115
+ for (const rel of out.split('\0')) {
116
+ if (rel === '' || rel.split('/').includes('node_modules') || isGateWrite(rel)) continue
117
+ if (later(rel)) return true
118
+ }
119
+ return later('.git/HEAD') || later('.git/packed-refs')
120
+ }
@@ -1,12 +1,21 @@
1
1
  // one answer of a hub kept warm in memory: computed when the hub loads,
2
2
  // recomputed in the background when something says it moved (a save, a
3
- // stamp, its age), and served from memory. A request never pays for the
4
- // walk behind it, except the very first one and one that asks for `fresh`.
3
+ // stamp, its age) while a page reads it, and served from memory. A cell
4
+ // nobody read for WARM_WATCH_MS computes nothing on a save: its next reader
5
+ // waits on one compute instead, so an unwatched repo costs no cpu per save.
5
6
  //
6
- // the one constraint a reader breaks first: `get()` answers what is in
7
- // memory even when it is known stale. Whoever just changed the truth (an
8
- // act, the end of a run) asks for `fresh`, which waits on a compute that
9
- // started after its call.
7
+ // the one constraint a reader breaks first: `get()` from a watched cell
8
+ // answers what is in memory even when it is known stale. Whoever just
9
+ // changed the truth (an act, the end of a run) asks for `fresh`, which waits
10
+ // on a compute that started after its call.
11
+
12
+ /** a cell read within this long has a reader: a save recomputes it in the
13
+ * background. Past it, nothing recomputes until a reader asks */
14
+ export const WARM_WATCH_MS = 30_000
15
+
16
+ /** a background recompute waits this many times the last compute's length
17
+ * after it ended: a 1.5s answer at most every 15s, a 50ms one at once */
18
+ export const WARM_SPACING = 10
10
19
 
11
20
  export type WarmCellOpts = {
12
21
  /** a cheap fingerprint of what the answer reads (file stats): a new one
@@ -20,6 +29,10 @@ export type WarmCellOpts = {
20
29
 
21
30
  export class WarmCell<T> {
22
31
  private value: { v: T; at: number; stamp: string } | null = null
32
+ /** when a reader last asked, null before any */
33
+ private readAt: number | null = null
34
+ /** when the last compute ended and how long it took */
35
+ private last: { end: number; ms: number } | null = null
23
36
  private running: Promise<T> | null = null
24
37
  /** bumped by invalidate: a compute that started before it is stale */
25
38
  private gen = 0
@@ -37,6 +50,7 @@ export class WarmCell<T> {
37
50
  const gen = this.gen
38
51
  const stamp = this.o.stamp?.() ?? ''
39
52
  this.runGen = gen
53
+ const start = this.now()
40
54
  const p = (async (): Promise<T> => {
41
55
  try {
42
56
  // never synchronously: `running` is set before this settles
@@ -44,6 +58,8 @@ export class WarmCell<T> {
44
58
  this.value = { v, at: this.now(), stamp }
45
59
  return v
46
60
  } finally {
61
+ const end = this.now()
62
+ this.last = { end, ms: end - start }
47
63
  this.running = null
48
64
  // what said it moved while this ran is not in this answer
49
65
  if (this.gen !== gen) this.kick()
@@ -53,11 +69,19 @@ export class WarmCell<T> {
53
69
  return p
54
70
  }
55
71
 
56
- /** a compute in the background: on a failure the last answer stays */
72
+ /** a compute in the background, at most once per WARM_SPACING times the
73
+ * last one: a kick too soon is dropped, and the next read past the gap
74
+ * kicks again. On a failure the last answer stays */
57
75
  private kick(): void {
76
+ if (this.last != null && this.now() < this.last.end + WARM_SPACING * this.last.ms) return
58
77
  void this.run().catch(() => {})
59
78
  }
60
79
 
80
+ /** a reader asked within WARM_WATCH_MS */
81
+ private get watched(): boolean {
82
+ return this.readAt != null && this.now() - this.readAt < WARM_WATCH_MS
83
+ }
84
+
61
85
  get stale(): boolean {
62
86
  const v = this.value
63
87
  if (v == null) return true
@@ -68,23 +92,32 @@ export class WarmCell<T> {
68
92
  /** the answer in memory, a stale one kicking a recompute behind it. With
69
93
  * nothing in memory yet, or `fresh`, it waits on a compute */
70
94
  async get(o: { fresh?: boolean } = {}): Promise<T> {
71
- if (o.fresh === true) {
72
- // a compute that started before this call may miss what the
73
- // caller just changed: wait for it, then for one of our own
74
- if (this.running != null) await this.running.catch(() => {})
75
- return this.run()
76
- }
95
+ const watched = this.watched
96
+ this.readAt = this.now()
97
+ if (o.fresh === true) return this.fresh()
77
98
  const v = this.value
78
99
  // nothing in memory, a failed compute included: wait on a new one
79
100
  if (v == null) return this.run()
80
- if (this.running == null && (this.stale || this.gen !== this.runGen)) this.kick()
101
+ const moved = this.stale || this.gen !== this.runGen
102
+ // the first reader in a while: what is in memory may be hours old,
103
+ // and no save recomputed it meanwhile
104
+ if (moved && !watched) return this.running != null && this.runGen === this.gen ? this.running : this.fresh()
105
+ if (this.running == null && moved) this.kick()
81
106
  return v.v
82
107
  }
83
108
 
84
- /** something this answer reads moved: recompute in the background */
109
+ /** a compute that started after this call: one that started before may
110
+ * miss what the caller just changed, so wait for it, then for our own */
111
+ private async fresh(): Promise<T> {
112
+ if (this.running != null) await this.running.catch(() => {})
113
+ return this.run()
114
+ }
115
+
116
+ /** something this answer reads moved: recompute in the background while
117
+ * a reader watches, else leave it to the next reader */
85
118
  invalidate(): void {
86
119
  this.gen++
87
- if (this.running == null) this.kick()
120
+ if (this.running == null && this.watched) this.kick()
88
121
  }
89
122
 
90
123
  /** compute now unless an answer is already in memory: the hub's load */