rvlib-shipkit 1.3.0 → 1.5.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 (203) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +12 -2
  7. package/src/check.ts +73 -98
  8. package/src/checks/SK001-package-json-shape.check.ts +26 -4
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  10. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  11. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  12. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  13. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  14. package/src/checks/SK007-github-visibility.check.ts +4 -2
  15. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  16. package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
  17. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  18. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  19. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  20. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  21. package/src/checks/SK014-prose-ste.check.ts +20 -6
  22. package/src/checks/SK015-nested-repo.check.ts +4 -2
  23. package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
  24. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  25. package/src/checks/SK018-test-files-run.check.ts +6 -2
  26. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  27. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  28. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  29. package/src/checks/SK024-identity-colors.check.ts +5 -3
  30. package/src/checks/SK025-release-drift.check.ts +4 -5
  31. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  32. package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
  33. package/src/checks/SK028-readme.check.ts +10 -2
  34. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  35. package/src/cli/cmd_Asks.ts +5 -5
  36. package/src/cli/cmd_Assets.ts +7 -7
  37. package/src/cli/cmd_Bump.ts +12 -11
  38. package/src/cli/cmd_Check.ts +8 -7
  39. package/src/cli/cmd_Checks.ts +7 -6
  40. package/src/cli/cmd_Ci.ts +12 -11
  41. package/src/cli/cmd_Commands.ts +16 -13
  42. package/src/cli/cmd_ConfigKeys.ts +7 -6
  43. package/src/cli/cmd_Contracts.ts +3 -3
  44. package/src/cli/cmd_CrossRepoActivity.ts +10 -6
  45. package/src/cli/cmd_Fleet.ts +9 -7
  46. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  47. package/src/cli/cmd_Home.ts +23 -17
  48. package/src/cli/cmd_Hooks.ts +6 -5
  49. package/src/cli/cmd_Init.ts +5 -4
  50. package/src/cli/cmd_Keys.ts +8 -7
  51. package/src/cli/cmd_Mv.ts +7 -6
  52. package/src/cli/cmd_Needs.ts +4 -3
  53. package/src/cli/cmd_Overview.ts +4 -3
  54. package/src/cli/cmd_Pb.ts +2 -2
  55. package/src/cli/cmd_Planned.ts +6 -5
  56. package/src/cli/cmd_Prose.ts +5 -5
  57. package/src/cli/cmd_Reflect.ts +7 -7
  58. package/src/cli/cmd_Release.ts +6 -5
  59. package/src/cli/cmd_Releases.ts +5 -4
  60. package/src/cli/cmd_Serve.ts +4 -3
  61. package/src/cli/cmd_Smoke.ts +5 -4
  62. package/src/cli/cmd_Start.ts +7 -6
  63. package/src/cli/cmd_Status.ts +4 -3
  64. package/src/cli/cmd_Tui.ts +3 -2
  65. package/src/cli/cmd_When.ts +71 -0
  66. package/src/cli/cmd_Wiki.ts +4 -4
  67. package/src/cli.ts +7 -2
  68. package/src/collections/checks.ts +2 -0
  69. package/src/collections/collection-types.ts +24 -4
  70. package/src/collections/providers.ts +6 -2
  71. package/src/contracts/apply-contracts.ts +9 -8
  72. package/src/contracts/contract-types.ts +8 -2
  73. package/src/contracts/contracts-engine.ts +34 -13
  74. package/src/contracts/manifests/identity.contract.ts +6 -5
  75. package/src/contracts/manifests/social.contract.ts +4 -2
  76. package/src/contracts/manifests/steam.contract.ts +7 -3
  77. package/src/contracts/web-surface.ts +59 -0
  78. package/src/core/budget.ts +19 -9
  79. package/src/core/canonical-scripts.ts +1 -1
  80. package/src/core/check-runner.ts +63 -25
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +31 -7
  83. package/src/core/layout.ts +2 -0
  84. package/src/core/legacy-shapes.ts +39 -10
  85. package/src/core/motherhome.ts +88 -66
  86. package/src/core/one-way-in-policy.ts +2 -2
  87. package/src/core/outcome-words.ts +18 -2
  88. package/src/core/port-check-cli.ts +52 -0
  89. package/src/core/port-check-file.ts +295 -0
  90. package/src/core/port-check-files.ts +31 -0
  91. package/src/core/retention.ts +0 -14
  92. package/src/core/run-check.ts +14 -14
  93. package/src/core/run-ci.ts +11 -7
  94. package/src/core/run-fix.ts +30 -23
  95. package/src/core/run-fixers.ts +42 -5
  96. package/src/core/runs-matrix.ts +179 -0
  97. package/src/core/runs.ts +242 -0
  98. package/src/core/update-policy.ts +20 -0
  99. package/src/core/when-block.ts +29 -30
  100. package/src/core/when.ts +41 -103
  101. package/src/core/wire.ts +34 -9
  102. package/src/fleet/scan-fleet.ts +20 -4
  103. package/src/fleet/update-fleet.ts +6 -6
  104. package/src/home/activity-shape.ts +4 -1
  105. package/src/home/activity.ts +22 -15
  106. package/src/home/gui-app/ShipkitGui.swift +144 -25
  107. package/src/home/gui-app.ts +93 -30
  108. package/src/home/home-launchd.ts +30 -6
  109. package/src/home/home-routes.ts +14 -2
  110. package/src/home/home-start.ts +33 -5
  111. package/src/home/hubs.ts +5 -5
  112. package/src/home/machine-stores.ts +4 -2
  113. package/src/home/open-app.ts +23 -10
  114. package/src/home/pm-config.ts +15 -11
  115. package/src/home/pm-inputs.ts +76 -0
  116. package/src/home/pm.ts +11 -3
  117. package/src/home/process-rows.ts +23 -6
  118. package/src/home/resources.ts +67 -15
  119. package/src/home/run-daemon.ts +6 -1
  120. package/src/home/run-pm.ts +30 -5
  121. package/src/home/serve-web.ts +2 -2
  122. package/src/home/supervise.ts +24 -1
  123. package/src/home/widgets.ts +32 -3
  124. package/src/hooks/commit-message.ts +2 -3
  125. package/src/hooks/run-hooks.ts +60 -6
  126. package/src/init-repo.ts +6 -6
  127. package/src/ledger/gate-checks.ts +115 -61
  128. package/src/ledger/git-key.ts +5 -1
  129. package/src/ledger/ledger.ts +5 -5
  130. package/src/ledger/store.ts +11 -0
  131. package/src/ledger/toolchain-identity.ts +45 -27
  132. package/src/ledger/world-cache.ts +17 -15
  133. package/src/plan/repo-source.ts +18 -39
  134. package/src/release/run-release.ts +5 -0
  135. package/src/repo-config.ts +36 -56
  136. package/src/rules/rule.ts +2 -2
  137. package/src/scaffold/files/agent-coding.md +1 -1
  138. package/src/scaffold/files/agent-index.md +1 -1
  139. package/src/scaffold/files/verify-skill.md +2 -2
  140. package/src/serve/hub-app/activity-st.ts +5 -4
  141. package/src/serve/hub-app/activity-view.tsx +4 -4
  142. package/src/serve/hub-app/admin-view.tsx +13 -11
  143. package/src/serve/hub-app/check-view.tsx +66 -46
  144. package/src/serve/hub-app/check-words.ts +1 -11
  145. package/src/serve/hub-app/checks-svc.ts +16 -36
  146. package/src/serve/hub-app/col-tables-view.tsx +131 -0
  147. package/src/serve/hub-app/col-tables.ts +115 -0
  148. package/src/serve/hub-app/cols-svc.ts +33 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +7 -1
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +15 -3
  160. package/src/serve/hub-app/home-strip.css +69 -19
  161. package/src/serve/hub-app/home-strip.tsx +192 -118
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +21 -5
  164. package/src/serve/hub-app/hub-types.ts +11 -5
  165. package/src/serve/hub-app/hub.css +88 -44
  166. package/src/serve/hub-app/icons.tsx +15 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +11 -8
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +34 -49
  171. package/src/serve/hub-app/palette-st.ts +5 -5
  172. package/src/serve/hub-app/palette-view.tsx +1 -1
  173. package/src/serve/hub-app/plan-view.tsx +51 -0
  174. package/src/serve/hub-app/planned-st.ts +9 -4
  175. package/src/serve/hub-app/processes-view.tsx +38 -0
  176. package/src/serve/hub-app/project-list.tsx +3 -3
  177. package/src/serve/hub-app/projects-svc.ts +15 -10
  178. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  179. package/src/serve/hub-app/reach.ts +36 -0
  180. package/src/serve/hub-app/readme-svc.ts +3 -2
  181. package/src/serve/hub-app/repo-sections.ts +117 -0
  182. package/src/serve/hub-app/repo-st.ts +47 -10
  183. package/src/serve/hub-app/runs-tabs.ts +1 -1
  184. package/src/serve/hub-app/sidebar.tsx +12 -37
  185. package/src/serve/hub-app/timeline-svc.ts +3 -2
  186. package/src/serve/hub-app/timeline-view.tsx +14 -11
  187. package/src/serve/hub-app/tree-build.ts +149 -155
  188. package/src/serve/hub-app/tree-row.tsx +39 -9
  189. package/src/serve/hub-app/tree-svc.ts +70 -49
  190. package/src/serve/hub-app/when-view.tsx +89 -0
  191. package/src/serve/hub-app/when.css +17 -0
  192. package/src/serve/hub-runtime.ts +3 -16
  193. package/src/serve/project-gutter.ts +1 -2
  194. package/src/serve/run-serve.ts +1 -1
  195. package/src/serve/serve-hub.ts +4 -14
  196. package/src/serve/warm-cell.ts +49 -16
  197. package/src/suite/judge-suites.ts +42 -17
  198. package/src/suite/suite-cli.ts +5 -8
  199. package/src/wiki/lint-md.ts +2 -0
  200. package/src/wiki/scan-md.ts +37 -10
  201. package/src/serve/hub-app/processes-svc.ts +0 -63
  202. package/src/serve/hub-app/repo-sections.tsx +0 -61
  203. package/src/serve/repo-processes.ts +0 -89
@@ -26,16 +26,12 @@
26
26
  // cache slower than the gate it caches. No tool produces that state; a human
27
27
  // debugging on purpose can delete the ledger.
28
28
  import { createHash } from 'crypto'
29
- import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'fs'
29
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'fs'
30
30
  import { isAbsolute, join, relative, resolve } from 'path'
31
31
 
32
32
  export type Toolchain = {
33
33
  /** the identity, 16 hex chars, stable for identical programs */
34
34
  id: string
35
- /** non-empty means DO NOT CACHE this run: something in the toolchain is
36
- * neither in the repo's git tree nor pinned by a version string, so no
37
- * key can describe it. Each entry says which package and why. */
38
- uncacheable: string[]
39
35
  }
40
36
 
41
37
  /** the gate binary driving THIS process, which is what the key hashes when a
@@ -62,25 +58,40 @@ function installedNames(nm: string): string[] {
62
58
  return names.sort()
63
59
  }
64
60
 
65
- /** the gate binary's own source, content-hashed. This is what makes editing
66
- * shipkit invalidate every key in every repo, in-repo copy or dependency. */
67
- function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
68
- const srcRoot = resolve(cli, '..')
69
- const files: string[] = []
70
- const walk = (d: string): void => {
71
- for (const e of readdirSync(d, { withFileTypes: true })) {
72
- if (e.name === 'node_modules' || e.name.startsWith('.')) continue
73
- const p = join(d, e.name)
74
- if (e.isDirectory()) walk(p)
75
- else files.push(p)
61
+ /** every file under `dir`, hashed as its path, its size and its bytes in
62
+ * path order, so a rename or a line moved between files moves the hash too.
63
+ * Its own installs and dot entries are left out. A symlink is followed (a
64
+ * cycle is walked once), and one that cannot be read THROWS: a tree only
65
+ * partly read must never be keyed as if it were whole */
66
+ function hashTree(h: ReturnType<typeof createHash>, dir: string, tag: string): void {
67
+ const files: { rel: string; abs: string }[] = []
68
+ const seen = new Set<string>()
69
+ const walk = (d: string, rel: string): void => {
70
+ const real = realpathSync(d)
71
+ if (seen.has(real)) return
72
+ seen.add(real)
73
+ for (const name of readdirSync(d)) {
74
+ if (name === 'node_modules' || name.startsWith('.')) continue
75
+ const abs = join(d, name)
76
+ const r = rel === '' ? name : `${rel}/${name}`
77
+ if (statSync(abs).isDirectory()) walk(abs, r)
78
+ else files.push({ rel: r, abs })
76
79
  }
77
80
  }
78
- walk(srcRoot)
79
- files.sort()
80
- for (const f of files) h.update(readFileSync(f))
81
- h.update(`clifiles=${files.length}\n`)
81
+ walk(dir, '')
82
+ files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))
83
+ for (const f of files) {
84
+ const bytes = readFileSync(f.abs)
85
+ h.update(`${f.rel}\0${bytes.length}\0`)
86
+ h.update(bytes)
87
+ }
88
+ h.update(`${tag}=${files.length}\n`)
82
89
  }
83
90
 
91
+ /** the gate binary's own source, content-hashed. This is what makes editing
92
+ * shipkit invalidate every key in every repo, in-repo copy or dependency. */
93
+ const hashCliTree = (h: ReturnType<typeof createHash>, cli: string): void => hashTree(h, resolve(cli, '..'), 'clifiles')
94
+
84
95
  /**
85
96
  * what ONE install holds, as a hash: every top-level package's name, version
86
97
  * and link target.
@@ -119,7 +130,6 @@ export function installIdentity(nodeModules: string): string {
119
130
  */
120
131
  export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain {
121
132
  const h = createHash('sha256')
122
- const uncacheable: string[] = []
123
133
  // BOTH sides resolved, or the comparison is wrong on macOS: /var is a
124
134
  // symlink to /private/var, so an in-repo cli under a resolved path reads
125
135
  // as outside an unresolved root. That mistake is silent and expensive: it
@@ -151,27 +161,35 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
151
161
  if (!existsSync(nm)) {
152
162
  // a worktree nobody installed is a REAL difference, not a missing input
153
163
  h.update('nm=absent\n')
154
- return { id: h.digest('hex').slice(0, 16), uncacheable }
164
+ return { id: h.digest('hex').slice(0, 16) }
155
165
  }
156
166
  const names = installedNames(nm)
157
167
  for (const n of names) {
158
168
  const dir = join(nm, n)
159
169
  let link = ''
170
+ let outside: string | null = null
160
171
  try {
161
172
  if (lstatSync(dir).isSymbolicLink()) {
162
173
  const target = realpathSync(dir)
163
174
  link = `->${target}`
164
- // a `bun link`ed package is live source under someone else's
165
- // edit: its content is in neither this repo's git tree nor its
166
- // version string, so nothing here can describe it
167
- if (!inRepo(root, target)) uncacheable.push(`${n} links outside the repo (${target})`)
175
+ if (!inRepo(root, target)) outside = target
168
176
  }
169
177
  } catch {}
178
+ // a `bun link`ed package is live source under someone else's edit:
179
+ // neither this repo's git tree nor its version string describes it, so
180
+ // its files do, and so does what it has installed itself, since its
181
+ // own imports resolve there. Outside the try on purpose: a linked tree
182
+ // that cannot be read throws, and the key layer then keys nothing.
183
+ // ~20ms for the whole rvlib-shipkit tree
184
+ if (outside != null) {
185
+ hashTree(h, outside, `linked:${n}`)
186
+ h.update(`${installIdentity(join(outside, 'node_modules'))}\n`)
187
+ }
170
188
  let version = 'MISSING'
171
189
  try { version = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch {}
172
190
  h.update(`${n}@${version}${link}\n`)
173
191
  }
174
192
  // the count catches a removal that leaves every survivor identical
175
193
  h.update(`n=${names.length}\n`)
176
- return { id: h.digest('hex').slice(0, 16), uncacheable }
194
+ return { id: h.digest('hex').slice(0, 16) }
177
195
  }
@@ -31,9 +31,8 @@
31
31
  // A measured negative may be kept only for a check about the WORLD. One about
32
32
  // THIS REPO is fixed by a human and the next run goes green, so replaying its
33
33
  // red would be the cache lying about work already done.
34
- import { FOREVER_MS, fmtStamp, type Line } from '../core/wire.ts'
34
+ import { FOREVER_MS, type Line, type Stamp } from '../core/wire.ts'
35
35
  import { FRESH_ENV, withAnswers, type Answers, type Stored } from './store.ts'
36
- import { CACHED, RAN } from '../core/outcome-words.ts'
37
36
 
38
37
  /** the longest a MEASURED NEGATIVE is remembered, whatever TTL was declared.
39
38
  * Long enough to stop a re-ask storm inside one working session, short
@@ -80,11 +79,21 @@ function adoptTimed(a: Answers, ns: string, name: string, key: string): Stored<L
80
79
  return head != null && e.key.slice(head[0].length) === rest && Array.isArray(e.payload) && e.payload.length > 0 && e.payload.every((l) => l.kind === 'ok' || l.kind === 'note')
81
80
  })
82
81
  if (old == null) return null
83
- const payload = old.payload.map((l, i): Line => (i !== 0 ? l : { ...l, text: l.text.replace(new RegExp(`\\((${CACHED} at [^)]*?) until [^)]*\\)$`), '($1 until its declaration changes)') }))
82
+ const payload = unbake(old.payload)
84
83
  a.write([{ ns, name, key, payload, at: old.at, keeps: FOREVER_MS }], { now: old.at })
85
84
  return { ...old, key, keeps: FOREVER_MS, payload }
86
85
  }
87
86
 
87
+ /** the times an older build wrote into the first line's text, as `(cached at
88
+ * <stamp> until <stamp>)` or `(ran at <stamp>, kept until <stamp>)`: the
89
+ * entry holds them as data, so the text drops them */
90
+ const BAKED = / \((?:cached|ran) at \d{4}-\d\d-\d\d \d\d:\d\d:\d\d,? (?:kept )?until [^)]*\)$/
91
+ const unbake = (lines: Line[]): Line[] => lines.map((l, i) => (i === 0 ? { ...l, text: l.text.replace(BAKED, '') } : l))
92
+
93
+ /** the first line, stamped with when its answer was measured and until when it keeps */
94
+ const stamped = (lines: Line[], stamp: Stamp): Line[] => lines.map((l, i) => (i === 0 ? { ...l, stamp } : l))
95
+ const untilOf = (at: number, keeps: number): number | null => (keeps >= FOREVER_MS ? null : at + keeps)
96
+
88
97
  /** the answer for this key, or the one a forever key adopts */
89
98
  const readOrAdopt = (a: Answers, ns: string, name: string, key: string): Stored<Line[]> | null => a.read<Line[]>(ns, name, key) ?? adoptTimed(a, ns, name, key)
90
99
 
@@ -104,11 +113,9 @@ export function replayWorld(c: WorldCache, name: string, key: string, ttlMs: num
104
113
  if (entry == null || !Array.isArray(entry.payload)) return null
105
114
  const keeps = entry.keeps ?? ttlMs
106
115
  if (now - entry.at >= keeps || now < entry.at) return null
107
- // the stamp was baked in when the answer was MEASURED (below): a replay
108
- // prints the stored lines VERBATIM, so the live run and every later replay
109
- // render the same string. Annotating replays but not live runs diffed on
110
- // every fresh-vs-cached alternation, so neither side annotates.
111
- return entry.payload
116
+ // the times ride as data on the first line, rendered by whoever prints it
117
+ // against its own clock, so a line read a day later says how old it is
118
+ return stamped(unbake(entry.payload), { at: entry.at, until: untilOf(entry.at, keeps), cached: true })
112
119
  }
113
120
 
114
121
  export type Measured = {
@@ -150,11 +157,6 @@ export function rememberWorld(c: WorldCache, name: string, key: string, ttlMs: n
150
157
  return m.lines
151
158
  }
152
159
  const now = c.now()
153
- const lines = m.lines.map((l, i): Line =>
154
- i !== 0 ? l
155
- : keepsFor >= FOREVER_MS ? { ...l, text: `${l.text} (${CACHED} at ${fmtStamp(now)} until its declaration changes)` }
156
- : { ...l, text: `${l.text} (${m.clean ? `${CACHED} at ${fmtStamp(now)} until` : `${RAN} at ${fmtStamp(now)}, kept until`} ${fmtStamp(now + keepsFor)})` },
157
- )
158
- withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: lines, at: now, keeps: keepsFor }], { now }))
159
- return lines
160
+ withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: m.lines, at: now, keeps: keepsFor }], { now }))
161
+ return stamped(m.lines, { at: now, until: untilOf(now, keepsFor) })
160
162
  }
@@ -12,6 +12,7 @@ import { Ledger } from '../ledger/ledger.ts'
12
12
  import { answersDb } from '../ledger/store.ts'
13
13
  import { hasWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
14
14
  import { FOREVER, FOREVER_MS } from '../core/wire.ts'
15
+ import { planned } from '../core/runs.ts'
15
16
  import { ONCE, type LastResult } from './cadence.ts'
16
17
  import { openDb } from '../ledger/db.ts'
17
18
  import { readLastResults } from '../ledger/run-log.ts'
@@ -21,57 +22,33 @@ import { movedSince } from '../serve/tracked-watch.ts'
21
22
  import type { RepoConfig } from '../repo-config.ts'
22
23
  import type { PlanCheck, PlanReason, PlanSource } from './planner.ts'
23
24
 
24
- /** the rows the plan covers: what a gate or a health pass runs. A release
25
- * check, an off one and an opt in build step wait for whoever names them */
25
+ /** the rows the plan covers: every live row declaring `onSave` or
26
+ * `onClock` (core/runs.ts). Nothing else is ever planned */
26
27
  export function plannable(row: CheckRow): boolean {
27
- if (row.when.at !== 'always' && row.when.at !== 'health') return false
28
- // nothing to do in this repo on a clock (check.ts planOff)
29
- if (row.planOff != null) return false
30
- if (row.optIn) return false
31
- return !(row.build && row.when.at === 'health')
28
+ return planned(row.runs)
32
29
  }
33
30
 
34
- /** a REPO check nothing can key and that declares no cadence runs about
35
- * once a day, never in step, until it declares what it depends on. No
36
- * built-in may land here (tests/plan-default-range.test.ts) */
37
- const DEFAULT_EVERY: [string, string] = ['20h', '28h']
38
-
39
31
  /** why the plan cannot tell when this check's answer moves, or null when it
40
- * can: a key, a ttl, a build step or a declared cadence says it */
32
+ * can. With every axis explicit, a planned row always says: kept as the
33
+ * burn-down reader for rows declaring a save trigger on nothing keyed */
41
34
  export function planGap(row: CheckRow): string | null {
35
+ if (row.runs.onSave !== 'moved') return null
42
36
  const pop = populationOf(row.decl)
43
- if (pop === 'content' || pop === 'reads' || pop === 'world' || row.build) return null
44
- if (row.plan?.every !== undefined || row.plan?.cron != null) return null
45
- const why = row.decl.never != null && row.decl.never !== '' ? `never cached (${row.decl.never})` : (row.decl.uncacheable ?? 'declares neither inputs nor ttl')
46
- return `${why}, and no every: nothing says when its answer moves`
37
+ if (pop === 'content' || pop === 'reads' || isForever(row)) return null
38
+ return `onSave 'moved' but nothing keys it (${row.decl.uncacheable ?? row.decl.declError ?? 'no key'}), so every save moves it`
47
39
  }
48
40
 
49
- /** one inventory row as a plan check. A world check keeps its ttl as its
50
- * cadence; a build step and a check keyed on its files wait for a save (its
51
- * answer replays until a file moves, so a clock re-asks nothing); a repo
52
- * check with no cadence runs at its own stable point of the default range,
53
- * and a built-in with none waits for a save and shows on the burn-down */
41
+ /** one inventory row as a plan check, straight from its declared axes:
42
+ * `onClock` is the cadence, `onSave` whether a save can make it due */
54
43
  export function planCheckOf(row: CheckRow): PlanCheck {
55
- const pop = populationOf(row.decl)
56
- const keyed = pop === 'content' || pop === 'reads'
57
- const ttl = row.decl.ttl != null && row.decl.ttl !== '' ? row.decl.ttl : null
58
- const fallback =
59
- ttl === FOREVER ? { value: ONCE, why: 'a decided fact: ttl forever' }
60
- : ttl != null ? { value: ttl, why: 'its ttl' }
61
- : row.build ? { value: null, why: 'a build step' }
62
- : keyed ? { value: null, why: 'keyed on its files' }
63
- : row.from === 'repo' ? { value: DEFAULT_EVERY, why: 'the default range' }
64
- : { value: null, why: 'a built-in declaring nothing, see shipkit keys --burn-down' }
65
- // a gate check nothing can key waits for a save only when no cadence
66
- // says when to run it: a range or an every it declares is the rule
67
- const clocked = fallback.value != null || row.plan?.every !== undefined || row.plan?.cron != null
44
+ const clock = row.runs.onClock
45
+ const plan = clock === false ? {} : clock === ONCE ? { every: ONCE } : { every: clock.every, whenInactive: clock.inactive }
68
46
  return {
69
47
  id: row.decl.id,
70
48
  title: row.title,
71
- plan: row.plan ?? {},
72
- // a decided fact is keyed on its declaration, so a save can move it
73
- onChange: isForever(row) || (pop !== 'world' && (keyed || (row.gate && !clocked))),
74
- fallback,
49
+ plan,
50
+ onChange: row.runs.onSave !== false,
51
+ fallback: { value: null, why: 'onClock false: only a save runs it' },
75
52
  }
76
53
  }
77
54
 
@@ -182,6 +159,8 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
182
159
  moved.delete(c.id)
183
160
  continue
184
161
  }
162
+ // 'burst': every quiet save burst, whatever moved
163
+ if (r.runs.onSave === 'burst') { out.push(c.id); continue }
185
164
  const k = ledger.layer.keyFor(c.id)
186
165
  if (k.key == null) {
187
166
  out.push(c.id)
@@ -112,6 +112,11 @@ export type ReleaseOpts = {
112
112
  export async function runReleaseGate(root: string, config: RepoConfig, print: (l: string) => void, o: { fix?: boolean; units?: ReleaseUnit[] } = {}): Promise<number> {
113
113
  return await runCi(root, {
114
114
  fix: o.fix ?? false,
115
+ // a check's repair never runs on the release path (runs.onRelease):
116
+ // only the mechanical slots (the formatter) may, when bump asks
117
+ fixRules: false,
118
+ // the rows declaring onRelease, whatever the tier
119
+ release: true,
115
120
  frozen: true,
116
121
  status: false,
117
122
  config,
@@ -59,29 +59,11 @@ export type ServeConfig = {
59
59
  * prints beside localhost and rides on every ping message, because a
60
60
  * phone cannot resolve localhost */
61
61
  publicHost?: string
62
- /** the long running processes this repo needs (a dev server, a watcher).
63
- * The hub supervises them: a switch each on its page, a crash restarts,
64
- * and they outlive a hub restart */
65
- processes?: ProcessDecl[]
66
62
  /** the ids of the global processes this repo needs (any house repo's
67
63
  * `globalProcess` unit files). The home rail marks the repo while one is off */
68
64
  uses?: string[]
69
65
  }
70
66
 
71
- /** one long running process a repo needs, supervised by its hub */
72
- export type ProcessDecl = {
73
- /** kebab-case, unique in this repo */
74
- id: string
75
- /** the command as a list of words, run from the repo root */
76
- cmd: string[]
77
- /** where it runs, repo-relative (default the repo root) */
78
- cwd?: string
79
- /** the url it serves, probed for up and shown as a link */
80
- url?: string
81
- /** starts with the hub. Default false: it starts from its switch */
82
- auto?: boolean
83
- }
84
-
85
67
  export type RepoConfig = {
86
68
  /** the repo name, as every surface prints it: the gate header, the hub
87
69
  * title, the motherhome row, the default hub port hash. The pre-commit
@@ -146,12 +128,17 @@ export type RepoConfig = {
146
128
  /** notify command for new RED needs while the hub serves, `{message}` substituted, as in a push-notification CLI */
147
129
  notifyCommand?: string
148
130
  /** the `.shipkit/<venue>/` release contracts: which ones this repo is excused
149
- * from, and any manifest the published toolkit does not carry. */
131
+ * from, any manifest the published toolkit does not carry, and the dir
132
+ * its site serves when the deploy does not say. */
150
133
  contracts?: {
151
134
  /** venue id to the reason it is waived. The only bypass, and it always prints */
152
135
  exempt?: Record<string, string>
153
136
  /** manifests declared by this repo, beside the built-in venues */
154
137
  extra?: ContractManifest[]
138
+ /** the dir the site serves, repo-relative, where the identity contract
139
+ * wants favicon.png, og.png and logo.*. Absent: the dir the deploy names
140
+ * (a Pages workflow's artifact path, a wrangler output dir), else `public` */
141
+ webRoot?: string
155
142
  }
156
143
  /** repo-contributed .gitignore lines, rendered INSIDE rule SK004's managed
157
144
  * block — for ecosystem-specific names the published lib may not carry */
@@ -166,21 +153,25 @@ export type RepoConfig = {
166
153
  * folder only: it never commits, untracks, tags or pushes on the parent's
167
154
  * history. Rule SK015 fails when this and the disk disagree (core/nesting.ts) */
168
155
  nested?: boolean
169
- /** WHERE EACH CHECK RUNS, keyed by check id. One declaration for a
170
- * question that used to have three: `'always'` every gate and every
171
- * health pass, `'release'` only at `--full` and on the release path,
172
- * `'health'` only in the health pass, `'off:<reason>'` not at all.
173
- * `defineRepo` throws on a value it cannot parse, on an `off` with no
174
- * reason, and on an id no check carries. The hub writes this
175
- * map through the managed block in this file (core/when-block.ts). */
156
+ /** THE CHECKS THIS REPO TURNS OFF, check id → why. The one thing a repo
157
+ * says about a check it does not own: where a check runs is its own
158
+ * declaration (build/shipkit/runs.md), and an off check runs in no
159
+ * context. `defineRepo` throws on a blank reason and on a rule id no
160
+ * check carries. The hub writes this map through the managed block in
161
+ * this file (core/when-block.ts). */
162
+ off?: Record<string, string>
163
+ /** RETIRED, read only while SK029 ports it into `off`: an `'off:<why>'`
164
+ * value reads as `off`, and any other word is refused, because a repo
165
+ * no longer places a check. Dies once `shipkit fleet` shows no repo
166
+ * carrying it */
176
167
  when?: Record<string, string>
177
168
  /** WHAT A HEALTHY RUN OF EACH CHECK TAKES, in ms, keyed by check id. A
178
169
  * check that passes over its budget prints a WARN and reads yellow, and
179
170
  * never blocks. It wins over the check's own `budgetMs` and the default
180
171
  * for its kind (core/budget.ts). An id no check carries fails the run,
181
- * the same as a `when` entry. */
172
+ * the same as an `off` entry. */
182
173
  budgets?: Record<string, number>
183
- /** DEPRECATED, read into `when` as `off:<reason>`: rule id → the reason
174
+ /** RETIRED, read as `off` while SK029 ports it there: rule id → the reason
184
175
  * this repo breaks that invariant on purpose. An exempted rule does not
185
176
  * lint and prints a skip line carrying the reason. `defineRepo` throws on
186
177
  * an unknown id or a blank reason */
@@ -210,7 +201,7 @@ export type RepoConfig = {
210
201
  /** widens the read-only tool allowlist SK008 enforces (default Read, Grep, Glob, Bash) */
211
202
  tools?: string[]
212
203
  }
213
- /** ONE WAY IN escape hatch (build/shipkit.md): governed script names this
204
+ /** ONE WAY IN escape hatch (build/shipkit/one-way-in.md): governed script names this
214
205
  * repo keeps as a direct tool call. Declared per NAME, never as a list of
215
206
  * banned binaries: the invariant is about which VERBS an agent reaches
216
207
  * for, and a binary blocklist goes stale the day a repo changes runner.
@@ -295,18 +286,7 @@ export type RepoConfig = {
295
286
  logDir?: string
296
287
  }
297
288
 
298
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
299
-
300
- const KEBAB = /^[a-z0-9][a-z0-9-]*$/
301
- const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
302
-
303
- /** why a process declaration cannot run, or null */
304
- export function processProblem(d: ProcessDecl): string | null {
305
- if (!KEBAB.test(d.id ?? '')) return `serve.processes: id '${String(d.id)}' must be kebab-case`
306
- if (!words(d.cmd)) return `serve.processes['${d.id}'] needs cmd: a list of words`
307
- if (d.url != null && !/^https?:\/\//.test(d.url)) return `serve.processes['${d.id}'].url must be an http url`
308
- return null
309
- }
289
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
310
290
 
311
291
  export function defineRepo(c: RepoConfig): RepoConfig {
312
292
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -316,13 +296,8 @@ export function defineRepo(c: RepoConfig): RepoConfig {
316
296
  // a half-declared reviewer would lint a path like `.claude/agents/.md`
317
297
  if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
318
298
  throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
319
- const pids = new Set<string>()
320
- for (const d of c.serve?.processes ?? []) {
321
- const why = processProblem(d)
322
- if (why != null) throw new Error(`defineRepo: ${why}`)
323
- if (pids.has(d.id)) throw new Error(`defineRepo: serve.processes declares '${d.id}' twice`)
324
- pids.add(d.id)
325
- }
299
+ if (c.serve != null && 'processes' in c.serve)
300
+ throw new Error(`defineRepo: serve.processes was removed; declare each process in a <name>.shipkit.ts file as \`export default globalProcess({...})\``)
326
301
  // unknown keys die loud — a typo'd or removed key silently changing the
327
302
  // gate is exactly the never-silence failure mode (A23)
328
303
  for (const k of Object.keys(c))
@@ -379,17 +354,22 @@ export function defineRepo(c: RepoConfig): RepoConfig {
379
354
  // repo rules are validated against the built-ins, so a bad id, a duplicate,
380
355
  // or an SK squatter dies at config load, not silently mid-check
381
356
  if (c.rules != null) validateRules(c.rules, { label: 'defineRepo: rules', taken: builtinCheckIds() })
382
- // a `when` moves a check off the gate, so a value it cannot act on dies
383
- // here. The RULE namespace is the one this file can check: a task is
384
- // resolved from the floor and a check is discovered on disk, so those two
385
- // are caught by the gate against the real inventory (core/when.ts
386
- // unknownWhenIds)
357
+ // `off` silences a check, so it names one that exists and says why. The
358
+ // RULE namespace is the one this file can check: a task is resolved from
359
+ // the floor and a check is discovered on disk, so those two are caught by
360
+ // the gate against the real inventory (core/when.ts unknownOffIds)
361
+ for (const [id, reason] of Object.entries(c.off ?? {})) {
362
+ if (id.trim() === '') throw new Error(`defineRepo: off has an entry with no check id`)
363
+ if (typeof reason !== 'string' || reason.trim() === '') throw new Error(`defineRepo: off.${id} needs a reason: a check nobody can explain is a silenced gate`)
364
+ if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
365
+ throw new Error(`defineRepo: off.${id} names no rule, the ids are in \`shipkit checks\``)
366
+ }
367
+ // the retired `when`: only its `off:<why>` word still means something
387
368
  for (const [id, raw] of Object.entries(c.when ?? {})) {
388
- if (id.trim() === '') throw new Error(`defineRepo: when has an entry with no check id`)
389
369
  const w = parseWhen(raw)
390
370
  if (typeof w === 'string') throw new Error(`defineRepo: when.${id} ${w}`)
391
- if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
392
- throw new Error(`defineRepo: when.${id} names no rule, the ids are in \`shipkit checks\``)
371
+ if (w.at !== 'off')
372
+ throw new Error(`defineRepo: when.${id} '${raw}' no longer places a check: every check declares where it runs (build/shipkit/runs.md). A repo can only turn one off: off: { ${id}: '<why>' }`)
393
373
  }
394
374
  // a budget turns a slow pass yellow, so a value it cannot compare with dies here
395
375
  for (const [id, v] of Object.entries(c.budgets ?? {})) {
package/src/rules/rule.ts CHANGED
@@ -6,14 +6,14 @@
6
6
  //
7
7
  // A new invariant is a `<name>.check.ts` file: `check({ blocking, run })`,
8
8
  // dropped anywhere in the repo, discovered with no config edit.
9
- import { whenOfRule } from '../core/when.ts'
9
+ import { repoOff } from '../core/when.ts'
10
10
  import { tagCheckLines, checkCacheExtra, type Rule, type RuleEnv } from '../check.ts'
11
11
 
12
12
  export type { FixResult, Rule, RuleEnv } from '../check.ts'
13
13
 
14
14
  /** the config part of a rule's cache identity, including where it runs */
15
15
  export function ruleCacheExtra(rule: Rule, env: RuleEnv): string | undefined {
16
- return checkCacheExtra(rule, env, whenOfRule(env.config, rule.id))
16
+ return checkCacheExtra(rule.cacheKey?.(env), repoOff(env.config, rule.id)?.reason ?? null)
17
17
  }
18
18
 
19
19
  /** tag every severity line with the rule id (check.ts owns the rendering) */
@@ -7,7 +7,7 @@ always-loaded one, and route the rest by surface.
7
7
  ## Gates
8
8
 
9
9
  - **Spec-first**: the `agent/` page changes BEFORE the code it describes.
10
- - **`shipkit ci` is the verification.** Run it, not the linter, the
10
+ - **`sk ci` is the verification.** Run it, not the linter, the
11
11
  typechecker and the tests one at a time.
12
12
  - **Repro before fix**: write the failing test, then fix, then re-run it.
13
13
  - One code path per concern. Delete the redundant mechanism rather than
@@ -5,7 +5,7 @@
5
5
  ## Quick start
6
6
 
7
7
  - `bun run hub` → the repo toolbox at http://localhost:{{PORT}}
8
- - `shipkit ci` → THE gate: run it to check your work, never the parts
8
+ - `sk ci` → THE gate: run it to check your work, never the parts
9
9
 
10
10
  This folder is the project's internal wiki and its SPEC. Update the page
11
11
  BEFORE the change it describes.
@@ -11,12 +11,12 @@ description: Prove a change in THIS project actually works — drive the real fl
11
11
 
12
12
  ## The rules (house-wide)
13
13
  - Failing repro test BEFORE the fix. Green checks ≠ verification.
14
- - Logic → fast headless bun tests, run through `shipkit ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <test file>` is the narrow way in, and one test by name is `bun run test:raw -- -t "<name>"`.
14
+ - Logic → fast headless bun tests, run through `sk ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <test file>` is the narrow way in, and one test by name is `bun run test:raw -- -t "<name>"`.
15
15
  - Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
16
16
  - Verify as the end user would: drive the real flow, observe the real signal.
17
17
 
18
18
  ## This project
19
- - Full gate: `shipkit ci` — THE command, never the linter, the typechecker
19
+ - Full gate: `sk ci` — THE command, never the linter, the typechecker
20
20
  and the tests one at a time.
21
21
  - TODO, fill these in once the stack exists: the dev server command and its
22
22
  port (check it is not already running: `curl -s localhost:PORT >/dev/null
@@ -9,6 +9,7 @@ import { clampCap, DEFAULT_CAP, DEFAULT_MINUTES, idleRow, WINDOW_MINUTES, type A
9
9
  // the words the page and `shipkit cross-repo-activity` share live beside the shapes
10
10
  export { ageShort, CAP_MAX, CAP_MIN, clampCap, DEFAULT_CAP, dirtWords, filesWords, upstreamWords, windowWords } from '../../home/activity-shape.ts'
11
11
  import type { HomeSt } from './home-st.ts'
12
+ import { ownTrouble, reach } from './reach.ts'
12
13
 
13
14
  export const ACTIVITY_POLL_MS = 60_000
14
15
  const MINUTES_KEY = 'home:activity:minutes'
@@ -105,11 +106,11 @@ export class ActivitySt {
105
106
  /** the repo in Fork, the git client: the daemon opens it by name */
106
107
  async openFork(name: string): Promise<void> {
107
108
  try {
108
- const r = await fetch('/api/projects/fork', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })
109
+ const r = await reach('/api/projects/fork', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })
109
110
  if (!r.ok) throw new Error(await r.text())
110
111
  runInAction(() => { this.error = '' })
111
112
  } catch (e) {
112
- runInAction(() => { this.error = `open ${name} in Fork: ${(e as Error).message}` })
113
+ runInAction(() => { this.error = ownTrouble(e, this.st.machine, (m) => `open ${name} in Fork: ${m}`) })
113
114
  }
114
115
  }
115
116
 
@@ -128,12 +129,12 @@ export class ActivitySt {
128
129
  const id = ++this.gen
129
130
  runInAction(() => { this.loading = true; this.nextAt = Date.now() + ACTIVITY_POLL_MS })
130
131
  try {
131
- const r = await fetch(`/api/home/activity.json?m=${this.minutes}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(30_000) })
132
+ const r = await reach(`/api/home/activity.json?m=${this.minutes}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(30_000) })
132
133
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
133
134
  const j = (await r.json()) as ActivityAnswer
134
135
  runInAction(() => { if (id === this.gen) { this.data = j; this.error = '' } })
135
136
  } catch (e) {
136
- runInAction(() => { if (id === this.gen) this.error = (e as Error).message })
137
+ runInAction(() => { if (id === this.gen) this.error = ownTrouble(e, this.st.machine) })
137
138
  } finally {
138
139
  // the countdown starts when the answer lands, so reads never overlap
139
140
  runInAction(() => { if (id === this.gen) { this.loading = false; this.nextAt = Date.now() + ACTIVITY_POLL_MS } })
@@ -52,7 +52,7 @@ const Next = observer(({ app }: { app: HomeSt }) => {
52
52
  })
53
53
 
54
54
  /** one commit's cells; `old` is the last one, from before the window */
55
- const Commit = ({ c, now, old }: { c: ActivityCommit; now: number; old: boolean }) => (
55
+ export const Commit = ({ c, now, old }: { c: ActivityCommit; now: number; old: boolean }) => (
56
56
  <>
57
57
  <td className="acsha">{c.sha}</td>
58
58
  <td className="acwhen" title={stampWords(c.at)}>{ageShort(c.at, now)}</td>
@@ -71,12 +71,12 @@ export const failingOf = (r: ActivityRow): StatusCheck[] => {
71
71
  if (r.failing != null) return r.failing
72
72
  const c = r.status
73
73
  if (c == null) return []
74
- const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '' })
74
+ const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '', lines: [] })
75
75
  return [...c.reds.map((id) => one(id, 'fail')), ...c.warns.map((id) => one(id, 'warn'))]
76
76
  }
77
77
 
78
78
  /** each failing and warning check: its id (a link to its page), its title,
79
- * how old that outcome is, and the first line it printed about it */
79
+ * how old that outcome is, and every line it printed about it */
80
80
  export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow }) => {
81
81
  const now = app.activity.now
82
82
  return (
@@ -93,7 +93,7 @@ export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow })
93
93
  {c.title !== c.id ? <span className="acchktitle">{c.title}</span> : null}
94
94
  {c.at == null ? null : <span className="acdim" title={stampWords(c.at)}>{ageShort(c.at, now)}</span>}
95
95
  </span>
96
- {c.line === '' ? null : <span className="acchkline">{c.line}</span>}
96
+ {(c.lines ?? (c.line === '' ? [] : [c.line])).map((l, i) => <span key={i} className="acchkline">{l}</span>)}
97
97
  </li>
98
98
  ))}
99
99
  </ul>
@@ -26,17 +26,19 @@ export const AdminPage = observer(({ app }: { app: RepoSt }) => {
26
26
  <span className="tid">admin</span>
27
27
  <h3 className="ctitle">{name}</h3>
28
28
  </div>
29
- <Row title="reload the hub" says={`stops ${name}'s hub in the daemon and loads it again, its repo.config.ts read fresh and its processes started again. The daemon already does it on its own when repo.config.ts changes: this is for a hub that is stuck.${w == null ? '' : ` Its hub is ${w.status} now.`}`}>
30
- <button className="ghost" onClick={() => void a.reload()}>reload</button>
31
- </Row>
32
- <Row title="keep active" says="an active repo runs the checks the plan holds for it and gets its red pings. It is active on its own while an agent works in it or VS Code has it open: this keeps it active without either.">
33
- <label className="adflag"><input type="checkbox" checked={a.active} onChange={(e) => void a.setActive(e.currentTarget.checked)} />active</label>
34
- </Row>
35
- <Row title="archive" says="it leaves the project list for the archived page, and nothing runs there: no hub, no watch, no plan rows, no processes, no widgets. Written to repo.config.ts as archived: true, and undone from the archived page.">
36
- {a.confirming
37
- ? <span className="adconfirm"><span>archive {name}?</span><button className="primary" onClick={() => a.archive()}>archive it</button><button className="ghost" onClick={() => a.cancel()}>cancel</button></span>
38
- : <button className="ghost" onClick={() => a.askArchive()}>archive</button>}
39
- </Row>
29
+ <div className="adbox">
30
+ <Row title="reload the hub" says={`stops ${name}'s hub in the daemon and loads it again, its repo.config.ts read fresh and its processes started again. The daemon already does it on its own when repo.config.ts changes: this is for a hub that is stuck.${w == null ? '' : ` Its hub is ${w.status} now.`}`}>
31
+ <button className="ghost" onClick={() => void a.reload()}>reload</button>
32
+ </Row>
33
+ <Row title="keep active" says="an active repo runs the checks the plan holds for it and gets its red pings. It is active on its own while an agent works in it or VS Code has it open: this keeps it active without either.">
34
+ <label className="adflag"><input type="checkbox" checked={a.active} onChange={(e) => void a.setActive(e.currentTarget.checked)} />active</label>
35
+ </Row>
36
+ <Row title="archive" says="it leaves the project list for the archived page, and nothing runs there: no hub, no watch, no plan rows, no processes, no widgets. Written to repo.config.ts as archived: true, and undone from the archived page.">
37
+ {a.confirming
38
+ ? <span className="adconfirm"><span>archive {name}?</span><button className="primary" onClick={() => a.archive()}>archive it</button><button className="ghost" onClick={() => a.cancel()}>cancel</button></span>
39
+ : <button className="ghost" onClick={() => a.askArchive()}>archive</button>}
40
+ </Row>
41
+ </div>
40
42
  </div>
41
43
  )
42
44
  })