rvlib-shipkit 1.4.0 → 1.6.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 (199) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/assets/gen/comfyts-gen.ts +12 -8
  5. package/src/assets/server/studio-server.ts +14 -2
  6. package/src/check.ts +73 -103
  7. package/src/checks/SK001-package-json-shape.check.ts +11 -6
  8. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  9. package/src/checks/SK003-claude-md-shape.check.ts +7 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  11. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  12. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  13. package/src/checks/SK007-github-visibility.check.ts +4 -2
  14. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  15. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  16. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  17. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  18. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  19. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  20. package/src/checks/SK014-prose-ste.check.ts +4 -0
  21. package/src/checks/SK015-nested-repo.check.ts +4 -2
  22. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  23. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  24. package/src/checks/SK018-test-files-run.check.ts +6 -2
  25. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  26. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  27. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  28. package/src/checks/SK024-identity-colors.check.ts +5 -3
  29. package/src/checks/SK025-release-drift.check.ts +4 -5
  30. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  31. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  32. package/src/checks/SK028-readme.check.ts +5 -1
  33. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  34. package/src/checks/SK030-cache-audit.check.ts +34 -0
  35. package/src/checks/SK031-trace-audit.check.ts +39 -0
  36. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  37. package/src/cli/cmd_Asks.ts +5 -5
  38. package/src/cli/cmd_Assets.ts +17 -13
  39. package/src/cli/cmd_Bump.ts +12 -11
  40. package/src/cli/cmd_CacheAudit.ts +43 -0
  41. package/src/cli/cmd_Check.ts +8 -7
  42. package/src/cli/cmd_Checks.ts +7 -6
  43. package/src/cli/cmd_Ci.ts +12 -11
  44. package/src/cli/cmd_Commands.ts +16 -13
  45. package/src/cli/cmd_ConfigKeys.ts +7 -6
  46. package/src/cli/cmd_Contracts.ts +3 -3
  47. package/src/cli/cmd_CrossRepoActivity.ts +8 -6
  48. package/src/cli/cmd_Fleet.ts +9 -7
  49. package/src/cli/cmd_GlobalProcesses.ts +120 -7
  50. package/src/cli/cmd_Home.ts +26 -19
  51. package/src/cli/cmd_Hooks.ts +6 -5
  52. package/src/cli/cmd_Init.ts +5 -4
  53. package/src/cli/cmd_Keys.ts +20 -8
  54. package/src/cli/cmd_Mv.ts +7 -6
  55. package/src/cli/cmd_Needs.ts +4 -3
  56. package/src/cli/cmd_Overview.ts +5 -4
  57. package/src/cli/cmd_Pb.ts +2 -2
  58. package/src/cli/cmd_Planned.ts +6 -5
  59. package/src/cli/cmd_Prose.ts +5 -5
  60. package/src/cli/cmd_Reflect.ts +7 -7
  61. package/src/cli/cmd_Release.ts +6 -5
  62. package/src/cli/cmd_Releases.ts +5 -4
  63. package/src/cli/cmd_Serve.ts +6 -4
  64. package/src/cli/cmd_Smoke.ts +5 -4
  65. package/src/cli/cmd_Start.ts +7 -6
  66. package/src/cli/cmd_Status.ts +4 -3
  67. package/src/cli/cmd_TraceAudit.ts +62 -0
  68. package/src/cli/cmd_Tui.ts +3 -2
  69. package/src/cli/cmd_When.ts +71 -0
  70. package/src/cli/cmd_Wiki.ts +4 -4
  71. package/src/cli.ts +11 -2
  72. package/src/collections/checks.ts +2 -0
  73. package/src/collections/collection-types.ts +12 -4
  74. package/src/collections/providers.ts +5 -3
  75. package/src/core/app-icon.ts +154 -0
  76. package/src/core/audit-report.ts +21 -0
  77. package/src/core/cache-audit.ts +72 -0
  78. package/src/core/canonical-scripts.ts +2 -2
  79. package/src/core/check-ctx.ts +14 -5
  80. package/src/core/check-runner.ts +43 -23
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +23 -2
  83. package/src/core/floor-resolve.ts +5 -7
  84. package/src/core/git-root.ts +13 -0
  85. package/src/core/ignored-block.ts +94 -0
  86. package/src/core/ignored-inputs.ts +47 -0
  87. package/src/core/jobs.ts +9 -2
  88. package/src/core/legacy-shapes.ts +39 -10
  89. package/src/core/one-lib.ts +59 -4
  90. package/src/core/one-way-in-policy.ts +2 -2
  91. package/src/core/outcome-words.ts +18 -2
  92. package/src/core/port-check-cli.ts +52 -0
  93. package/src/core/port-check-file.ts +295 -0
  94. package/src/core/port-check-files.ts +31 -0
  95. package/src/core/read-set.ts +97 -9
  96. package/src/core/retention.ts +0 -14
  97. package/src/core/run-check.ts +12 -9
  98. package/src/core/run-ci.ts +14 -5
  99. package/src/core/run-fix.ts +30 -23
  100. package/src/core/run-fixers.ts +48 -5
  101. package/src/core/runs-matrix.ts +179 -0
  102. package/src/core/runs.ts +242 -0
  103. package/src/core/trace-audit.ts +120 -0
  104. package/src/core/update-policy.ts +20 -0
  105. package/src/core/when-block.ts +29 -30
  106. package/src/core/when.ts +41 -103
  107. package/src/core/wire.ts +10 -14
  108. package/src/fleet/scan-fleet.ts +20 -4
  109. package/src/fleet/update-fleet.ts +6 -6
  110. package/src/home/gui-app.ts +24 -27
  111. package/src/home/home-launchd.ts +20 -4
  112. package/src/home/home-routes.ts +12 -1
  113. package/src/home/home-start.ts +11 -5
  114. package/src/home/hubs.ts +5 -5
  115. package/src/home/open-app.ts +2 -2
  116. package/src/home/pm.ts +8 -0
  117. package/src/home/run-pm.ts +1 -0
  118. package/src/home/serve-web.ts +2 -2
  119. package/src/home/supervise.ts +24 -1
  120. package/src/home/via-hub.ts +14 -0
  121. package/src/hooks/run-hooks.ts +48 -4
  122. package/src/init-repo.ts +6 -6
  123. package/src/ledger/db.ts +2 -13
  124. package/src/ledger/gate-checks.ts +133 -61
  125. package/src/ledger/git-key.ts +153 -31
  126. package/src/ledger/git-scan.ts +5 -2
  127. package/src/ledger/ledger.ts +136 -21
  128. package/src/ledger/store.ts +14 -0
  129. package/src/ledger/toolchain-identity.ts +248 -45
  130. package/src/plan/repo-source.ts +18 -39
  131. package/src/problems/extract.ts +3 -0
  132. package/src/release/run-release.ts +5 -0
  133. package/src/repo-config.ts +46 -54
  134. package/src/rules/rule.ts +2 -2
  135. package/src/scaffold/files/agent-coding.md +1 -1
  136. package/src/scaffold/files/agent-index.md +1 -1
  137. package/src/scaffold/files/verify-skill.md +2 -2
  138. package/src/serve/hub-app/activity-st.ts +5 -4
  139. package/src/serve/hub-app/activity-view.tsx +1 -1
  140. package/src/serve/hub-app/admin-svc.ts +36 -3
  141. package/src/serve/hub-app/admin-view.tsx +46 -12
  142. package/src/serve/hub-app/check-view.tsx +66 -46
  143. package/src/serve/hub-app/check-words.ts +1 -11
  144. package/src/serve/hub-app/checks-svc.ts +14 -35
  145. package/src/serve/hub-app/col-card.tsx +1 -44
  146. package/src/serve/hub-app/col-tables-view.tsx +84 -11
  147. package/src/serve/hub-app/col-tables.ts +27 -0
  148. package/src/serve/hub-app/cols-svc.ts +5 -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 +6 -0
  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 +14 -2
  160. package/src/serve/hub-app/home-strip.css +43 -12
  161. package/src/serve/hub-app/home-strip.tsx +115 -64
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +32 -8
  164. package/src/serve/hub-app/hub-types.ts +5 -2
  165. package/src/serve/hub-app/hub.css +89 -63
  166. package/src/serve/hub-app/icons.tsx +11 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +15 -4
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +18 -22
  171. package/src/serve/hub-app/plan-view.tsx +51 -0
  172. package/src/serve/hub-app/planned-st.ts +9 -4
  173. package/src/serve/hub-app/processes-view.tsx +45 -0
  174. package/src/serve/hub-app/project-list.css +4 -1
  175. package/src/serve/hub-app/project-list.tsx +6 -3
  176. package/src/serve/hub-app/projects-svc.ts +25 -10
  177. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  178. package/src/serve/hub-app/reach.ts +36 -0
  179. package/src/serve/hub-app/readme-svc.ts +3 -2
  180. package/src/serve/hub-app/repo-sections.ts +131 -0
  181. package/src/serve/hub-app/repo-st.ts +70 -14
  182. package/src/serve/hub-app/sidebar.tsx +17 -42
  183. package/src/serve/hub-app/timeline-svc.ts +3 -2
  184. package/src/serve/hub-app/tree-build.ts +151 -155
  185. package/src/serve/hub-app/tree-row.tsx +55 -12
  186. package/src/serve/hub-app/tree-svc.ts +97 -56
  187. package/src/serve/hub-app/when-view.tsx +89 -0
  188. package/src/serve/hub-app/when.css +17 -0
  189. package/src/serve/hub-runtime.ts +7 -18
  190. package/src/serve/project-gutter-route.ts +22 -1
  191. package/src/serve/run-serve.ts +1 -1
  192. package/src/serve/serve-hub.ts +4 -14
  193. package/src/suite/judge-suites.ts +6 -0
  194. package/src/trace/interpose.c +655 -0
  195. package/src/trace/read-trace.ts +485 -0
  196. package/src/trace/tracer.ts +282 -0
  197. package/src/serve/hub-app/processes-svc.ts +0 -63
  198. package/src/serve/hub-app/repo-sections.tsx +0 -61
  199. package/src/serve/repo-processes.ts +0 -89
@@ -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
@@ -161,6 +143,15 @@ export type RepoConfig = {
161
143
  /** repo-contributed .gitignore lines, rendered INSIDE rule SK004's managed
162
144
  * block — for ecosystem-specific names the published lib may not carry */
163
145
  gitignore?: string[]
146
+ /** gitignored paths a task READS, by task id (`'*'` for every task),
147
+ * repo-relative: a path joins that task's key by content (generated
148
+ * types, a local fixture), and `dir/**` by the names under the dir (an
149
+ * output folder a suite lists). A task keyed on the whole work tree sees
150
+ * no gitignored file, and a declared list names none either, so both
151
+ * carry these. The traced audit (SK031) finds the missing ones and the
152
+ * fix pass writes them into their own managed block; an entry written
153
+ * outside it stops that repair */
154
+ ignoredInputs?: Record<string, string[]>
164
155
  /** untrack the paths shipkit generates on every run (STATUS.md and
165
156
  * friends) the first time it sees them tracked, in one commit. Default
166
157
  * ON: a generated file left in the index dirties every tree forever and
@@ -171,21 +162,25 @@ export type RepoConfig = {
171
162
  * folder only: it never commits, untracks, tags or pushes on the parent's
172
163
  * history. Rule SK015 fails when this and the disk disagree (core/nesting.ts) */
173
164
  nested?: boolean
174
- /** WHERE EACH CHECK RUNS, keyed by check id. One declaration for a
175
- * question that used to have three: `'always'` every gate and every
176
- * health pass, `'release'` only at `--full` and on the release path,
177
- * `'health'` only in the health pass, `'off:<reason>'` not at all.
178
- * `defineRepo` throws on a value it cannot parse, on an `off` with no
179
- * reason, and on an id no check carries. The hub writes this
180
- * map through the managed block in this file (core/when-block.ts). */
165
+ /** THE CHECKS THIS REPO TURNS OFF, check id → why. The one thing a repo
166
+ * says about a check it does not own: where a check runs is its own
167
+ * declaration (build/shipkit/runs.md), and an off check runs in no
168
+ * context. `defineRepo` throws on a blank reason and on a rule id no
169
+ * check carries. The hub writes this map through the managed block in
170
+ * this file (core/when-block.ts). */
171
+ off?: Record<string, string>
172
+ /** RETIRED, read only while SK029 ports it into `off`: an `'off:<why>'`
173
+ * value reads as `off`, and any other word is refused, because a repo
174
+ * no longer places a check. Dies once `shipkit fleet` shows no repo
175
+ * carrying it */
181
176
  when?: Record<string, string>
182
177
  /** WHAT A HEALTHY RUN OF EACH CHECK TAKES, in ms, keyed by check id. A
183
178
  * check that passes over its budget prints a WARN and reads yellow, and
184
179
  * never blocks. It wins over the check's own `budgetMs` and the default
185
180
  * for its kind (core/budget.ts). An id no check carries fails the run,
186
- * the same as a `when` entry. */
181
+ * the same as an `off` entry. */
187
182
  budgets?: Record<string, number>
188
- /** DEPRECATED, read into `when` as `off:<reason>`: rule id → the reason
183
+ /** RETIRED, read as `off` while SK029 ports it there: rule id → the reason
189
184
  * this repo breaks that invariant on purpose. An exempted rule does not
190
185
  * lint and prints a skip line carrying the reason. `defineRepo` throws on
191
186
  * an unknown id or a blank reason */
@@ -215,7 +210,7 @@ export type RepoConfig = {
215
210
  /** widens the read-only tool allowlist SK008 enforces (default Read, Grep, Glob, Bash) */
216
211
  tools?: string[]
217
212
  }
218
- /** ONE WAY IN escape hatch (build/shipkit.md): governed script names this
213
+ /** ONE WAY IN escape hatch (build/shipkit/one-way-in.md): governed script names this
219
214
  * repo keeps as a direct tool call. Declared per NAME, never as a list of
220
215
  * banned binaries: the invariant is about which VERBS an agent reaches
221
216
  * for, and a binary blocklist goes stale the day a repo changes runner.
@@ -300,18 +295,7 @@ export type RepoConfig = {
300
295
  logDir?: string
301
296
  }
302
297
 
303
- 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'])
304
-
305
- const KEBAB = /^[a-z0-9][a-z0-9-]*$/
306
- const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
307
-
308
- /** why a process declaration cannot run, or null */
309
- export function processProblem(d: ProcessDecl): string | null {
310
- if (!KEBAB.test(d.id ?? '')) return `serve.processes: id '${String(d.id)}' must be kebab-case`
311
- if (!words(d.cmd)) return `serve.processes['${d.id}'] needs cmd: a list of words`
312
- if (d.url != null && !/^https?:\/\//.test(d.url)) return `serve.processes['${d.id}'].url must be an http url`
313
- return null
314
- }
298
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'ignoredInputs', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
315
299
 
316
300
  export function defineRepo(c: RepoConfig): RepoConfig {
317
301
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -321,12 +305,15 @@ export function defineRepo(c: RepoConfig): RepoConfig {
321
305
  // a half-declared reviewer would lint a path like `.claude/agents/.md`
322
306
  if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
323
307
  throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
324
- const pids = new Set<string>()
325
- for (const d of c.serve?.processes ?? []) {
326
- const why = processProblem(d)
327
- if (why != null) throw new Error(`defineRepo: ${why}`)
328
- if (pids.has(d.id)) throw new Error(`defineRepo: serve.processes declares '${d.id}' twice`)
329
- pids.add(d.id)
308
+ if (c.serve != null && 'processes' in c.serve)
309
+ throw new Error(`defineRepo: serve.processes was removed; declare each process in a <name>.shipkit.ts file as \`export default globalProcess({...})\``)
310
+ if (c.ignoredInputs != null && (typeof c.ignoredInputs !== 'object' || Array.isArray(c.ignoredInputs)))
311
+ throw new Error(`defineRepo: ignoredInputs is { <task id or '*'>: [paths] }`)
312
+ for (const [task, paths] of Object.entries(c.ignoredInputs ?? {})) {
313
+ if (task === '' || !Array.isArray(paths)) throw new Error(`defineRepo: ignoredInputs '${task}' must name a task (or '*') and list paths`)
314
+ for (const p of paths)
315
+ if (typeof p !== 'string' || p === '' || p.startsWith('/') || p.split('/').includes('..'))
316
+ throw new Error(`defineRepo: ignoredInputs '${String(p)}' must be a repo-relative path inside the repo`)
330
317
  }
331
318
  // unknown keys die loud — a typo'd or removed key silently changing the
332
319
  // gate is exactly the never-silence failure mode (A23)
@@ -384,17 +371,22 @@ export function defineRepo(c: RepoConfig): RepoConfig {
384
371
  // repo rules are validated against the built-ins, so a bad id, a duplicate,
385
372
  // or an SK squatter dies at config load, not silently mid-check
386
373
  if (c.rules != null) validateRules(c.rules, { label: 'defineRepo: rules', taken: builtinCheckIds() })
387
- // a `when` moves a check off the gate, so a value it cannot act on dies
388
- // here. The RULE namespace is the one this file can check: a task is
389
- // resolved from the floor and a check is discovered on disk, so those two
390
- // are caught by the gate against the real inventory (core/when.ts
391
- // unknownWhenIds)
374
+ // `off` silences a check, so it names one that exists and says why. The
375
+ // RULE namespace is the one this file can check: a task is resolved from
376
+ // the floor and a check is discovered on disk, so those two are caught by
377
+ // the gate against the real inventory (core/when.ts unknownOffIds)
378
+ for (const [id, reason] of Object.entries(c.off ?? {})) {
379
+ if (id.trim() === '') throw new Error(`defineRepo: off has an entry with no check id`)
380
+ if (typeof reason !== 'string' || reason.trim() === '') throw new Error(`defineRepo: off.${id} needs a reason: a check nobody can explain is a silenced gate`)
381
+ if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
382
+ throw new Error(`defineRepo: off.${id} names no rule, the ids are in \`shipkit checks\``)
383
+ }
384
+ // the retired `when`: only its `off:<why>` word still means something
392
385
  for (const [id, raw] of Object.entries(c.when ?? {})) {
393
- if (id.trim() === '') throw new Error(`defineRepo: when has an entry with no check id`)
394
386
  const w = parseWhen(raw)
395
387
  if (typeof w === 'string') throw new Error(`defineRepo: when.${id} ${w}`)
396
- if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
397
- throw new Error(`defineRepo: when.${id} names no rule, the ids are in \`shipkit checks\``)
388
+ if (w.at !== 'off')
389
+ 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>' }`)
398
390
  }
399
391
  // a budget turns a slow pass yellow, so a value it cannot compare with dies here
400
392
  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>
@@ -1,17 +1,50 @@
1
1
  // A repo's admin page, as state: what administers the repo rather than works
2
- // in it. Archive asks first (the confirmation is this state, so it survives a
2
+ // in it. Its identity colours, with the app icon they give it. Archive asks first (the confirmation is this state, so it survives a
3
3
  // re-render and a test drives it headless), reload and keep active go to the
4
4
  // machine by the repo's key. On a hub of its own there is no machine, and no
5
5
  // admin page.
6
- import { makeAutoObservable } from 'mobx'
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import type { Identity } from '../../core/identity-color.ts'
7
8
  import type { RepoSt } from './repo-st.ts'
8
9
 
10
+ /** how long a picker rests before the preview draws its colours */
11
+ export const PREVIEW_REST_MS = 150
12
+
9
13
  export class AdminSvc {
10
14
  /** the archive button was pressed: its confirmation shows in its place */
11
15
  confirming = false
16
+ /** the colours the preview keeps while a picker moves, so a drag draws a
17
+ * few icons, not one per frame; null follows the repo's colours */
18
+ held: Identity | null = null
19
+ /** the repo has no png mark, or it would not draw: the app wears the default icon */
20
+ noIcon = false
21
+ private rest: ReturnType<typeof setTimeout> | null = null
12
22
 
13
23
  constructor(private st: RepoSt) {
14
- makeAutoObservable<this, 'st'>(this, { st: false })
24
+ makeAutoObservable<this, 'st' | 'rest'>(this, { st: false, rest: false })
25
+ }
26
+
27
+ /** by key: the project list and the icon route know a repo by its
28
+ * folder, and its config can name it otherwise */
29
+ get identity(): Identity {
30
+ return this.st.home.projects.identityOf(this.st.key)
31
+ }
32
+ /** one colour, shown at once and saved to repo.config.ts once it rests */
33
+ setColour(patch: Partial<Identity>): void {
34
+ this.held ??= this.identity
35
+ this.st.home.projects.setIdentity(this.st.key, patch)
36
+ if (this.rest != null) clearTimeout(this.rest)
37
+ this.rest = setTimeout(() => runInAction(() => { this.held = null }), PREVIEW_REST_MS)
38
+ }
39
+ get previewSrc(): string {
40
+ const id = this.held ?? this.identity
41
+ return `/api/projects/app-icon/${encodeURIComponent(this.st.key)}?light=${encodeURIComponent(id.colorLight)}&dark=${encodeURIComponent(id.colorDark)}`
42
+ }
43
+ previewFailed(): void {
44
+ this.noIcon = true
45
+ }
46
+ previewLoaded(): void {
47
+ this.noIcon = false
15
48
  }
16
49
 
17
50
  askArchive(): void {
@@ -1,5 +1,6 @@
1
1
  // THE ADMIN PAGE of a repo (`#page:admin`): what administers the repo rather
2
- // than works in it, one row per control, each with the one sentence that says
2
+ // than works in it. Its identity first (the colours, and the app icon they
3
+ // draw, core/app-icon.ts), then one row per control, each with the one sentence that says
3
4
  // what it does. State in admin-svc.ts. Only on the home: a hub of its own has
4
5
  // no machine to administer it.
5
6
  import { observer } from 'mobx-react-lite'
@@ -16,6 +17,36 @@ const Row = ({ title, says, children }: { title: string; says: string; children:
16
17
  </div>
17
18
  )
18
19
 
20
+ /** the repo's two colours, and what they look like: its app icon and its name on both pages */
21
+ const IdentityBox = observer(({ app }: { app: RepoSt }) => {
22
+ const a = app.admin
23
+ const id = a.identity
24
+ const name = app.name
25
+ return (
26
+ <div className="adbox adid">
27
+ <div className="adicon">
28
+ <img src={a.previewSrc} alt={`${name} app icon`} width={128} height={128} hidden={a.noIcon}
29
+ onLoad={() => a.previewLoaded()} onError={() => a.previewFailed()} />
30
+ {a.noIcon ? <span className="dim">no png mark (.shipkit/identity/icon.png, public/logo.png or public/favicon.png): its app wears the default icon</span> : null}
31
+ </div>
32
+ <div className="adtext">
33
+ <b>identity</b>
34
+ <span className="dim">the repo's two colours, written to repo.config.ts as identity. The app icon sets the mark on the one it stands out on most, and each app redraws it at its next open.</span>
35
+ <label className="adpick">
36
+ <input type="color" value={id.colorDark} onChange={(e) => a.setColour({ colorDark: e.currentTarget.value })} />
37
+ <span className="adsample dark" style={{ color: id.colorDark }}>{name}</span>
38
+ <span className="dim">on a dark page, colorDark {id.colorDark}</span>
39
+ </label>
40
+ <label className="adpick">
41
+ <input type="color" value={id.colorLight} onChange={(e) => a.setColour({ colorLight: e.currentTarget.value })} />
42
+ <span className="adsample light" style={{ color: id.colorLight }}>{name}</span>
43
+ <span className="dim">on a light page, colorLight {id.colorLight}</span>
44
+ </label>
45
+ </div>
46
+ </div>
47
+ )
48
+ })
49
+
19
50
  export const AdminPage = observer(({ app }: { app: RepoSt }) => {
20
51
  const a = app.admin
21
52
  const w = app.home.machine?.worker(app.key)
@@ -26,17 +57,20 @@ export const AdminPage = observer(({ app }: { app: RepoSt }) => {
26
57
  <span className="tid">admin</span>
27
58
  <h3 className="ctitle">{name}</h3>
28
59
  </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>
60
+ <IdentityBox app={app} />
61
+ <div className="adbox">
62
+ <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.`}`}>
63
+ <button className="ghost" onClick={() => void a.reload()}>reload</button>
64
+ </Row>
65
+ <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.">
66
+ <label className="adflag"><input type="checkbox" checked={a.active} onChange={(e) => void a.setActive(e.currentTarget.checked)} />active</label>
67
+ </Row>
68
+ <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.">
69
+ {a.confirming
70
+ ? <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>
71
+ : <button className="ghost" onClick={() => a.askArchive()}>archive</button>}
72
+ </Row>
73
+ </div>
40
74
  </div>
41
75
  )
42
76
  })
@@ -1,45 +1,42 @@
1
1
  // ONE GATE CHECK, on its own page, as ONE compact block: the id as a tag, the
2
2
  // title, the sentence it wrote about itself, the exact command it runs, then
3
3
  // one grid of facts, each a dim label and a short value whose explanation is
4
- // the row's `(?)`. No box holds a single field. Navigation is the tree on the
4
+ // the row's `(?)`, then where it runs: one line per context. No box holds a single field. Navigation is the tree on the
5
5
  // left, so there is no back link.
6
6
  import { observer } from 'mobx-react-lite'
7
7
  import { useEffect, useRef } from 'react'
8
8
  import { CACHED } from '../../core/outcome-words.ts'
9
+ import { cellMeaning, MATRIX_AXES, RUNS_LEGEND } from '../../core/runs-matrix.ts'
9
10
  import { durationTone, fmtMs } from './dur.ts'
10
11
  import { Help, TERMS } from './help.tsx'
11
12
  import type { RepoSt } from './repo-st.ts'
12
- import type { SlowSuite, CheckFacts, RecentRun, WhenAt } from './hub-types.ts'
13
+ import type { SlowSuite, CheckFacts, RecentRun } from './hub-types.ts'
13
14
  import { renderMd, renderMdInline } from './md.ts'
14
15
  import { nextRun } from './next-run.ts'
15
16
  import { ProseHits } from './prose-hits-view.tsx'
16
17
  import { stampWords, TimeAgo, TimeAhead } from './time-ago.tsx'
17
- import { checkName, commandOf, docPath, editorHref, lastRun, rememberedFact, repoPath, whatFacts, WHEN_OPTION, type ShortPath } from './check-words.ts'
18
+ import { checkName, commandOf, docPath, editorHref, lastRun, rememberedFact, repoPath, whatFacts, type ShortPath } from './check-words.ts'
18
19
 
19
20
  /** which declaration is answering, so the switch never looks like it wrote
20
21
  * something it did not. The CHIP is the short one, its help is the sentence */
21
22
  const SOURCE_CHIP: Record<CheckFacts['whenSource'], string> = {
22
- when: 'repo.config.ts',
23
+ off: 'repo.config.ts',
24
+ when: 'when (retired)',
25
+ ruleExempt: 'ruleExempt (retired)',
23
26
  'check-file': 'the check file',
24
- ruleExempt: 'ruleExempt',
25
- optIn: 'optIn',
26
- 'gate-export': 'gate = false',
27
27
  defaults: 'defaults',
28
- default: 'default',
28
+ default: 'derived',
29
29
  }
30
30
 
31
31
  const SOURCE_WORD: Record<CheckFacts['whenSource'], string> = {
32
- when: 'set in the `when` block of `repo.config.ts`, which this switch writes',
33
- 'check-file': 'declared by the check file itself, and the `when` block overrides it',
34
- ruleExempt: 'from the older `ruleExempt`, which reads as off',
35
- optIn: 'from the older `optIn` key, which reads as health',
36
- 'gate-export': 'from `export const gate = false` in its own file',
32
+ off: 'turned off in the `off` block of `repo.config.ts`, which this switch writes',
33
+ when: 'turned off by the retired `when` key, which `shipkit ci` ports to `off` (SK029)',
34
+ ruleExempt: 'turned off by the retired `ruleExempt` key, which `shipkit ci` ports to `off` (SK029)',
35
+ 'check-file': 'declared by the check file itself: every context it runs in, no default',
37
36
  defaults: 'from the `defaults` block, which removes the floor slot entirely',
38
- default: 'nobody declared where this check runs, so it takes the default',
37
+ default: 'a row with no file of its own: the code that derives it writes every context',
39
38
  }
40
39
 
41
- const WHEN_VALUES: WhenAt[] = ['always', 'release', 'health', 'off']
42
-
43
40
  const VERDICT_WORD: Record<'pass' | 'warn' | 'fail' | 'skip', string> = {
44
41
  pass: 'passed', warn: 'warned', fail: 'failed', skip: 'skipped',
45
42
  }
@@ -126,44 +123,67 @@ export const CommandLine = ({ app, u }: { app: RepoSt; u: CheckFacts }) => {
126
123
  )
127
124
  }
128
125
 
129
- // ── the switch ───────────────────────────────────────────────────────────
126
+ // ── where it runs ────────────────────────────────────────────────────────
127
+
128
+ /** every context, one line each: the value the check declared and what it
129
+ * means in the legend's words. The cache closes it, the off switch ends it */
130
+ const WhereItRuns = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
131
+ const r = u.runs
132
+ return (
133
+ <Section title="where it runs" help="every context a check declares, with no default. The when page of this repo shows every check at once, and `sk when` prints the same">
134
+ {r.off != null
135
+ ? <div className="wline">runs nowhere: {r.off}</div>
136
+ : <div className="cruns">
137
+ {MATRIX_AXES.map((k) => {
138
+ const c = r.cells[k]
139
+ const a = RUNS_LEGEND.find((x) => x.axis === k)!
140
+ return (
141
+ <div key={k} className={`crun${c.on ? ' on' : ''}`}>
142
+ <span className="ck" title={`${k}: ${a.context.replace(/`/g, '')}. From ${c.source}`}>{a.short}</span>
143
+ <span className="crv">{c.on ? c.text : 'no'}</span>
144
+ <span className="crm">{cellMeaning(k, c.text)}</span>
145
+ </div>
146
+ )
147
+ })}
148
+ <div className="crun cache">
149
+ <span className="ck" title={`cache: how an answer is kept. ${r.cache}`}>cache</span>
150
+ <span className="crv">{r.cache.split(/[\s:]/)[0]}</span>
151
+ <span className="crm">{cellMeaning('cache', r.cache)}</span>
152
+ </div>
153
+ </div>}
154
+ {r.derivedWhy == null ? null : <div className="note">no file declares this row: {r.derivedWhy}</div>}
155
+ <div className="cfacts"><OffSwitch app={app} u={u} /></div>
156
+ </Section>
157
+ )
158
+ })
130
159
 
131
- /** RUN WHEN, one select named by effect. It writes `repo.config.ts` itself,
132
- * through the managed block, and leaves the file dirty: a change of place is
133
- * a change its owner reviews in a git client, never a commit made on their
134
- * behalf. Choosing never asks for the reason before it writes. */
135
- const RunWhen = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
160
+ // ── the off switch ───────────────────────────────────────────────────────
161
+
162
+ /** the one thing this repo says about a check: that it is OFF here, with the
163
+ * reason. It writes `repo.config.ts` through the managed block and leaves the
164
+ * file dirty for its owner to review */
165
+ const OffSwitch = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
136
166
  const s = app.checks
137
167
  const g = s.gate
138
- const shown = s.whenShown(u)
139
168
  const reason = s.reasonFor(u.checkId)
169
+ const off = u.runs.off
140
170
  return (
141
- <Row k="run when" help="where this check runs. Changing it writes repo.config.ts and leaves the file for you to commit">
171
+ <Row k="off in this repo" wide help="where this check runs is its own declaration; the one thing this repo says is that it is OFF here, with why. The switch writes the `off` block of repo.config.ts and leaves the file for you to commit">
142
172
  <span className="cwhen">
143
- <select className="wsel" value={shown} disabled={s.gateBusy} onChange={(e) => s.chooseWhen(u.checkId, e.target.value as WhenAt)}>
144
- {WHEN_VALUES.map((v) => <option key={v} value={v}>{WHEN_OPTION[v]}</option>)}
145
- </select>
146
- <Help text={
147
- <span className="legend">
148
- {WHEN_VALUES.map((v) => <span key={v} className="lrow"><b>{WHEN_OPTION[v]}</b> ({v}): {TERMS[v]}</span>)}
149
- </span>
150
- } />
173
+ {off != null ? <span className="tl red">off: {off}</span> : <span className="dim">runs where it declares</span>}
151
174
  <span className="stchip">{SOURCE_CHIP[u.whenSource]}</span>
152
175
  <MdHelp text={SOURCE_WORD[u.whenSource]} />
153
- {u.whenSource === 'when'
154
- ? <button className="wclear" disabled={s.gateBusy} onClick={() => void s.clearWhen(u.checkId)}>drop the entry</button>
155
- : null}
176
+ <a href="#" onClick={(e) => { e.preventDefault(); app.showWhen() }}>what runs when</a>
156
177
  </span>
157
- {shown === 'off'
158
- ? <span className="cblock">
159
- <input className="wreason" placeholder="why it is off (required, and it rides in the config)"
160
- value={reason} onChange={(e) => s.setReason(u.checkId, e.target.value)} />
161
- {u.when === 'off' && u.whenReason != null ? <span className="note">reason today: {u.whenReason}</span> : null}
162
- <button disabled={s.gateBusy || reason.trim() === ''} onClick={() => void s.setWhen(u.checkId, 'off')}>
163
- {u.when === 'off' ? 'change the reason' : 'turn it off'}
164
- </button>
165
- </span>
166
- : null}
178
+ {off != null && u.whenSource === 'off'
179
+ ? <span className="cblock"><button disabled={s.gateBusy} onClick={() => void s.turnOn(u.checkId)}>turn it back on</button></span>
180
+ : off == null
181
+ ? <span className="cblock">
182
+ <input className="wreason" placeholder="why this repo turns it off (required, it rides in the config)"
183
+ value={reason} onChange={(e) => s.setReason(u.checkId, e.target.value)} />
184
+ <button disabled={s.gateBusy || reason.trim() === ''} onClick={() => void s.setOff(u.checkId)}>turn it off here</button>
185
+ </span>
186
+ : null}
167
187
  {g != null && g.state === 'stale'
168
188
  ? <span className="cblock warnline">the managed block in {g.path} was hand edited: reconcile it, then the switch works again</span>
169
189
  : null}
@@ -321,7 +341,6 @@ export const CheckCard = observer(({ app, id }: { app: RepoSt; id: string }) =>
321
341
  {whatFacts(u).map((f) => (
322
342
  <Row key={f.k} k={f.k} help={f.help}>{f.tone == null ? f.v : <span className={`tl ${f.tone}`}>{f.v}</span>}</Row>
323
343
  ))}
324
- <RunWhen app={app} u={u} />
325
344
  <Remembered u={u} />
326
345
  {/* the help never depends on the second, so it reads the wall
327
346
  clock and the row does not repaint on every tick */}
@@ -338,6 +357,7 @@ export const CheckCard = observer(({ app, id }: { app: RepoSt; id: string }) =>
338
357
  {u.group == null ? null : <Row k="group" help="a label several checks share, so `--skip <group>` parks all of them at once">{u.group}</Row>}
339
358
  <Timings u={u} />
340
359
  </div>
360
+ <WhereItRuns app={app} u={u} />
341
361
  {u.build && u.checkId === 'test' ? <SlowSuites app={app} /> : null}
342
362
  {u.checkId === 'SK014' ? <ProseHits app={app} /> : null}
343
363
  <LastOutput app={app} lines={u.lastLines} />
@@ -49,17 +49,7 @@ export function rememberedFact(u: CachePop): RememberedFact {
49
49
  const cadence = CADENCE[u.when ?? 'always']
50
50
  if (u.never != null)
51
51
  return { v: 'never', inputs: [], help: `never cached, so it runs ${cadence}${u.never === '' ? '' : `: ${u.never}`}` }
52
- return { v: 'never', inputs: [], help: `nothing declared, so it runs ${cadence}. \`inputs\`, \`ttl\` or \`never\` in the check says otherwise` }
53
- }
54
-
55
- /** the four places a check runs, named by what they do. The config keeps its
56
- * own words (`always`, `release`, `health`, `off`), and these are only what
57
- * the select under `run when` shows */
58
- export const WHEN_OPTION: Record<WhenAt, string> = {
59
- always: 'always',
60
- release: 'before a release',
61
- health: 'health pass only',
62
- off: 'never',
52
+ return { v: 'never', inputs: [], help: `nothing keys it, so it runs ${cadence}. A check file keys it with \`cache: { by: 'files' | 'reads' | 'ttl' | 'fact' }\`` }
63
53
  }
64
54
 
65
55
  /** what a check IS: three FACTS, each a short value a reader scans, with the