rvlib-shipkit 1.2.0 → 1.3.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 (138) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +20 -6
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  18. package/src/checks/SK024-identity-colors.check.ts +5 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Ci.ts +5 -0
  25. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  26. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  27. package/src/cli/cmd_Keys.ts +17 -3
  28. package/src/cli/cmd_Overview.ts +98 -0
  29. package/src/cli/cmd_Planned.ts +117 -0
  30. package/src/cli.ts +9 -1
  31. package/src/collections/checks.ts +3 -2
  32. package/src/collections/providers.ts +1 -1
  33. package/src/collections/test-time.ts +10 -1
  34. package/src/core/agent-spawn.ts +46 -0
  35. package/src/core/archived.ts +82 -0
  36. package/src/core/canonical-scripts.ts +12 -3
  37. package/src/core/check-runner.ts +40 -12
  38. package/src/core/discover-checks.ts +2 -0
  39. package/src/core/file-stamp.ts +20 -0
  40. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  41. package/src/core/identity-color.ts +1 -1
  42. package/src/core/repo-facts.ts +14 -0
  43. package/src/core/retention.ts +14 -5
  44. package/src/core/retired-checks.ts +7 -0
  45. package/src/core/run-check.ts +6 -2
  46. package/src/core/run-ci.ts +43 -12
  47. package/src/core/run-fix.ts +7 -2
  48. package/src/core/run-fixers.ts +10 -6
  49. package/src/core/wire.ts +11 -0
  50. package/src/fleet/scan-fleet.ts +2 -1
  51. package/src/home/activity-shape.ts +135 -0
  52. package/src/home/activity.ts +249 -0
  53. package/src/home/global-units.ts +3 -2
  54. package/src/home/home-routes.ts +32 -5
  55. package/src/home/pm.ts +37 -4
  56. package/src/home/process-rows.ts +205 -0
  57. package/src/home/resources.ts +35 -8
  58. package/src/home/run-daemon.ts +6 -6
  59. package/src/home/run-pm.ts +1 -0
  60. package/src/home/serve-web.ts +6 -3
  61. package/src/home/supervise.ts +16 -1
  62. package/src/home/widget-text.ts +119 -0
  63. package/src/hooks/run-hooks.ts +20 -3
  64. package/src/ledger/db.ts +1 -1
  65. package/src/ledger/gate-checks.ts +17 -3
  66. package/src/ledger/house-runs.ts +43 -0
  67. package/src/ledger/live-timeline.ts +4 -19
  68. package/src/ledger/run-log.ts +39 -10
  69. package/src/ledger/store.ts +19 -2
  70. package/src/ledger/world-cache.ts +51 -4
  71. package/src/plan/cadence.ts +21 -5
  72. package/src/plan/catch-up.ts +37 -0
  73. package/src/plan/plan-timeline.ts +99 -0
  74. package/src/plan/planner.ts +138 -13
  75. package/src/plan/repo-source.ts +120 -8
  76. package/src/release/run-release.ts +9 -2
  77. package/src/release/unit-resolve.ts +8 -12
  78. package/src/repo-config.ts +11 -3
  79. package/src/serve/house-runs.ts +53 -0
  80. package/src/serve/hub-app/activity-st.ts +168 -0
  81. package/src/serve/hub-app/activity-view.tsx +218 -0
  82. package/src/serve/hub-app/activity.css +78 -0
  83. package/src/serve/hub-app/admin-svc.ts +41 -0
  84. package/src/serve/hub-app/admin-view.tsx +42 -0
  85. package/src/serve/hub-app/chart-view.ts +12 -5
  86. package/src/serve/hub-app/check-words.ts +2 -0
  87. package/src/serve/hub-app/controls.css +32 -0
  88. package/src/serve/hub-app/cycle-button.tsx +36 -0
  89. package/src/serve/hub-app/dock-anim.ts +25 -0
  90. package/src/serve/hub-app/dock-st.ts +329 -0
  91. package/src/serve/hub-app/dock-view.tsx +68 -0
  92. package/src/serve/hub-app/dock.css +35 -0
  93. package/src/serve/hub-app/hash-route.ts +7 -1
  94. package/src/serve/hub-app/header-bar.tsx +79 -104
  95. package/src/serve/hub-app/home-st.ts +49 -12
  96. package/src/serve/hub-app/home-strip.css +65 -22
  97. package/src/serve/hub-app/home-strip.tsx +228 -97
  98. package/src/serve/hub-app/home.html +4 -0
  99. package/src/serve/hub-app/hub-app.tsx +96 -160
  100. package/src/serve/hub-app/hub.css +77 -37
  101. package/src/serve/hub-app/icons.tsx +23 -3
  102. package/src/serve/hub-app/index.html +4 -0
  103. package/src/serve/hub-app/keys.ts +42 -7
  104. package/src/serve/hub-app/live-svc.ts +24 -64
  105. package/src/serve/hub-app/machine-st.ts +64 -1
  106. package/src/serve/hub-app/md.ts +38 -5
  107. package/src/serve/hub-app/next-run.ts +1 -1
  108. package/src/serve/hub-app/palette-st.ts +11 -7
  109. package/src/serve/hub-app/palette-view.tsx +47 -0
  110. package/src/serve/hub-app/pill-row.tsx +65 -0
  111. package/src/serve/hub-app/planned-st.ts +21 -85
  112. package/src/serve/hub-app/planned-view.tsx +12 -7
  113. package/src/serve/hub-app/planned.css +5 -2
  114. package/src/serve/hub-app/project-list.css +43 -21
  115. package/src/serve/hub-app/project-list.tsx +122 -23
  116. package/src/serve/hub-app/projects-svc.ts +120 -12
  117. package/src/serve/hub-app/readme-svc.ts +38 -0
  118. package/src/serve/hub-app/readme-view.tsx +19 -0
  119. package/src/serve/hub-app/readme.css +11 -0
  120. package/src/serve/hub-app/repo-sections.tsx +61 -0
  121. package/src/serve/hub-app/repo-st.ts +25 -6
  122. package/src/serve/hub-app/runs-tabs.ts +1 -1
  123. package/src/serve/hub-app/sidebar.tsx +84 -0
  124. package/src/serve/hub-app/tests-view.tsx +31 -4
  125. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  126. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  127. package/src/serve/hub-app/timeline-svc.ts +2 -2
  128. package/src/serve/hub-app/timeline-view.tsx +20 -3
  129. package/src/serve/hub-runtime.ts +17 -9
  130. package/src/serve/other-repos.ts +8 -45
  131. package/src/serve/project-gutter-route.ts +100 -20
  132. package/src/serve/project-gutter.ts +33 -11
  133. package/src/serve/readme-route.ts +57 -0
  134. package/src/serve/serve-hub.ts +13 -8
  135. package/src/serve/tracked-watch.ts +33 -1
  136. package/src/shipkit-files.ts +20 -4
  137. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  138. 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,6 +13,7 @@ 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'
16
17
 
17
18
  export type WiredRepo = { name: string; path: string; icon: string | null }
18
19
 
@@ -72,11 +73,16 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
72
73
 
73
74
  /** the house repos around the repo at `root`: its main checkout (`home`),
74
75
  * 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[] } {
76
+ * same index, `home` among them. No index = no repos. An archived repo
77
+ * (`archived: true`, core/archived.ts) is never in `repos`, so nothing that
78
+ * lists through here processes it: it is in `archived`, which only the
79
+ * archived page reads */
80
+ export function houseRepos(root: string, userHome: string): { home: string; index: string | null; repos: WiredRepo[]; archived: WiredRepo[] } {
77
81
  const home = homeRepo(root)
78
82
  const index = houseIndex(home, userHome)
79
- return { home, index, repos: index == null ? [] : wiredRepos(dirname(home), index, userHome) }
83
+ const all = index == null ? [] : wiredRepos(dirname(home), index, userHome)
84
+ const archived = all.filter((r) => isArchived(r.path))
85
+ return { home, index, repos: all.filter((r) => !archived.includes(r)), archived }
80
86
  }
81
87
 
82
88
  /** the checkout a linked worktree belongs to, so a hub started in a worktree
@@ -289,15 +295,31 @@ export function branchOf(head: string): string | null {
289
295
  /** `git status --porcelain`: one line per changed or untracked path */
290
296
  export const dirtyCount = (porcelain: string): number => porcelain.split('\n').filter((l) => l.trim() !== '').length
291
297
 
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 }
298
+ /** a check's last outcome as the run record keeps it */
299
+ export type LastRow = { id: string; status: string; at?: number }
300
+
301
+ /** every check's last outcome in one colour: red when one failed, orange
302
+ * when one only warned, green when all passed or skipped, with when each
303
+ * red or warning was recorded. Only the ids the repo declares today count
304
+ * (`declared`; null = not known, every row counts), so a renamed or deleted
305
+ * check never holds a repo red. No row left = unknown */
306
+ export type CheckState = { state: 'red' | 'orange' | 'green'; reds: string[]; warns: string[]; since: Record<string, number> } | null
307
+ export function checkStateOf(rows: LastRow[], declared: ReadonlySet<string> | null = null): CheckState {
308
+ const live = declared == null ? rows : rows.filter((r) => declared.has(r.id))
309
+ if (live.length === 0) return null
310
+ const reds = live.filter((r) => r.status === 'fail').map((r) => r.id).sort()
311
+ const warns = live.filter((r) => r.status === 'warn').map((r) => r.id).sort()
312
+ const since: Record<string, number> = {}
313
+ for (const r of live) if ((r.status === 'fail' || r.status === 'warn') && typeof r.at === 'number') since[r.id] = r.at
314
+ return { state: reds.length > 0 ? 'red' : warns.length > 0 ? 'orange' : 'green', reds, warns, since }
299
315
  }
300
316
 
317
+ /** the last gate: green, or the checks whose last outcome failed, read off
318
+ * the same state. Unknown when no declared row is left */
319
+ export type Gate = 'green' | { reds: string[] } | null
320
+ export const gateFrom = (c: CheckState): Gate => (c == null ? null : c.reds.length === 0 ? 'green' : { reds: c.reds })
321
+ export const gateOf = (rows: LastRow[], declared: ReadonlySet<string> | null = null): Gate => gateFrom(checkStateOf(rows, declared))
322
+
301
323
  /** the tag shapes a release leaves: `v1.2.3`, `name@1.2.3` (the shipkit
302
324
  * defaults) and a bare `1.2.3`. A date or a merge marker is no release */
303
325
  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
+ }
@@ -49,9 +49,9 @@ export type GlobalProcessDef = {
49
49
  probe?: string
50
50
  /** a button each beside the switch, by key */
51
51
  actions?: Record<string, ProcessAction>
52
- /** with `url`: open it as a shipkit app window. Starting it from the
53
- * page opens that window once it answers, in one go */
54
- app?: boolean
52
+ /** what its card wears: an emoji, or a repo-relative image path
53
+ * (png, svg, ico, jpg, webp). Absent: the declaring repo's own icon */
54
+ icon?: string
55
55
  }
56
56
 
57
57
  export type WidgetDef = {
@@ -74,6 +74,19 @@ const KEBAB = /^[a-z0-9][a-z0-9-]*$/
74
74
  const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
75
75
  const httpUrl = (v: unknown): boolean => typeof v === 'string' && /^https?:\/\//.test(v)
76
76
 
77
+ /** the image types an icon file can be, by extension */
78
+ export const ICON_IMAGE_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp' }
79
+
80
+ /** the image an icon names (its extension), or null when it is text */
81
+ export const iconImageType = (icon: string): string | null => ICON_IMAGE_TYPES[/\.([a-z]+)$/i.exec(icon)?.[1]?.toLowerCase() ?? ''] ?? null
82
+
83
+ function iconProblem(icon: unknown): string | null {
84
+ if (typeof icon !== 'string' || icon.trim() === '') return 'icon is an emoji or a repo relative image path'
85
+ if (iconImageType(icon) != null) return icon.startsWith('/') || icon.split(/[\\/]/).includes('..') ? `icon '${icon}' must be a repo relative path` : null
86
+ if (/[\\/.]/.test(icon) || [...icon].length > 8) return `icon '${icon}' is neither an emoji nor an image path (${Object.keys(ICON_IMAGE_TYPES).join(', ')})`
87
+ return null
88
+ }
89
+
77
90
  /** why a global process declaration cannot run, or null */
78
91
  export function globalProcessProblem(d: GlobalProcessDef): string | null {
79
92
  const at = `globalProcess '${String(d.id)}'`
@@ -83,7 +96,10 @@ export function globalProcessProblem(d: GlobalProcessDef): string | null {
83
96
  if (d.url != null && !httpUrl(d.url)) return `${at}: url must be an http url`
84
97
  if (d.probe != null && !httpUrl(d.probe)) return `${at}: probe must be an http url`
85
98
  if (d.down != null && d.probe == null && d.url == null) return `${at}: a bring-up (one with down) needs probe or url, the only truth of whether it is up`
86
- if (d.app === true && d.url == null) return `${at}: app needs a url to open`
99
+ if (d.icon !== undefined) {
100
+ const why = iconProblem(d.icon)
101
+ if (why != null) return `${at}: ${why}`
102
+ }
87
103
  for (const [k, a] of Object.entries(d.actions ?? {})) {
88
104
  if (!KEBAB.test(k)) return `${at}: action '${k}' must be kebab-case`
89
105
  if ((a.cmd != null) === (a.run != null)) return `${at}: action '${k}' declares exactly one of cmd or run`
@@ -1,49 +0,0 @@
1
- // SK023 stores under cap (core/retention.ts owns the caps): every store shipkit
2
- // writes for this repo, and the machine's own when a home dir exists, is
3
- // inside its declared cap plus the drift one sweep interval allows. Red means
4
- // the daemon's hourly sweep stopped running, or a store grows faster than its
5
- // cap assumes. The fix runs the sweep.
6
- import { existsSync } from 'fs'
7
- import { check } from '../check.ts'
8
- import { fmtAge, fmtBytes, measureStores, repoStores, sweepStores, type Store } from '../core/retention.ts'
9
- import { homeDir } from '../home/home-paths.ts'
10
- import { machineStores } from '../home/machine-stores.ts'
11
-
12
- const storesOf = (root: string): Store[] => [...repoStores(root), ...(existsSync(homeDir()) ? machineStores(homeDir()) : [])]
13
-
14
- const capWords = (s: Store): string =>
15
- [
16
- s.cap.maxCount != null ? `${s.cap.maxCount} entries` : null,
17
- s.cap.maxAgeMs != null ? fmtAge(s.cap.maxAgeMs) : null,
18
- s.cap.maxFileBytes != null ? `${fmtBytes(s.cap.maxFileBytes)} a file` : null,
19
- s.cap.maxBytes != null ? fmtBytes(s.cap.maxBytes) : null,
20
- ].filter((x) => x != null).join(', ')
21
-
22
- export const storesUnderCap = check({
23
- id: 'SK023',
24
- blocking: false,
25
- title: 'stores under cap',
26
- description: 'every store shipkit writes (answers, run record, logs, jobs) is inside its cap',
27
- owner: 'build/shipkit/built-in-checks.md',
28
- when: 'health',
29
- run: (c) => {
30
- const all = measureStores(storesOf(c.root))
31
- const over = all.filter((m) => m.over != null)
32
- const unread = all.filter((m) => m.use == null)
33
- for (const m of over) c.fail(`${m.store.id} over its cap: ${m.over} (${m.store.where})`, 'shipkit check --fix SK023 runs the sweep the daemon runs hourly')
34
- for (const m of unread) c.warn(`${m.store.id} could not be read (${m.store.where})`)
35
- if (over.length === 0) c.ok(`${all.length} stores inside their caps: ${all.map((m) => `${m.store.id} ${m.use?.count ?? '?'} (cap ${capWords(m.store)})`).join(', ')}`)
36
- },
37
- fix: async (c) => {
38
- const stores = storesOf(c.root)
39
- const before = measureStores(stores).filter((m) => m.over != null)
40
- const r = await sweepStores(stores)
41
- const broke = r.filter((x) => x.error != null)
42
- return {
43
- changed: before.length > 0,
44
- summary: `swept ${stores.length} stores${before.length > 0 ? `, ${before.map((m) => m.store.id).join(', ')} were over` : ''}${broke.length > 0 ? `; failed: ${broke.map((x) => `${x.id} (${x.error})`).join(', ')}` : ''}`,
45
- }
46
- },
47
- })
48
-
49
- export default storesUnderCap
@@ -1,20 +0,0 @@
1
- // the contracts check — `.shipkit/*` release-contract fulfillness. It shares ONE
2
- // evaluation with check SK002 (the `contractsResult` scan), and it tags with
3
- // that id, because the invariant has one owner and two surfaces report it. A
4
- // venue deploy step deps on it BY NAME, which needs nothing from this end.
5
- import { applyContractsResult, contractsResult } from '../contracts/apply-contracts.ts'
6
- import { check } from '../check.ts'
7
- import { contractsFulfillness } from './SK002-contracts-fulfillness.check.ts'
8
-
9
- export const contractsCheck = check({
10
- id: 'contracts',
11
- title: 'contracts',
12
- description: 'every declared venue has the `.shipkit/*` release contract it demands',
13
- owner: 'meta/contracts.md',
14
- blocking: true,
15
- run: async (c) => {
16
- applyContractsResult(c, await c.use(contractsResult), contractsFulfillness)
17
- },
18
- })
19
-
20
- export default contractsCheck