rvlib-shipkit 0.38.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -5,6 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>hub</title>
7
7
  <link rel="stylesheet" href="./hub.css" />
8
+ <link rel="stylesheet" href="./project-gutter.css" />
9
+ <link rel="stylesheet" href="./prose-hits.css" />
8
10
  </head>
9
11
  <body>
10
12
  <div id="root"></div>
@@ -0,0 +1,174 @@
1
+ // The live strip under the header, as the page holds it: `/api/live.json`
2
+ // read every second (the runs of the last ten minutes, the ones in flight,
3
+ // here and in the other house repos), whether the strip is folded, and the
4
+ // chart it draws. Folded, the chart is the thin rolling window of the last
5
+ // two minutes; unfolded, the whole record (the recent runs of /api/live.json
6
+ // over the older ones of /api/timeline.json), opened on its last ten minutes
7
+ // and fit to all of it on demand. It is the hub's one run chart
8
+ // (chart-view.ts, timeline-chart.tsx).
9
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
10
+ import type { LiveView } from '../../ledger/live-timeline.ts'
11
+ import type { TimelineInvocation } from '../../ledger/run-timeline.ts'
12
+ import { ChartView, type ChartSource } from './chart-view.ts'
13
+ import type { HubSt } from './hub-st.ts'
14
+ import { chartHeight } from './timeline-geometry.ts'
15
+
16
+ /** how often the page asks. A strip a second behind the runner is still live */
17
+ export const LIVE_POLL_MS = 1_000
18
+ /** the folded strip's window, and the span the unfolded one opens on */
19
+ export const FOLDED_WINDOW_MS = 120_000
20
+ export const OPEN_WINDOW_MS = 600_000
21
+ /** the most the unfolded chart grows to: past it the lanes scroll inside it */
22
+ export const OPEN_MAX_H = 320
23
+ /** how long the unfolded chart keeps a height its lanes no longer fill,
24
+ * so it grows at once and never shrinks under the eye every second */
25
+ export const HEIGHT_HOLD_MS = 10_000
26
+
27
+ /** the height the unfolded chart shows: up at once, down only once the
28
+ * lanes have wanted less for HEIGHT_HOLD_MS */
29
+ export type HeldHeight = { h: number; lowSince: number | null }
30
+ export function settleHeight(prev: HeldHeight, wanted: number, now: number, holdMs = HEIGHT_HOLD_MS): HeldHeight {
31
+ if (wanted >= prev.h) return { h: wanted, lowSince: null }
32
+ if (prev.lowSince == null) return { h: prev.h, lowSince: now }
33
+ return now - prev.lowSince >= holdMs ? { h: wanted, lowSince: null } : prev
34
+ }
35
+
36
+ /** the whole record the unfolded chart draws: every run of the live view,
37
+ * then the older runs of the record it does not carry, newest first */
38
+ export function mergeRuns(recent: TimelineInvocation[], record: TimelineInvocation[]): TimelineInvocation[] {
39
+ const seen = new Set(recent.map((i) => i.runId))
40
+ return [...recent, ...record.filter((i) => !seen.has(i.runId))].sort((a, b) => b.started - a.started)
41
+ }
42
+ /** the lanes the folded strip has room for */
43
+ export const FOLDED_LANES = 5
44
+
45
+ /** where the fold is remembered. localStorage is per origin and every
46
+ * repo's hub has its own port, so this is per repo already */
47
+ export const OPEN_KEY = 'hub:liveOpen'
48
+
49
+ const readOpen = (): boolean => {
50
+ try { return globalThis.localStorage?.getItem(OPEN_KEY) === '1' } catch { return false }
51
+ }
52
+
53
+ /** the server clock now: the view's stamp plus what the page clock has
54
+ * counted since it arrived, since the page may be another machine */
55
+ export const serverNow = (view: LiveView, receivedAt: number, clientNow: number): number => view.now + Math.max(0, clientNow - receivedAt)
56
+
57
+ /** what the chart draws: folded, the rolling two minutes; open, the whole
58
+ * record, opening on its last ten */
59
+ export function liveSource(view: LiveView | null, now: number, open: boolean, record: TimelineInvocation[] = []): ChartSource | null {
60
+ if (view == null) return null
61
+ if (!open) return { invocations: view.invocations, now, windowMs: FOLDED_WINDOW_MS, laneCap: Math.max(1, FOLDED_LANES - view.repos.length), zoomable: false, cap: view.cap }
62
+ return { invocations: mergeRuns(view.invocations, record), now, windowMs: null, zoomable: true, cap: view.cap, homeSpanMs: OPEN_WINDOW_MS }
63
+ }
64
+
65
+ export class LiveSvc {
66
+ view: LiveView | null = null
67
+ /** the page clock when `view` arrived */
68
+ receivedAt = 0
69
+ error = ''
70
+ /** a short answer to a click (a repo whose hub is not serving) */
71
+ note = ''
72
+ open = readOpen()
73
+ /** the unfolded chart's height, held so it does not jump every second */
74
+ held: HeldHeight = { h: 0, lowSince: null }
75
+ chart: ChartView
76
+ private timer: ReturnType<typeof setInterval> | null = null
77
+ private inFlight = false
78
+
79
+ constructor(private st: HubSt) {
80
+ this.chart = new ChartView(null, () => liveSource(this.view, this.now, this.open, this.st.timeline.data?.invocations ?? []))
81
+ makeAutoObservable<this, 'st' | 'timer' | 'inFlight'>(this, { st: false, timer: false, inFlight: false, view: observableRef, chart: false })
82
+ }
83
+
84
+ /** the server clock, as of the page clock's last tick */
85
+ get now(): number {
86
+ return this.view == null ? this.st.clock.now : serverNow(this.view, this.receivedAt, this.st.clock.now)
87
+ }
88
+
89
+ toggle(): void {
90
+ this.open = !this.open
91
+ try { globalThis.localStorage?.setItem(OPEN_KEY, this.open ? '1' : '0') } catch { /* private window: the fold lasts the tab */ }
92
+ // either fold starts live: the folded strip is a trail, nothing else
93
+ this.chart.attach()
94
+ this.opened()
95
+ }
96
+
97
+ /** unfolded: back to the last ten minutes, the older record fetched, and
98
+ * a height from the lanes as they are */
99
+ private opened(): void {
100
+ if (!this.open) return
101
+ this.chart.home()
102
+ this.chart.attach()
103
+ this.held = { h: 0, lowSince: null }
104
+ this.settle(this.st.clock.now)
105
+ if (this.st.timeline.data == null) void this.st.timeline.load()
106
+ }
107
+
108
+ /** the height the lanes want now, as the held rule lets it show */
109
+ settle(now: number): void {
110
+ const l = this.chart.layout
111
+ const lanes = (l?.lanes ?? 0) + (this.view?.repos.length ?? 0)
112
+ this.held = settleHeight(this.held, Math.min(OPEN_MAX_H, chartHeight(lanes)), now)
113
+ }
114
+
115
+ start(): void {
116
+ if (this.timer != null) return
117
+ this.opened()
118
+ void this.poll()
119
+ this.timer = setInterval(() => void this.poll(), LIVE_POLL_MS)
120
+ }
121
+ dispose(): void {
122
+ if (this.timer != null) clearInterval(this.timer)
123
+ this.timer = null
124
+ }
125
+
126
+ async poll(): Promise<void> {
127
+ // a slow answer must not stack a second request behind it
128
+ if (this.inFlight) return
129
+ this.inFlight = true
130
+ try {
131
+ const r = await fetch('/api/live.json')
132
+ if (!r.ok) throw new Error(await r.text())
133
+ const v = (await r.json()) as LiveView
134
+ const ended = this.running && v.runs.length === 0
135
+ runInAction(() => {
136
+ this.view = v
137
+ this.receivedAt = Date.now()
138
+ this.error = ''
139
+ if (this.open) this.settle(Date.now())
140
+ })
141
+ // a run that ended, from a terminal as much as from a button,
142
+ // wrote new numbers: the rows and the timeline read them now
143
+ // rather than on the next ten second poll
144
+ if (ended) {
145
+ void this.st.cols.poll()
146
+ if (this.st.timeline.data != null) void this.st.timeline.load()
147
+ }
148
+ } catch (e) {
149
+ runInAction(() => { this.error = `the live runs could not be read: ${(e as Error).message}` })
150
+ } finally {
151
+ this.inFlight = false
152
+ }
153
+ }
154
+
155
+ get running(): boolean {
156
+ return (this.view?.runs.length ?? 0) > 0
157
+ }
158
+
159
+ /** a repo lane was clicked: its hub, when it serves */
160
+ async openRepo(repo: string): Promise<void> {
161
+ try {
162
+ const r = (await (await fetch(`/api/repo-hub?repo=${encodeURIComponent(repo)}`)).json()) as { url: string | null }
163
+ if (r.url != null) window.open(r.url, '_blank')
164
+ else this.say(`${repo} has no hub serving`)
165
+ } catch (e) {
166
+ this.say(`${repo}: ${(e as Error).message}`)
167
+ }
168
+ }
169
+
170
+ private say(text: string): void {
171
+ runInAction(() => { this.note = text })
172
+ setTimeout(() => runInAction(() => { if (this.note === text) this.note = '' }), 4_000)
173
+ }
174
+ }
@@ -22,3 +22,18 @@ export const renderMd = (src: string): string =>
22
22
  a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
23
23
  },
24
24
  })
25
+
26
+ /** ONE LINE of markdown, for text that already carries a line structure of
27
+ * its own. A wire line keeps its prefix and its place in the log, and the
28
+ * backtick spans and urls inside it still render: no block tag survives the
29
+ * allowlist, so nothing here can break the line it was given. */
30
+ export const renderMdInline = (src: string): string =>
31
+ sanitizeHtml(marked.parseInline(src) as string, {
32
+ allowedTags: ['code', 'strong', 'em', 'b', 'i', 'a'],
33
+ allowedAttributes: { a: ['href', 'target', 'rel'] },
34
+ allowedSchemes: ['http', 'https'],
35
+ allowProtocolRelative: false,
36
+ transformTags: {
37
+ a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
38
+ },
39
+ })
@@ -552,7 +552,10 @@ export class MediaSvc {
552
552
  if (k === 'ArrowRight' && i < rolls.length - 1) this.setActive(rolls[i + 1]!.name)
553
553
  else if (k === 'ArrowLeft' && i > 0) this.setActive(rolls[i - 1]!.name)
554
554
  else if (k === 'Enter' && this.active != null) void this.pick(this.active)
555
- else if ((k === 'a' || k === 'A') && this.active != null) void this.alt(this.active)
555
+ // SHIFT+A only, the binding the help line prints: a bare `a` belongs
556
+ // to the page (the all/needs/red filter), and a letter bound in both
557
+ // places is bound in neither
558
+ else if (k === 'A' && this.active != null) void this.alt(this.active)
556
559
  else if ((k === 'x' || k === 'X') && this.active != null) void this.discard(this.active)
557
560
  else return false
558
561
  return true
@@ -144,6 +144,10 @@ export const ToolsBar = observer(({ app }: { app: HubSt }) => {
144
144
  const label = (id: string): string =>
145
145
  id === 'json' ? 'asset json' : id === 'meta' ? 'roll meta' : id.startsWith('c:') ? id.slice(2) : id
146
146
  const cur = [...m.tools].sort().join(',')
147
+ // one draft per KIND, never per asset: tuning the loras for the road rat is
148
+ // tuning them for all 107 creatures, which is the point of a shared draft
149
+ const wfId = d.info?.workflow?.id ?? null
150
+ const kindId = d.info?.kindId ?? 'default'
147
151
  return (
148
152
  <div className="toolsbar">
149
153
  {/* tabs = tool presets (kit-declared first) — switching swaps the
@@ -157,6 +161,12 @@ export const ToolsBar = observer(({ app }: { app: HubSt }) => {
157
161
  {m.validTools(d).map((id) => (
158
162
  <button key={id} className={`chip${m.tools.has(id) ? ' on' : ''}`} onClick={() => m.toggleTool(id)}>{label(id)}</button>
159
163
  ))}
164
+ {/* the workflow behind this asset, tuned where the asset already is.
165
+ Only on an asset a comfy workflow actually draws */}
166
+ {wfId != null ? (
167
+ <button className="chip" title={`tune ${wfId} for every ${kindId} asset`}
168
+ onClick={() => void app.comfy.tune(wfId, kindId)}>tune workflow</button>
169
+ ) : null}
160
170
  <span className="sp" />
161
171
  <button className="ghost" onClick={() => m.toggleVariants()}>
162
172
  {m.variantsOpen ? 'variants ▸' : `variants (${d.rolls.length}) ◂`}
@@ -263,6 +273,11 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
263
273
  <label>size for ALL {cat} <input type="number" step={0.1} min={0.1} value={m.allSize}
264
274
  onChange={(e) => runInAction(() => { m.allSize = e.target.value })} /></label>
265
275
  <button className="ghost" onClick={() => void m.applyAllSize()}>apply</button>
276
+ {/* rolls for the whole category, the one control the tree
277
+ has no row to carry */}
278
+ {m.catRows.every((r) => m.assets.find((a) => a.id === r.id)?.manual === true) ? null : (
279
+ <span className="catgen">{[1, 2, 3].map((n) => <button key={n} className="ghost" onClick={() => void m.genCat(cat, n)}>+{n}</button>)}</span>
280
+ )}
266
281
  <span className="jmsg">{m.allMsg}</span>
267
282
  </div>
268
283
  <div className="cards">
@@ -0,0 +1,68 @@
1
+ // WHEN A CHECK RUNS NEXT, as a true statement. Nothing runs a check on a clock
2
+ // except the hub's daily health pass (serve/health-schedule.ts), so the answer
3
+ // is read off where the check runs (`when`), what it remembers, and that one
4
+ // timer. Pure, so every case is pinned by a test rather than a screenshot.
5
+ import type { Schedule, CheckFacts } from './hub-types.ts'
6
+
7
+ export type NextRun = {
8
+ /** the value the page shows */
9
+ words: string
10
+ /** the exact moment, when there is one: the tooltip's stamp */
11
+ at: number | null
12
+ /** the sentence behind the value, for the row's help */
13
+ help: string
14
+ }
15
+
16
+ const MIN = 60_000
17
+ const HOUR = 60 * MIN
18
+ const DAY = 24 * HOUR
19
+
20
+ /** a span ahead, in the two largest units: `~14h`, `~3d11h`, `~25m` */
21
+ export function aheadWords(ms: number): string {
22
+ if (ms < MIN) return '<1m'
23
+ if (ms < HOUR) return `~${Math.round(ms / MIN)}m`
24
+ if (ms < DAY) return `~${Math.round(ms / HOUR)}h`
25
+ let d = Math.floor(ms / DAY)
26
+ let h = Math.round((ms - d * DAY) / HOUR)
27
+ if (h === 24) { d++; h = 0 }
28
+ return `~${d}d${h > 0 ? `${h}h` : ''}`
29
+ }
30
+
31
+ /** a span as a rhythm: `24h`, `3d`, `90m`. Whole days read as days from two
32
+ * days up, because `every ~72h` makes a reader do the division */
33
+ export function spanWords(ms: number): string {
34
+ if (ms >= 2 * DAY && ms % DAY === 0) return `${ms / DAY}d`
35
+ if (ms >= HOUR) return `${Math.round((ms / HOUR) * 10) / 10}h`
36
+ if (ms >= MIN) return `${Math.round(ms / MIN)}m`
37
+ return `${Math.round(ms / 1000)}s`
38
+ }
39
+
40
+ export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'> & { keepsUntil?: number | null }
41
+
42
+ export function nextRun(u: NextRunFacts, s: Schedule | null, now: number): NextRun {
43
+ if (u.when === 'always')
44
+ return { words: 'next shipkit ci', at: null, help: 'it runs on every `shipkit ci` and in every health pass. A cached answer is used when nothing it depends on moved' }
45
+ if (u.when === 'release')
46
+ return { words: 'before a release', at: null, help: '`shipkit ci --full` and the release path run it, and so does the health pass. The quick gate you run while working does not' }
47
+ if (u.when === 'off')
48
+ return { words: u.whenReason != null && u.whenReason !== '' ? `never: ${u.whenReason}` : 'never', at: null, help: 'it is off: nothing runs it until the switch below puts it somewhere' }
49
+ if (s == null)
50
+ return { words: 'next health pass', at: null, help: 'this hub runs no daily timer, so it runs when `shipkit check` runs or when you press run the health pass' }
51
+ if (s.running) return { words: 'running now', at: now, help: 'the daily health pass is running right now' }
52
+ const pass = Math.max(now, s.nextAt)
53
+ const keeps = u.keepsUntil ?? null
54
+ if (keeps != null && keeps > pass) {
55
+ const k = Math.ceil((keeps - pass) / s.everyMs)
56
+ const at = pass + k * s.everyMs
57
+ return {
58
+ words: `in ${aheadWords(at - now)}`,
59
+ at,
60
+ help: `the health pass runs every ${spanWords(s.everyMs)}, and uses this check's cached answer until it runs out. The first pass after that runs it again`,
61
+ }
62
+ }
63
+ return {
64
+ words: pass <= now ? 'due now' : `in ${aheadWords(pass - now)}`,
65
+ at: pass,
66
+ help: `the next health pass: the hub runs it every ${spanWords(s.everyMs)} while it serves, and at startup when the last one is older than that`,
67
+ }
68
+ }
@@ -0,0 +1,65 @@
1
+ /* the project gutter. It is FIXED to the left edge, so it takes no cell of
2
+ the .app grid, and the page makes room for it by padding, whatever the
3
+ grid holds. Colours are hub.css tokens only, spacing is its scale:
4
+ tests/hub-css-invariants.test.ts reads this sheet too. */
5
+ .app:has(> .pgut){--gut:44px;padding-left:var(--gut)}
6
+ .pgut{position:fixed;left:0;top:0;bottom:0;width:var(--gut,44px);z-index:5;display:flex;flex-direction:column;align-items:center;
7
+ gap:var(--s1);padding:var(--s2) 0;background:var(--bg);border-right:1px solid var(--line);overflow-y:auto;scrollbar-width:none}
8
+ .pgt{position:relative;flex:none;width:32px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;
9
+ background:transparent;border:0;border-radius:6px;cursor:pointer}
10
+ .pgt:hover{background:var(--panel2)}
11
+ .pgt.cur{background:var(--sel)}
12
+ .pgt img{width:24px;height:24px;border-radius:5px;object-fit:cover;pointer-events:none}
13
+ /* the initial on a hue hashed from the name (projects-svc hueOf) */
14
+ .pgi{width:24px;height:24px;border-radius:5px;display:flex;align-items:center;justify-content:center;
15
+ font-size:12px;font-weight:600;user-select:none;color:var(--c);background:color-mix(in srgb,var(--c) 22%,var(--panel2))}
16
+ .pgi.h1{--c:var(--k1)}
17
+ .pgi.h2{--c:var(--k2)}
18
+ .pgi.h3{--c:var(--k3)}
19
+ .pgi.h4{--c:var(--k4)}
20
+ .pgi.h5{--c:var(--k5)}
21
+ .pgi.h6{--c:var(--k6)}
22
+ /* the face says three things and no more (projects-svc faceOf) */
23
+ /* closed in VS Code: the icon dims, the badge does not */
24
+ .pgt.dim img,.pgt.dim .pgi{opacity:.45}
25
+ /* the one badge: open asks, top right */
26
+ .pga{position:absolute;top:0;right:0;min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;
27
+ font-size:9px;font-weight:700;line-height:14px;text-align:center;color:var(--bg);background:var(--warn)}
28
+ /* Claude sessions running: a thin edge on the left that breathes */
29
+ .pgs{position:absolute;left:0;top:6px;bottom:6px;width:2px;border-radius:1px;background:var(--accent);pointer-events:none;animation:pgpulse 2.4s ease-in-out infinite}
30
+ @keyframes pgpulse{0%,100%{opacity:1}50%{opacity:.25}}
31
+ @media (prefers-reduced-motion:reduce){.pgs{animation:none}}
32
+ /* the hover card: right of the rail, one at a time, never interactive.
33
+ Header, then what needs a human, then the label and value rows of the
34
+ check page (.wfact .wk .wv from hub.css) */
35
+ .pgcard{position:fixed;left:calc(var(--gut,44px) + var(--s2));z-index:20;width:300px;padding:var(--s3);pointer-events:none;
36
+ background:var(--panel);border:1px solid var(--line);border-radius:4px;box-shadow:0 6px 20px var(--shadow);font-size:12px;line-height:1.45;color:var(--ink)}
37
+ .pgch{display:flex;align-items:center;gap:var(--s2);min-width:0}
38
+ .pgcface{flex:none;display:flex}
39
+ .pgcface img,.pgcface .pgi{width:20px;height:20px;border-radius:4px;font-size:11px}
40
+ .pgcname{flex:1;min-width:0;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
41
+ .pgcthis{font-weight:400;font-size:11px;color:var(--dim)}
42
+ .pgcver{flex:none;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums}
43
+ .pgcline{margin-top:var(--s1);color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
44
+ .pgcdesc{margin-top:var(--s1);color:var(--dim);font-size:11px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
45
+ /* NEEDS YOU: a warn toned bullet, set apart from the facts */
46
+ .pgcneeds{margin-top:var(--s2);padding:var(--s1) var(--s2);border-left:2px solid var(--warn);background:var(--panel2);border-radius:0 3px 3px 0}
47
+ .pgcneed{display:flex;gap:var(--s1);min-width:0;color:var(--warn)}
48
+ .pgcneed.red{color:var(--red);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
49
+ .pgcask{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
50
+ .pgcmore{flex:none}
51
+ .pgcfacts{margin-top:var(--s2)}
52
+ .pgcfacts .wk{flex-basis:64px;white-space:nowrap}
53
+ .pgcfacts .wv{text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;word-break:normal}
54
+ .pgcfacts .wv.warn{color:var(--warn)}
55
+ .pgerr{font-size:12px;font-weight:700;color:var(--red);cursor:help}
56
+ /* the one time sort by last activity, above the icons */
57
+ .pgsort{flex:none;width:32px;height:20px;padding:0;background:transparent;border:1px solid var(--line);border-radius:3px;
58
+ color:var(--dim);font-size:12px;line-height:18px;cursor:pointer}
59
+ .pgsort:hover{color:var(--ink);border-color:var(--accent)}
60
+ /* a drag: the tile being moved fades, and the gap it would land in is a line
61
+ drawn on a tile edge, centred in the gap between two tiles */
62
+ .pgt.dragging{opacity:.4}
63
+ .pgt.dropb::before,.pgt.dropa::after{content:'';position:absolute;left:2px;right:2px;height:2px;border-radius:1px;background:var(--accent);pointer-events:none}
64
+ .pgt.dropb::before{top:-3px}
65
+ .pgt.dropa::after{bottom:-3px}
@@ -0,0 +1,128 @@
1
+ // the project gutter: one icon per repo that shares this hub's house wiki,
2
+ // down the far left of the page. The face stays quiet (faceOf): at most one
3
+ // badge, the open asks; the icon dims while VS Code does not have the folder
4
+ // open; a thin edge pulses while Claude sessions run in it; the selected
5
+ // background marks this hub's own repo. Everything else is the hover card,
6
+ // shown at once to the right of the icon. A click opens the folder in VS
7
+ // Code, a drag reorders the rail, and the button on top sorts it once by
8
+ // last activity. Fixed position, so it takes no cell of the page grid
9
+ // (project-gutter.css).
10
+ import { observer } from 'mobx-react-lite'
11
+ import { useEffect, useLayoutEffect, useRef } from 'react'
12
+ import type { HubSt } from './hub-st.ts'
13
+ import { cardModel, faceOf, hueOf, type Project } from './projects-svc.ts'
14
+
15
+ export const Tile = observer(({ app, p, i }: { app: HubSt; p: Project; i: number }) => {
16
+ const s = app.projects
17
+ const gap = s.shownGap
18
+ const face = faceOf(p)
19
+ // the drop line rides on a tile edge, so showing it moves nothing
20
+ const edge = gap === i ? ' dropb' : gap === i + 1 && i === s.list.length - 1 ? ' dropa' : ''
21
+ const cls = `pgt${p.current ? ' cur' : ''}${face.dim ? ' dim' : ''}${s.drag === p.name ? ' dragging' : ''}${edge}`
22
+ return (
23
+ <button className={cls} aria-label={p.name}
24
+ draggable onClick={() => void s.open(p.name)}
25
+ onMouseEnter={(e) => {
26
+ const r = e.currentTarget.getBoundingClientRect()
27
+ s.hoverIn(p.name, r.top + r.height / 2)
28
+ }}
29
+ onMouseLeave={() => s.hoverOut(p.name)}
30
+ onDragStart={(e) => {
31
+ e.dataTransfer.effectAllowed = 'move'
32
+ e.dataTransfer.setData('text/plain', p.name)
33
+ s.dragStart(p.name)
34
+ }}
35
+ onDragOver={(e) => {
36
+ if (s.drag == null) return
37
+ e.preventDefault()
38
+ const r = e.currentTarget.getBoundingClientRect()
39
+ s.dragOver(i, e.clientY - r.top, r.height)
40
+ }}
41
+ onDrop={(e) => {
42
+ e.preventDefault()
43
+ s.drop()
44
+ }}
45
+ onDragEnd={() => s.dragEnd()}>
46
+ <Face p={p} />
47
+ {face.badge != null ? <b className="pga">{face.badge}</b> : null}
48
+ {face.busy ? <i className="pgs" /> : null}
49
+ </button>
50
+ )
51
+ })
52
+
53
+ const Face = ({ p }: { p: Project }) => (p.icon != null
54
+ ? <img src={p.icon} alt="" draggable={false} />
55
+ : <span className={`pgi h${hueOf(p.name)}`}>{(p.name.match(/[a-zA-Z]/)?.[0] ?? p.name.charAt(0)).toUpperCase()}</span>)
56
+
57
+ /** the card for one repo: header, what needs a human, then the fact rows,
58
+ * in the label and value shape the check page's WHAT widget uses */
59
+ export const Card = ({ p, now }: { p: Project; now: number }) => {
60
+ const m = cardModel(p, now)
61
+ return (
62
+ <>
63
+ <div className="pgch">
64
+ <span className="pgcface"><Face p={p} /></span>
65
+ <b className="pgcname">{m.head.name}{m.head.current ? <span className="pgcthis"> this hub</span> : null}</b>
66
+ {m.head.version == null ? null : <span className="pgcver">v{m.head.version.replace(/^v/, '')}</span>}
67
+ </div>
68
+ {m.head.branch == null ? null : <div className="pgcline">{m.head.branch}</div>}
69
+ {m.head.description == null ? null : <div className="pgcdesc">{m.head.description}</div>}
70
+ {m.needs == null ? null : (
71
+ <div className="pgcneeds">
72
+ {m.needs.asks == null ? null : (
73
+ <div className="pgcneed">
74
+ <span className="pgcask">{m.needs.asks.count} ask{m.needs.asks.count === 1 ? '' : 's'}: {m.needs.asks.titles.join(' · ')}</span>
75
+ {m.needs.asks.more > 0 ? <span className="pgcmore">+{m.needs.asks.more}</span> : null}
76
+ </div>
77
+ )}
78
+ {m.needs.gate == null ? null : <div className="pgcneed red">{m.needs.gate}</div>}
79
+ </div>
80
+ )}
81
+ {m.facts.length === 0 ? null : (
82
+ <div className="pgcfacts">
83
+ {m.facts.map((f) => (
84
+ <div key={f.k} className="wfact">
85
+ <span className="wk">{f.k}</span>
86
+ <span className={`wv${f.tone == null ? '' : ` ${f.tone}`}`}>{f.v}</span>
87
+ </div>
88
+ ))}
89
+ </div>
90
+ )}
91
+ </>
92
+ )
93
+ }
94
+
95
+ /** the one hover card: right of the rail, centred on the icon's row, then
96
+ * clamped into the viewport once its own height is known */
97
+ export const HoverCard = observer(({ app }: { app: HubSt }) => {
98
+ const s = app.projects
99
+ const p = s.hovered
100
+ const y = s.hover?.y ?? 0
101
+ const ref = useRef<HTMLDivElement>(null)
102
+ useLayoutEffect(() => {
103
+ const el = ref.current
104
+ if (el == null) return
105
+ const h = el.offsetHeight
106
+ el.style.top = `${Math.max(8, Math.min(y - h / 2, window.innerHeight - h - 8))}px`
107
+ })
108
+ if (p == null) return null
109
+ return (
110
+ <div ref={ref} className="pgcard" role="tooltip">
111
+ <Card p={p} now={Date.now()} />
112
+ </div>
113
+ )
114
+ })
115
+
116
+ export const ProjectGutter = observer(({ app }: { app: HubSt }) => {
117
+ useEffect(() => app.projects.start(), [app])
118
+ const s = app.projects
119
+ if (s.list.length === 0 && s.error === '') return null
120
+ return (
121
+ <nav className="pgut" aria-label="projects">
122
+ <button className="pgsort" title="sort by last activity, once: the result is the new manual order" onClick={() => s.sortByActivity()}>⇅</button>
123
+ {s.list.map((p, i) => <Tile key={p.name} app={app} p={p} i={i} />)}
124
+ {s.error !== '' ? <span className="pgerr" title={s.error}>!</span> : null}
125
+ <HoverCard app={app} />
126
+ </nav>
127
+ )
128
+ })