rvlib-shipkit 1.4.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 (161) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/check.ts +68 -95
  5. package/src/checks/SK001-package-json-shape.check.ts +6 -2
  6. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  7. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  8. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  9. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  10. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  11. package/src/checks/SK007-github-visibility.check.ts +4 -2
  12. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  13. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  14. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  15. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  16. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  17. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  18. package/src/checks/SK014-prose-ste.check.ts +4 -0
  19. package/src/checks/SK015-nested-repo.check.ts +4 -2
  20. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  21. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  22. package/src/checks/SK018-test-files-run.check.ts +6 -2
  23. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  24. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  25. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  26. package/src/checks/SK024-identity-colors.check.ts +5 -3
  27. package/src/checks/SK025-release-drift.check.ts +4 -5
  28. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  29. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  30. package/src/checks/SK028-readme.check.ts +5 -1
  31. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  32. package/src/cli/cmd_Asks.ts +5 -5
  33. package/src/cli/cmd_Assets.ts +7 -7
  34. package/src/cli/cmd_Bump.ts +12 -11
  35. package/src/cli/cmd_Check.ts +8 -7
  36. package/src/cli/cmd_Checks.ts +7 -6
  37. package/src/cli/cmd_Ci.ts +12 -11
  38. package/src/cli/cmd_Commands.ts +16 -13
  39. package/src/cli/cmd_ConfigKeys.ts +7 -6
  40. package/src/cli/cmd_Contracts.ts +3 -3
  41. package/src/cli/cmd_CrossRepoActivity.ts +6 -5
  42. package/src/cli/cmd_Fleet.ts +9 -7
  43. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  44. package/src/cli/cmd_Home.ts +23 -17
  45. package/src/cli/cmd_Hooks.ts +6 -5
  46. package/src/cli/cmd_Init.ts +5 -4
  47. package/src/cli/cmd_Keys.ts +8 -7
  48. package/src/cli/cmd_Mv.ts +7 -6
  49. package/src/cli/cmd_Needs.ts +4 -3
  50. package/src/cli/cmd_Overview.ts +4 -3
  51. package/src/cli/cmd_Pb.ts +2 -2
  52. package/src/cli/cmd_Planned.ts +6 -5
  53. package/src/cli/cmd_Prose.ts +5 -5
  54. package/src/cli/cmd_Reflect.ts +7 -7
  55. package/src/cli/cmd_Release.ts +6 -5
  56. package/src/cli/cmd_Releases.ts +5 -4
  57. package/src/cli/cmd_Serve.ts +4 -3
  58. package/src/cli/cmd_Smoke.ts +5 -4
  59. package/src/cli/cmd_Start.ts +7 -6
  60. package/src/cli/cmd_Status.ts +4 -3
  61. package/src/cli/cmd_Tui.ts +3 -2
  62. package/src/cli/cmd_When.ts +71 -0
  63. package/src/cli/cmd_Wiki.ts +4 -4
  64. package/src/cli.ts +7 -2
  65. package/src/collections/checks.ts +2 -0
  66. package/src/collections/collection-types.ts +9 -4
  67. package/src/core/canonical-scripts.ts +1 -1
  68. package/src/core/check-runner.ts +37 -20
  69. package/src/core/config-rewrite.ts +37 -24
  70. package/src/core/discover-checks.ts +23 -2
  71. package/src/core/legacy-shapes.ts +39 -10
  72. package/src/core/one-way-in-policy.ts +2 -2
  73. package/src/core/outcome-words.ts +18 -2
  74. package/src/core/port-check-cli.ts +52 -0
  75. package/src/core/port-check-file.ts +295 -0
  76. package/src/core/port-check-files.ts +31 -0
  77. package/src/core/retention.ts +0 -14
  78. package/src/core/run-check.ts +12 -9
  79. package/src/core/run-ci.ts +8 -4
  80. package/src/core/run-fix.ts +30 -23
  81. package/src/core/run-fixers.ts +42 -5
  82. package/src/core/runs-matrix.ts +179 -0
  83. package/src/core/runs.ts +242 -0
  84. package/src/core/update-policy.ts +20 -0
  85. package/src/core/when-block.ts +29 -30
  86. package/src/core/when.ts +41 -103
  87. package/src/core/wire.ts +10 -14
  88. package/src/fleet/scan-fleet.ts +20 -4
  89. package/src/fleet/update-fleet.ts +6 -6
  90. package/src/home/home-launchd.ts +20 -4
  91. package/src/home/home-routes.ts +12 -1
  92. package/src/home/hubs.ts +5 -5
  93. package/src/home/pm.ts +8 -0
  94. package/src/home/run-pm.ts +1 -0
  95. package/src/home/serve-web.ts +2 -2
  96. package/src/home/supervise.ts +24 -1
  97. package/src/hooks/run-hooks.ts +48 -4
  98. package/src/init-repo.ts +6 -6
  99. package/src/ledger/gate-checks.ts +113 -59
  100. package/src/ledger/git-key.ts +5 -1
  101. package/src/ledger/ledger.ts +5 -5
  102. package/src/ledger/toolchain-identity.ts +45 -27
  103. package/src/plan/repo-source.ts +18 -39
  104. package/src/release/run-release.ts +5 -0
  105. package/src/repo-config.ts +30 -55
  106. package/src/rules/rule.ts +2 -2
  107. package/src/scaffold/files/agent-coding.md +1 -1
  108. package/src/scaffold/files/agent-index.md +1 -1
  109. package/src/scaffold/files/verify-skill.md +2 -2
  110. package/src/serve/hub-app/activity-st.ts +5 -4
  111. package/src/serve/hub-app/activity-view.tsx +1 -1
  112. package/src/serve/hub-app/admin-view.tsx +13 -11
  113. package/src/serve/hub-app/check-view.tsx +66 -46
  114. package/src/serve/hub-app/check-words.ts +1 -11
  115. package/src/serve/hub-app/checks-svc.ts +14 -35
  116. package/src/serve/hub-app/col-tables-view.tsx +13 -6
  117. package/src/serve/hub-app/cols-svc.ts +5 -4
  118. package/src/serve/hub-app/dock-st.ts +3 -2
  119. package/src/serve/hub-app/dock-view.tsx +36 -0
  120. package/src/serve/hub-app/dock.css +6 -0
  121. package/src/serve/hub-app/float-cards.tsx +30 -0
  122. package/src/serve/hub-app/floating.tsx +41 -4
  123. package/src/serve/hub-app/git-st.ts +42 -0
  124. package/src/serve/hub-app/git-view.tsx +61 -0
  125. package/src/serve/hub-app/hash-route.ts +45 -1
  126. package/src/serve/hub-app/header-bar.tsx +62 -72
  127. package/src/serve/hub-app/health-svc.ts +3 -2
  128. package/src/serve/hub-app/home-st.ts +14 -2
  129. package/src/serve/hub-app/home-strip.css +43 -8
  130. package/src/serve/hub-app/home-strip.tsx +111 -47
  131. package/src/serve/hub-app/home.html +1 -0
  132. package/src/serve/hub-app/hub-app.tsx +14 -4
  133. package/src/serve/hub-app/hub-types.ts +5 -2
  134. package/src/serve/hub-app/hub.css +65 -43
  135. package/src/serve/hub-app/icons.tsx +11 -0
  136. package/src/serve/hub-app/index.html +1 -0
  137. package/src/serve/hub-app/keys.ts +5 -2
  138. package/src/serve/hub-app/live-svc.ts +3 -2
  139. package/src/serve/hub-app/machine-st.ts +18 -22
  140. package/src/serve/hub-app/plan-view.tsx +51 -0
  141. package/src/serve/hub-app/planned-st.ts +9 -4
  142. package/src/serve/hub-app/processes-view.tsx +38 -0
  143. package/src/serve/hub-app/projects-svc.ts +15 -10
  144. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  145. package/src/serve/hub-app/reach.ts +36 -0
  146. package/src/serve/hub-app/readme-svc.ts +3 -2
  147. package/src/serve/hub-app/repo-sections.ts +117 -0
  148. package/src/serve/hub-app/repo-st.ts +46 -9
  149. package/src/serve/hub-app/sidebar.tsx +12 -37
  150. package/src/serve/hub-app/timeline-svc.ts +3 -2
  151. package/src/serve/hub-app/tree-build.ts +149 -155
  152. package/src/serve/hub-app/tree-row.tsx +39 -9
  153. package/src/serve/hub-app/tree-svc.ts +70 -49
  154. package/src/serve/hub-app/when-view.tsx +89 -0
  155. package/src/serve/hub-app/when.css +17 -0
  156. package/src/serve/hub-runtime.ts +3 -16
  157. package/src/serve/run-serve.ts +1 -1
  158. package/src/serve/serve-hub.ts +4 -14
  159. package/src/serve/hub-app/processes-svc.ts +0 -63
  160. package/src/serve/hub-app/repo-sections.tsx +0 -61
  161. 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
  }
@@ -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
@@ -171,21 +153,25 @@ export type RepoConfig = {
171
153
  * folder only: it never commits, untracks, tags or pushes on the parent's
172
154
  * history. Rule SK015 fails when this and the disk disagree (core/nesting.ts) */
173
155
  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). */
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 */
181
167
  when?: Record<string, string>
182
168
  /** WHAT A HEALTHY RUN OF EACH CHECK TAKES, in ms, keyed by check id. A
183
169
  * check that passes over its budget prints a WARN and reads yellow, and
184
170
  * never blocks. It wins over the check's own `budgetMs` and the default
185
171
  * for its kind (core/budget.ts). An id no check carries fails the run,
186
- * the same as a `when` entry. */
172
+ * the same as an `off` entry. */
187
173
  budgets?: Record<string, number>
188
- /** 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
189
175
  * this repo breaks that invariant on purpose. An exempted rule does not
190
176
  * lint and prints a skip line carrying the reason. `defineRepo` throws on
191
177
  * an unknown id or a blank reason */
@@ -215,7 +201,7 @@ export type RepoConfig = {
215
201
  /** widens the read-only tool allowlist SK008 enforces (default Read, Grep, Glob, Bash) */
216
202
  tools?: string[]
217
203
  }
218
- /** 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
219
205
  * repo keeps as a direct tool call. Declared per NAME, never as a list of
220
206
  * banned binaries: the invariant is about which VERBS an agent reaches
221
207
  * for, and a binary blocklist goes stale the day a repo changes runner.
@@ -300,18 +286,7 @@ export type RepoConfig = {
300
286
  logDir?: string
301
287
  }
302
288
 
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
- }
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'])
315
290
 
316
291
  export function defineRepo(c: RepoConfig): RepoConfig {
317
292
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -321,13 +296,8 @@ export function defineRepo(c: RepoConfig): RepoConfig {
321
296
  // a half-declared reviewer would lint a path like `.claude/agents/.md`
322
297
  if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
323
298
  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)
330
- }
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({...})\``)
331
301
  // unknown keys die loud — a typo'd or removed key silently changing the
332
302
  // gate is exactly the never-silence failure mode (A23)
333
303
  for (const k of Object.keys(c))
@@ -384,17 +354,22 @@ export function defineRepo(c: RepoConfig): RepoConfig {
384
354
  // repo rules are validated against the built-ins, so a bad id, a duplicate,
385
355
  // or an SK squatter dies at config load, not silently mid-check
386
356
  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)
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
392
368
  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
369
  const w = parseWhen(raw)
395
370
  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\``)
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>' }`)
398
373
  }
399
374
  // a budget turns a slow pass yellow, so a value it cannot compare with dies here
400
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>
@@ -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
  })