rvlib-shipkit 1.1.0 → 1.2.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 (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -0,0 +1,542 @@
1
+ // one repo's hub, alive: its deps resolved from repo.config.ts, its request
2
+ // handler, the save watch over its tracked files, its declared processes,
3
+ // and its RED pings. No server and nothing process wide: `shipkit serve` puts
4
+ // one behind a Bun.serve of its own (run-serve.ts), the daemon runs one per
5
+ // house repo behind /r/<repo>/ of its one server (home/hubs.ts).
6
+ //
7
+ // the one constraint a reader breaks first: a hub starts no run on its own.
8
+ // It joins the host's plan (plan/planner.ts), which decides what runs when,
9
+ // and a save only tells the plan that something moved.
10
+ //
11
+ // what mounts comes from convention + repo.config's `serve` block: the wiki
12
+ // graph is always on (custom provider via serve.wikiGraph, else the generic md
13
+ // scan), the base-context wiki when a CLAUDE.md include names one, agent/
14
+ // graph when the dir exists, the studio when .shipkit/assets/kit.ts loads.
15
+ // Port of a hub serving on its own: --port > serve.port > a deterministic
16
+ // house-range default (4800-4979, hashed from the repo name, stable across
17
+ // launches; pin serve.port when two repos collide).
18
+ import { existsSync, readFileSync, statSync } from 'fs'
19
+ import { homedir } from 'os'
20
+ import { basename, dirname, join, resolve, sep } from 'path'
21
+ import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
22
+ import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
23
+ import { projectNeeds } from '../collections/collection-types.ts'
24
+ import { comfyCollection } from '../collections/comfy.ts'
25
+ import { ComfyPanel, comfyBox, defaultComfyPanelPort, scanComfyWorkflows } from '../comfy/comfy-panel.ts'
26
+ import { wikisCollection } from '../collections/wikis.ts'
27
+ import { pingTick } from '../collections/pings.ts'
28
+ import { buildCollections } from '../collections/providers.ts'
29
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
30
+ import { CheckCtx } from '../core/check-ctx.ts'
31
+ import { proseView } from '../prose/prose-view.ts'
32
+ import { allTasks, gateInventory } from '../ledger/gate-checks.ts'
33
+ import { runCheck, type CheckEvent } from '../core/run-check.ts'
34
+ import { runCi, type CiOpts } from '../core/run-ci.ts'
35
+ import type { GraphMount } from '../wiki/graph-mount.ts'
36
+ import { scanMdGraph } from '../wiki/scan-md.ts'
37
+ import { readInvocations, readLive, readRuns, readRunsSince, runsPath, sweepLive } from '../ledger/run-log.ts'
38
+ import { LIVE_HISTORY_MS, shapeLive, type LiveView } from '../ledger/live-timeline.ts'
39
+ import { parallelCap } from '../core/parallel.ts'
40
+ import { autoGatePool, takeSlot } from '../core/slots.ts'
41
+ import { liveJobs, startJob } from '../core/jobs.ts'
42
+ import { followJob } from '../core/job-follow.ts'
43
+ import { RUN_JOB } from '../home/home-words.ts'
44
+ import { OtherRepos } from './other-repos.ts'
45
+ import { recordStamp } from './project-gutter.ts'
46
+ import { RepoProcesses } from './repo-processes.ts'
47
+ import { shapeTimeline } from '../ledger/run-timeline.ts'
48
+ import { readSuiteRuns, readSuites } from '../ledger/suite-log.ts'
49
+ import { shapeTestCheck, shapeTestsRoot } from '../collections/test-time.ts'
50
+ import type { TestFacts } from '../collections/collection-types.ts'
51
+ import { declaredSlowFiles } from '../suite/test-units.ts'
52
+ import { BUILD_TIMEOUT_MS, CHECK_TIMEOUT_MS } from '../collections/checks.ts'
53
+ import { watchTracked } from './tracked-watch.ts'
54
+ import { repoSource } from '../plan/repo-source.ts'
55
+ import type { PlanSource, Planner } from '../plan/planner.ts'
56
+ import { ProblemBoard } from '../problems/problem-board.ts'
57
+ import { HubProblems } from './hub-problems.ts'
58
+ import { hubHandler, type CheckTap, type HubDeps, type HubHandler } from './serve-hub.ts'
59
+ import type { projectGutter } from './project-gutter-route.ts'
60
+ import { defaultHubPort } from './hub-port.ts'
61
+ import { LAYOUT } from '../core/layout.ts'
62
+ export { defaultHubPort }
63
+
64
+ export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
65
+
66
+
67
+ // the BASE-CONTEXT wiki: the shared wiki this repo's CLAUDE.md include chain
68
+ // points at (`@~/dev/<other-repo>/wiki/index.md`). It lives in ANOTHER repo and
69
+ // binds every agent working here, so the hub makes it browsable. Never linted
70
+ // from here. The local-include forms (`@./agent/index.md`,
71
+ // `@./CLAUDE.local.md`) and any index inside this repo are skipped: those are
72
+ // already their own mounts.
73
+ export function baseWikiRoot(root: string): string | null {
74
+ const abs = resolve(root)
75
+ for (const f of ['CLAUDE.md', 'CLAUDE.local.md', 'CLAUDE.private.md']) {
76
+ const p = join(abs, f)
77
+ if (!existsSync(p)) continue
78
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
79
+ const m = line.trim().match(/^@(\S+\.md)$/)
80
+ if (m == null) continue
81
+ const raw = m[1]!
82
+ const target = resolve(abs, raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw)
83
+ const dir = dirname(target)
84
+ if (!existsSync(target)) continue
85
+ if (dir === abs || dir.startsWith(abs + sep)) continue
86
+ return dir
87
+ }
88
+ }
89
+ return null
90
+ }
91
+
92
+ // `~/dev/<repo>/wiki` reads as "<repo> wiki" — a dir literally named wiki says
93
+ // nothing on its own, so the owning repo names the card
94
+ /** the tab of the repo's own wiki, beside the house wiki named for its repo */
95
+ const WIKI_LABEL = "this repo's wiki"
96
+ const wikiLabel = (dir: string): string => (basename(dir) === 'wiki' ? `${basename(dirname(dir))} wiki` : basename(dir))
97
+
98
+ /** the config file's bytes, cheaply: a re-read costs a new module instance,
99
+ * so it happens only when the file really moved */
100
+ export function configStamp(root: string): string {
101
+ try {
102
+ const st = statSync(join(root, 'repo.config.ts'))
103
+ return `${st.mtimeMs}:${st.size}`
104
+ } catch {
105
+ return ''
106
+ }
107
+ }
108
+
109
+ /** a host that runs every gate as a detached job (the daemon): the cli the
110
+ * job runs, so a reload of the host never kills a gate in flight */
111
+ export type GateJobs = { cli: string; pollMs?: number }
112
+
113
+ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo, o?: { port?: number; base?: string; jobs?: GateJobs; planName?: string }): Promise<HubDeps & { plan: PlanSource }> {
114
+ // THE SWITCH WRITES repo.config.ts, so the surfaces built on the config
115
+ // read the one on disk rather than the one this process booted with. A
116
+ // toggle that never reached the rows would read as a toggle that did
117
+ // nothing. What is resolved ONCE stays once: the kit, the graphs, the
118
+ // studio and the comfy panel are mounts, and remounting a server under a
119
+ // live page is a different question from re-reading a declaration.
120
+ let c = boot
121
+ let stamp = configStamp(root)
122
+ const refresh = async (): Promise<void> => {
123
+ const now = configStamp(root)
124
+ if (now === stamp || now === '') return
125
+ stamp = now
126
+ try {
127
+ c = await loadRepoConfig(root, { fresh: true })
128
+ } catch (e) {
129
+ io.print(`WARN repo.config.ts did not load, so the page is reading the one this process booted with — ${(e as Error).message}`)
130
+ }
131
+ }
132
+
133
+ // ONE list: the routes, the /hub.json tools and the wikis section all
134
+ // read it, so nothing can advertise a graph the server does not serve.
135
+ // The repo's OWN wiki mounts when it has one (a game repo's hub
136
+ // advertising an empty wiki route was startup noise), a config-declared
137
+ // provider always mounts, and the base-context wiki (the shared one named
138
+ // by the CLAUDE.md include chain) mounts wherever it is reachable: an
139
+ // agent working here obeys that wiki, so a reader can open it.
140
+ const graphs: GraphMount[] = []
141
+ if (c.serve?.wikiGraph != null)
142
+ graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
143
+ else if (existsSync(join(root, 'wiki')))
144
+ graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
145
+ const baseWiki = baseWikiRoot(root)
146
+ if (baseWiki != null)
147
+ graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
148
+ const agentRoot = join(root, 'agent')
149
+ if (existsSync(agentRoot))
150
+ graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
151
+ // ONE page for every repo: the studio ALWAYS
152
+ // mounts — kit-less repos get an empty media section, never a second
153
+ // page. A broken kit degrades to the same empty kit, loudly.
154
+ let kit: ResolvedKit | null = null
155
+ if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
156
+ try {
157
+ kit = await loadKit(root)
158
+ } catch (e) {
159
+ io.print(`WARN kit not loaded (media section empty): ${(e as Error).message}`)
160
+ }
161
+ }
162
+ const emptyKit: ResolvedKit = { kinds: [], workspace: '.shipkit/assets/out' }
163
+ let studio: StudioRoutes | null = null
164
+ try {
165
+ studio = await studioRoutes({ kit: kit ?? emptyKit, root })
166
+ } catch (e) {
167
+ io.print(`WARN media API not mounted: ${(e as Error).message}`)
168
+ }
169
+ // a repo with no workflow module gets NO comfy section and no panel
170
+ // process: an empty section header is a surface that teaches nothing
171
+ const hasComfy = scanComfyWorkflows(root).length > 0
172
+ const panel = hasComfy ? new ComfyPanel({ root, port: c.serve?.comfyPort ?? defaultComfyPanelPort(c.name) }) : null
173
+ const allCollections = () => [
174
+ ...buildCollections(root, c, kit),
175
+ ...(hasComfy ? [comfyCollection(root)] : []),
176
+ wikisCollection(graphs, root),
177
+ ]
178
+ const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
179
+ // every run this process has going, whoever started it
180
+ let active = 0
181
+ // ONE run at a time, whoever asked: two gates over one tree would measure
182
+ // the same checks twice and race each other's repair pass
183
+ let chain: Promise<unknown> = Promise.resolve()
184
+ // the latest verdict of every check, printed as one problem frame after
185
+ // each run for the vscode problems pane (problems/problem-line.ts)
186
+ const board = new ProblemBoard()
187
+ // what is wrong with the hub itself rides the same frame, the moment it
188
+ // moves, so the pane never reads 0 while the page shows a hub red
189
+ const hubProblems = new HubProblems((all) => {
190
+ if (board.setHub(all)) for (const l of board.frame()) io.print(l)
191
+ })
192
+ const jobs = o?.jobs ?? null
193
+ // the frame the problems pane reads: one per verdict that moved the board
194
+ const onBoard = (e: CheckEvent): void => { if (board.take(e)) for (const l of board.frame()) io.print(l) }
195
+ // ONE way a run executes. In process for a hub of its own; as a detached
196
+ // job in the daemon, which follows it: its lines and verdicts reach the
197
+ // same taps, and a reload of the daemon leaves it running
198
+ const exec = async (kind: 'ci' | 'check', opts: Record<string, unknown>, tap: CheckTap, background: boolean): Promise<number> => {
199
+ if (jobs == null) {
200
+ const turn = background ? await takeSlot(autoGatePool(), { repo: c.name, check: 'auto gate' }, { pollMs: 250 }) : null
201
+ try {
202
+ if (kind === 'ci') return await runCi(root, { ...(opts as CiOpts), print: tap.line, config: c, onCheck: tap.check })
203
+ return await runCheck(root, { ...(opts as Parameters<typeof runCheck>[1]), print: tap.line, config: c, onCheck: tap.check })
204
+ } finally {
205
+ turn?.release()
206
+ }
207
+ }
208
+ // a job still running here, started before this image or by a terminal's --detach
209
+ while (liveJobs(root).length > 0) await Bun.sleep(jobs.pollMs ?? 250)
210
+ const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background } })
211
+ tap.job?.(j.id)
212
+ return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
213
+ }
214
+ const runChecks: HubDeps['runChecks'] = (tap, o) => {
215
+ const run = chain.then(async () => {
216
+ active++
217
+ // a run the hub starts on its own takes the machine's background
218
+ // turn, once it is this hub's turn, never while it queues
219
+ const background = o?.auto === true
220
+ try {
221
+ return await runIn({
222
+ ...tap,
223
+ check: (e) => {
224
+ // a frame per verdict that moved the board, so a tsc
225
+ // error reaches the pane when typecheck ends rather
226
+ // than when the whole gate does
227
+ onBoard(e)
228
+ tap.check(e)
229
+ },
230
+ }, o, background)
231
+ } finally {
232
+ active--
233
+ // and one per run, so every run ends on a frame
234
+ for (const l of board.frame()) io.print(l)
235
+ }
236
+ })
237
+ chain = run.catch(() => {})
238
+ return run
239
+ }
240
+ // a `shipkit ci` or `shipkit check` from a terminal, run HERE (home/via-hub.ts):
241
+ // in the same one-at-a-time chain as the hub's own runs, feeding the same
242
+ // problems frame, recorded as the cli's
243
+ const runCli: HubDeps['runCli'] = (kind, opts, print) => {
244
+ // never on a --json run: its output is the json and nothing else
245
+ if ((active > 0 || (jobs != null && liveJobs(root).length > 0)) && opts.json !== true) print('note: this hub has a run in flight, so this one starts when it ends')
246
+ const run = chain.then(async () => {
247
+ active++
248
+ try {
249
+ await refresh()
250
+ return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: print, check: onBoard, job: (id) => print(`${RUN_JOB}${id}`) }, false)
251
+ } finally {
252
+ active--
253
+ for (const l of board.frame()) io.print(l)
254
+ }
255
+ })
256
+ chain = run.catch(() => {})
257
+ return run
258
+ }
259
+ const runIn = async (tap: CheckTap, o: Parameters<HubDeps['runChecks']>[1], background: boolean): Promise<number> => {
260
+ const only = o?.only != null && o.only.length > 0 ? o.only : undefined
261
+ const by = o?.by ?? 'hub'
262
+ const buildIds = new Set((await allTasks(c, root)).tasks.map((t) => t.id))
263
+ const namedTasks = (only ?? []).filter((id) => buildIds.has(id))
264
+ // ONE runner, two questions: a build step is the gate's, so
265
+ // anything naming one (or asking for the gate itself) goes through
266
+ // runCi, and a set of everything else takes the health pass
267
+ if (o?.gate === true || namedTasks.length > 0)
268
+ return exec('ci', { ...hubGateOpts({ only, auto: o?.auto === true }), buildOnly: only != null && namedTasks.length === only.length, invokedBy: by }, tap, background)
269
+ // a NARROWED check run must not stamp STATUS.md: that file carries
270
+ // the verdict of a whole run, and one check is not one
271
+ return exec('check', { only, status: only == null, invokedBy: by }, tap, background)
272
+ }
273
+ // a daemon image that boots while a job of its previous image still runs
274
+ // follows it to its end: its verdicts reach the problems pane, and the
275
+ // next run queues behind it
276
+ if (jobs != null) {
277
+ const adopted = liveJobs(root)
278
+ if (adopted.length > 0) {
279
+ io.print(`ok: following ${adopted.length} gate job(s) still running: ${adopted.map((j) => j.id).join(', ')}`)
280
+ active++
281
+ const run = Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).finally(() => {
282
+ active--
283
+ for (const l of board.frame()) io.print(l)
284
+ })
285
+ chain = run.catch(() => {})
286
+ }
287
+ }
288
+ // resolved once: it shells out to git, and the page asks on every poll
289
+ let record = ''
290
+ try { record = runsPath(root) } catch { record = '' }
291
+ // nothing runs in this process yet, so a live row holding its pid was
292
+ // left by an earlier image of it: `bun --watch` restarts in place
293
+ if (record !== '') sweepLive(record, Date.now(), { ownPid: process.pid })
294
+ // the live strip asks every second, from every open tab: the record is
295
+ // read only when its files moved, and one shaped answer serves every
296
+ // request inside LIVE_SHARE_MS
297
+ const others = new OtherRepos(root)
298
+ let local: { stamp: string; at: number; live: ReturnType<typeof readLive>; runs: ReturnType<typeof readRunsSince>; invocations: ReturnType<typeof readInvocations> } | null = null
299
+ let shaped: { at: number; view: LiveView } | null = null
300
+ const LIVE_SHARE_MS = 500
301
+ const live = (): LiveView => {
302
+ const now = Date.now()
303
+ if (shaped != null && now - shaped.at < LIVE_SHARE_MS) return shaped.view
304
+ const stamp = recordStamp(record)
305
+ // the window slides even when nothing is written, so the history is
306
+ // re-read at least every few seconds
307
+ if (local == null || local.stamp !== stamp || now - local.at > 5_000) {
308
+ const since = now - LIVE_HISTORY_MS
309
+ local = { stamp, at: now, live: readLive(record), runs: readRunsSince(record, since), invocations: readInvocations(record, since) }
310
+ }
311
+ const view = shapeLive({ now, cap: parallelCap(), live: local.live, runs: local.runs, invocations: local.invocations, others: others.read() })
312
+ shaped = { at: now, view }
313
+ return view
314
+ }
315
+ // what the plan asks of this repo: its checks, their last results, what
316
+ // a save moved, and a batch through this runner, in the background
317
+ const plan = repoSource({
318
+ repo: o?.planName ?? c.name,
319
+ root,
320
+ config: () => c,
321
+ record,
322
+ run: (ids, g, why) => runChecks({ line: () => {}, check: () => {} }, { only: ids, gate: g.gate, by: why === 'asked' ? 'hub' : 'schedule', auto: true }),
323
+ })
324
+ return {
325
+ root,
326
+ name: c.name,
327
+ uses: c.serve?.uses ?? [],
328
+ processes: new RepoProcesses(root, c.serve?.processes ?? []),
329
+ port,
330
+ graphs,
331
+ studio,
332
+ refresh,
333
+ // the config and the run record, by their stats: what the warm rows
334
+ // and the runs page read besides the tracked files the watch sees
335
+ stamp: () => `${configStamp(root)}|${record === '' ? '' : recordStamp(record)}`,
336
+ runCli,
337
+ // the run happens IN THIS PROCESS, so every check result reaches the
338
+ // page as it finishes. A shelled run could only ever deliver one wall
339
+ // of text, which is what made seventy checks share one destination
340
+ runChecks,
341
+ plan,
342
+ // every run in flight, whoever started it: they all write the same
343
+ // record, so a terminal gate shows here the same as a button's
344
+ live,
345
+ repoHub: (repo) => others.hubOf(repo),
346
+ timeline: async () => {
347
+ const ceilings: Record<string, number> = {}
348
+ const budgets: Record<string, number> = {}
349
+ const inventory = await gateInventory(root, c)
350
+ for (const row of inventory) {
351
+ ceilings[row.decl.id] = row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS)
352
+ budgets[row.decl.id] = row.budgetMs
353
+ }
354
+ return shapeTimeline({
355
+ runs: readRuns(record),
356
+ invocations: readInvocations(record),
357
+ suites: readSuites(record),
358
+ ceilings,
359
+ budgets,
360
+ inventory: inventory.map((row) => row.decl.id),
361
+ declaredSlow: declaredSlowFiles(c),
362
+ cap: parallelCap(),
363
+ })
364
+ },
365
+ tests: async (check) => {
366
+ // the SAME test files the tree lists, from the same provider
367
+ const col = allCollections().find((x) => x.id === 'tests')
368
+ const facts = col == null ? [] : (await col.list()).flatMap((i): TestFacts[] => (i.test == null ? [] : [i.test]))
369
+ const input = { facts, runs: readRuns(record), suiteRuns: readSuiteRuns(record) }
370
+ return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
371
+ },
372
+ prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
373
+ needs: () => projectNeeds(allCollections(), hubProblems.collector),
374
+ hubProblems,
375
+ // a job outlives this image, so only an in process run holds a reload back
376
+ busy: () => jobs == null && active > 0,
377
+ collections: allCollections,
378
+ asksDir: c.asks?.dir ?? LAYOUT.asks,
379
+ comfy: panel == null ? null : { ensure: () => panel.ensure(), restart: () => panel.restart(), box: () => comfyBox(root), stop: () => panel.stop() },
380
+ // RED-only pings while serving; notify command is repo config data.
381
+ // Delivery failure must be LOUD — a silently broken notify path means
382
+ // a silent one reads as no blockers at all
383
+ pingTick: (needs) =>
384
+ pingTick(root, c.asks?.dir ?? LAYOUT.asks, needs, {
385
+ link: c.serve?.publicHost != null ? `http://${c.serve.publicHost}:${port}${o?.base ?? ''}/?f=needs` : undefined,
386
+ run: (cmd) => {
387
+ const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
388
+ void p.exited.then(async (code) => {
389
+ if (code !== 0) io.printErr(`WARN ping delivery failed (exit ${code}): ${await new Response(p.stderr).text().then((s) => s.trim().slice(0, 160))}`)
390
+ })
391
+ },
392
+ notifyCommand: c.notifyCommand,
393
+ }),
394
+ }
395
+ }
396
+
397
+ /** a run the hub starts on its own waits for the machine's one background
398
+ * turn (autoGatePool), so several hubs never gate at once */
399
+ export async function inAutoTurn<T>(repo: string, run: () => Promise<T>, pool = autoGatePool()): Promise<T> {
400
+ const turn = await takeSlot(pool, { repo, check: 'auto gate' }, { pollMs: 250 })
401
+ try {
402
+ return await run()
403
+ } finally {
404
+ turn?.release()
405
+ }
406
+ }
407
+
408
+ /** how a gate the hub starts differs from `shipkit ci`. The gate repairs and
409
+ * stamps STATUS.md, a narrowed run does neither: one check is not the verdict
410
+ * of a gate. A run the hub starts ON ITS OWN never mutates the tree someone
411
+ * is editing: no repair pass, frozen (SK005 never updates a dependency), no
412
+ * status files, and the quick tier */
413
+ export function hubGateOpts(o: { only?: string[]; auto: boolean }): Pick<CiOpts, 'onlyIds' | 'fix' | 'status' | 'frozen' | 'tests'> {
414
+ const whole = o.only == null
415
+ if (o.auto) return { onlyIds: o.only, fix: false, status: false, frozen: true, tests: 'quick' }
416
+ return { onlyIds: o.only, fix: whole, status: whole }
417
+ }
418
+
419
+ /** how often the RED ping looks, on a hub that owns its timers */
420
+ export const PING_EVERY_MS = 60_000
421
+
422
+ export type HubRuntime = {
423
+ name: string
424
+ config: RepoConfig
425
+ deps: HubDeps
426
+ handler: HubHandler
427
+ /** the live half: the save watch, its place in the plan, the declared
428
+ * processes, and the ping interval when the runtime owns it. Nothing
429
+ * runs before it, so a hub that could not bind its port started nothing */
430
+ start: () => void
431
+ /** one RED ping tick */
432
+ ping: () => Promise<void>
433
+ /** a run is in flight: a reload now would kill it */
434
+ busy: () => boolean
435
+ /** everything this runtime started stops, and it leaves the plan. `procs`
436
+ * also stops the repo's declared processes: a real stop of the hub,
437
+ * never a process restart */
438
+ stop: (o?: { procs?: boolean }) => void
439
+ }
440
+
441
+ export type LoadHubOpts = {
442
+ /** where the page sits on the server that answers: '' or '/r/<repo>' */
443
+ base?: string
444
+ port?: number
445
+ /** the rail, shared when one process serves many repos */
446
+ projects?: ReturnType<typeof projectGutter>
447
+ /** the plan this hub joins: the daemon's one for the machine, or a hub
448
+ * of its own's one for its repo. It decides every run nobody asked for */
449
+ plan: Planner
450
+ /** the repo's name in that plan, which the activity signals use */
451
+ planName?: string
452
+ /** a start counts as a save: the checks whose answer would not replay
453
+ * run once it settles. Off where one process loads many repos */
454
+ gateAtStart: boolean
455
+ /** the ping interval runs here. Off = the host calls ping() itself */
456
+ ownTimers: boolean
457
+ /** re-import repo.config.ts rather than reuse the module already loaded */
458
+ fresh?: boolean
459
+ /** overrides where a lane click of the live strip sends another repo */
460
+ repoHub?: HubDeps['repoHub']
461
+ /** every gate runs as a detached job this host follows */
462
+ jobs?: GateJobs
463
+ }
464
+
465
+ /** how long a save burst settles before the warm answers recompute */
466
+ export const WARM_SETTLE_MS = 800
467
+
468
+ /** the hubs of this process warm one after the other: twenty repos walking
469
+ * at once would slow the one a page is waiting on */
470
+ let warming: Promise<void> = Promise.resolve()
471
+
472
+ export async function loadHub(root: string, io: ServeIo, o: LoadHubOpts): Promise<HubRuntime> {
473
+ const c = await loadRepoConfig(root, { fresh: o.fresh === true })
474
+ const deps = await resolveHubDeps(root, c, io, { port: o.port, base: o.base, jobs: o.jobs, planName: o.planName })
475
+ if (o.repoHub != null) deps.repoHub = o.repoHub
476
+ const name = deps.plan.repo
477
+ deps.planned = () => o.plan.rows(name)
478
+ deps.ask = (check) => {
479
+ const ok = o.plan.ask(name, check)
480
+ if (ok) o.plan.tick()
481
+ return ok
482
+ }
483
+ const handler = hubHandler(deps, { base: o.base, projects: o.projects })
484
+ const procs = deps.processes
485
+ // tick failures are LOUD (the hub's own lines), never swallowed
486
+ const ping = async (): Promise<void> => {
487
+ if (deps.pingTick == null) return
488
+ try {
489
+ deps.pingTick(await deps.needs())
490
+ } catch (e) {
491
+ io.printErr(`WARN ping tick broke: ${(e as Error).message}`)
492
+ }
493
+ }
494
+ const live: { stop: () => void }[] = []
495
+ const start = (): void => {
496
+ // the plan hears every save burst and asks the ledger what it moved.
497
+ // The watch decides nothing: the runner measures or replays like any run
498
+ void o.plan.add(deps.plan).then(() => {
499
+ // a start is a change like a save
500
+ if (o.gateAtStart) o.plan.touched(name)
501
+ })
502
+ // a burst of saves is one recompute of the warm answers, once it settles
503
+ let settle: ReturnType<typeof setTimeout> | null = null
504
+ const watcher = watchTracked(root, () => {
505
+ o.plan.touched(name)
506
+ if (settle != null) clearTimeout(settle)
507
+ settle = setTimeout(() => handler.invalidate(), WARM_SETTLE_MS)
508
+ }, { onError: (e) => io.printErr(`WARN the file watch stopped, so the plan hears no save until the hub reloads: ${e.message}`) })
509
+ if (watcher.error != null) io.printErr(`WARN no file watch (${watcher.error}), so the plan hears no save in this repo`)
510
+ live.push({ stop: () => watcher.close() }, { stop: () => o.plan.remove(name) }, { stop: () => { if (settle != null) clearTimeout(settle) } })
511
+ // the answers a page waits on, computed now, one hub at a time
512
+ // across the process, so a first visit reads them from memory
513
+ warming = warming.then(() => handler.warm())
514
+ // the repo's own processes: the auto ones start, a restart adopts the
515
+ // ones still running from their pid records
516
+ void procs?.startAuto()
517
+ if (procs != null && procs.size > 0) {
518
+ const t = setInterval(() => void procs.tick(), 2_000)
519
+ live.push({ stop: () => clearInterval(t) })
520
+ }
521
+ if (o.ownTimers) {
522
+ // one tick now (the state file guarantees a restart never re-fires), then every minute
523
+ void ping()
524
+ const t = setInterval(() => void ping(), PING_EVERY_MS)
525
+ live.push({ stop: () => clearInterval(t) })
526
+ }
527
+ }
528
+ return {
529
+ name: c.name,
530
+ config: c,
531
+ deps,
532
+ handler,
533
+ start,
534
+ ping,
535
+ busy: () => deps.busy?.() === true,
536
+ stop: (s) => {
537
+ for (const l of live.splice(0)) l.stop()
538
+ if (s?.procs === true) procs?.stopAll()
539
+ handler.stop()
540
+ },
541
+ }
542
+ }
@@ -19,6 +19,8 @@ import {
19
19
  recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type Gate, type WiredRepo,
20
20
  } from './project-gutter.ts'
21
21
  import { readLastStatusReadonly } from '../ledger/run-log.ts'
22
+ import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
23
+ import { writeIdentity } from '../core/identity.ts'
22
24
 
23
25
  export type { Gate }
24
26
 
@@ -63,6 +65,8 @@ export type ProjectRow = {
63
65
  release: Release
64
66
  /** the deployed url `smoke.base` declares, or null */
65
67
  live: string | null
68
+ /** the colours its repo.config.ts declares (SK024), null when it declares none */
69
+ identity: Identity | null
66
70
  /** epoch ms of the newest of: the last commit, the last shipkit run, the
67
71
  * newest Claude Code transcript. null when none of them exists */
68
72
  activity: number | null
@@ -278,6 +282,7 @@ export function projectGutter(d: GutterDeps) {
278
282
  gate: gateOfRepo(r.path),
279
283
  release: facts[i]!.release,
280
284
  live: liveUrlOf(config),
285
+ identity: identityOfText(config),
281
286
  activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
282
287
  }
283
288
  })
@@ -321,6 +326,22 @@ export function projectGutter(d: GutterDeps) {
321
326
  headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
322
327
  })
323
328
  }
329
+ if (path === '/api/projects/identity' && req.method === 'POST') {
330
+ if (repos.length === 0) await answer()
331
+ const b = (await req.json().catch(() => ({}))) as { name?: unknown; colorLight?: unknown; colorDark?: unknown }
332
+ const r = find(String(b.name))
333
+ if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
334
+ // a colour left out keeps the config's own, so only the sent ones are checked
335
+ const bad = identityProblem({ colorLight: b.colorLight ?? '#000000', colorDark: b.colorDark ?? '#000000' })
336
+ if (bad != null) return new Response(bad, { status: 400 })
337
+ try {
338
+ const id = writeIdentity(r.path, { colorLight: b.colorLight as string | undefined, colorDark: b.colorDark as string | undefined })
339
+ if (last != null) last = { ...last, projects: last.projects.map((p) => (p.name === r.name ? { ...p, identity: id } : p)) }
340
+ return Response.json({ identity: id })
341
+ } catch (e) {
342
+ return new Response((e as Error).message, { status: 409 })
343
+ }
344
+ }
324
345
  if (path === '/api/projects/order' && req.method === 'POST') {
325
346
  if (repos.length === 0) await answer()
326
347
  const b = (await req.json().catch(() => ({}))) as { names?: unknown }
@@ -46,8 +46,9 @@ export function houseIndex(repo: string, home: string): string | null {
46
46
  return targets.find((t) => !inside(t, repo)) ?? targets.find((t) => !basename(t).startsWith('CLAUDE')) ?? null
47
47
  }
48
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']
49
+ /** a repo's own icon, first hit wins. `.shipkit/identity/icon.png` is the rail
50
+ * icon a repo with no public surface carries, the identity kit writes public/ */
51
+ export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
51
52
 
52
53
  export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
53
54