rvlib-shipkit 0.38.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -4,10 +4,14 @@
4
4
  // gitignored territory); a repo without node_modules gets NO pings rather
5
5
  // than re-fired backlogs (no persistence = no dedup = stay silent). For
6
6
  // ask-backed items the marker is the ask file's own `pinged:` field — the
7
- // SAME once-ever guarantee every other ping path uses.
7
+ // SAME once-ever guarantee every other ping path uses. A hub in a linked
8
+ // worktree is a side checkout (a test hub, a branch under review): it never
9
+ // notifies and never marks, so the phone and the markers belong to the main
10
+ // checkout's hub alone.
8
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
9
12
  import { join } from 'path'
10
13
  import { markPinged } from '../asks/asks-queue.ts'
14
+ import { isLinkedWorktree } from '../core/git-root.ts'
11
15
  import type { NeedItem } from './collection-types.ts'
12
16
 
13
17
  export type PingIo = {
@@ -24,13 +28,33 @@ export type PingIo = {
24
28
 
25
29
  const stateDir = (root: string): string | null => (existsSync(join(root, 'node_modules')) ? join(root, 'node_modules', '.cache', 'shipkit') : null)
26
30
 
27
- const loadPinged = (root: string): Set<string> | null => {
31
+ /** the state file's shape. It is bumped when the IDS a surface emits move,
32
+ * because dedup marks are worthless under a name nothing emits any more:
33
+ * v1 was a bare array, written while a repo health row was one wire line of
34
+ * STATUS.md; v2 arrived with one resource per gate check. */
35
+ const STATE_VERSION = 2
36
+
37
+ /** `adopt` = the marks in hand cannot be compared to the ids in front of us,
38
+ * so this tick MARKS the machine-owned reds and notifies about none of them.
39
+ * The alternative is the whole backlog firing again, at once, on his phone. */
40
+ type PingState = { ids: Set<string>; adopt: boolean }
41
+
42
+ const loadPinged = (root: string): PingState | null => {
28
43
  const dir = stateDir(root)
29
44
  if (dir == null) return null
45
+ const file = join(dir, 'pinged.json')
46
+ // no file at all = a repo that never pinged: there is no backlog to
47
+ // protect, and its first tick is the normal first tick
48
+ if (!existsSync(file)) return { ids: new Set(), adopt: false }
30
49
  try {
31
- return new Set(JSON.parse(readFileSync(join(dir, 'pinged.json'), 'utf8')) as string[])
50
+ const raw = JSON.parse(readFileSync(file, 'utf8')) as unknown
51
+ if (Array.isArray(raw)) return { ids: new Set(raw as string[]), adopt: true }
52
+ const s = raw as { v?: number; ids?: string[] }
53
+ return { ids: new Set(s.ids ?? []), adopt: s.v !== STATE_VERSION }
32
54
  } catch {
33
- return new Set()
55
+ // unreadable marks say nothing about what he has already seen, and
56
+ // the safe direction is silence, never a burst
57
+ return { ids: new Set(), adopt: true }
34
58
  }
35
59
  }
36
60
 
@@ -38,28 +62,30 @@ const savePinged = (root: string, ids: Set<string>): void => {
38
62
  const dir = stateDir(root)
39
63
  if (dir == null) return
40
64
  mkdirSync(dir, { recursive: true })
41
- writeFileSync(join(dir, 'pinged.json'), JSON.stringify([...ids].sort()))
65
+ writeFileSync(join(dir, 'pinged.json'), JSON.stringify({ v: STATE_VERSION, ids: [...ids].sort() }))
42
66
  }
43
67
 
44
68
  /** one tick: ping ONCE for the batch of never-pinged red items, mark them.
45
69
  * Returns the pinged ids (empty = silence). */
46
70
  export function pingTick(root: string, asksDir: string, needs: NeedItem[], io: PingIo): string[] {
47
- const pinged = loadPinged(root)
48
- if (pinged == null) return [] // no persistence, no pings — never re-fire a backlog
71
+ if (isLinkedWorktree(root)) return []
72
+ const state = loadPinged(root)
73
+ if (state == null) return [] // no persistence, no pings — never re-fire a backlog
49
74
  const fresh: NeedItem[] = []
50
75
  for (const n of needs) {
51
76
  if (n.severity !== 'red') continue
52
77
  if (n.askOwned === true) continue // the covering ask's pinged: field already fired the once-ever ping
53
78
  if (n.collection === 'asks') {
54
- // ask file owns the once-ever marker (shared with agent pings)
79
+ // an ask's marker lives in the ask FILE and survives any id move,
80
+ // so it is never adopted: a new ask still fires on a migrating tick
55
81
  const file = n.id.replace(/^ask:/, '')
56
82
  if (markPinged(join(root, asksDir), file)) fresh.push(n)
57
- } else if (!pinged.has(n.id)) {
58
- pinged.add(n.id)
59
- fresh.push(n)
83
+ } else if (!state.ids.has(n.id)) {
84
+ state.ids.add(n.id)
85
+ if (!state.adopt) fresh.push(n)
60
86
  }
61
87
  }
62
- savePinged(root, pinged)
88
+ savePinged(root, state.ids)
63
89
  if (fresh.length === 0) return []
64
90
  const head = fresh.slice(0, 3).map((n) => n.text.slice(0, 80)).join(' · ')
65
91
  const message = `${fresh.length} new item(s) need you: ${head}${fresh.length > 3 ? ' · …' : ''}${io.link != null ? ` → ${io.link}` : ''}`
@@ -1,4 +1,4 @@
1
- // social-posts collection — projects `.rv-social/<channel>/` post files
1
+ // social-posts collection — projects `.shipkit/social/<channel>/` post files
2
2
  // (contract v2) into the needs feed. Status-aware: rejected/posted history
3
3
  // stays on disk as training data and never floods the feed. Drafts-as-rolls:
4
4
  // variants are sibling files `<slug>.a.md`, `<slug>.b.md`; picking one flips
@@ -43,7 +43,7 @@ export function parsePostFile(dir: string, channel: string, file: string): PostF
43
43
  }
44
44
 
45
45
  export function listPosts(root: string): PostFile[] {
46
- const social = join(root, '.rv-social')
46
+ const social = join(root, '.shipkit/social')
47
47
  if (!existsSync(social)) return []
48
48
  const out: PostFile[] = []
49
49
  for (const ch of readdirSync(social)) {
@@ -71,12 +71,17 @@ export function socialPostsCollection(root: string): Collection {
71
71
  return lines.length > 0 ? lines.slice(-3) : undefined
72
72
  }
73
73
  for (const [key, variants] of bySlug) {
74
- const fileOf = (v: PostFile): string => `.rv-social/${v.channel}/${v.file}`
74
+ const fileOf = (v: PostFile): string => `.shipkit/social/${v.channel}/${v.file}`
75
+ // the row is the post, the venue is the slot beside it
76
+ const place = { label: key.split('/').slice(1).join('/'), slot: variants[0]!.channel }
75
77
  const missing = variants.filter((v) => v.status == null)
76
78
  if (missing.length > 0) {
77
79
  out.push({
78
80
  id: `post:${key}:invalid`,
81
+ ...place,
79
82
  text: `${key} — missing/invalid status: frontmatter (${missing.map((v) => v.file).join(', ')})`,
83
+ type: 'post',
84
+ path: fileOf(missing[0]!),
80
85
  severity: 'red',
81
86
  file: fileOf(missing[0]!),
82
87
  })
@@ -86,7 +91,10 @@ export function socialPostsCollection(root: string): Collection {
86
91
  if (picked != null) {
87
92
  out.push({
88
93
  id: `post:${key}:topost`,
94
+ ...place,
89
95
  text: `${key} — picked, waiting to be POSTED (copy → post → paste the URL)`,
96
+ type: 'post',
97
+ path: fileOf(picked),
90
98
  severity: 'red',
91
99
  artifact: { copy: postSection(picked.body) },
92
100
  verbs: ['paste-proof', 'rate'],
@@ -100,7 +108,10 @@ export function socialPostsCollection(root: string): Collection {
100
108
  if (drafts.length > 0) {
101
109
  out.push({
102
110
  id: `post:${key}:pick`,
111
+ ...place,
103
112
  text: `${key} — ${drafts.length} draft(s), pick one`,
113
+ type: 'post',
114
+ path: fileOf(drafts[0]!),
104
115
  severity: 'yellow',
105
116
  verbs: ['pick', 'rate'],
106
117
  candidates: drafts.map((v) => ({ id: v.file, label: v.variant ?? 'draft', hint: postSection(v.body).slice(0, 200) })),
@@ -114,7 +125,12 @@ export function socialPostsCollection(root: string): Collection {
114
125
  const shown = posted ?? variants[0]!
115
126
  out.push({
116
127
  id: `post:${key}:${posted != null ? 'posted' : 'rejected'}`,
128
+ ...place,
117
129
  text: `${key} — ${posted != null ? `posted${posted.posted != null ? ` (${posted.posted})` : ''}` : 'rejected'}`,
130
+ type: 'post',
131
+ path: fileOf(shown),
132
+ // posted and rejected are history: visible, never loud
133
+ state: 'quiet',
118
134
  file: fileOf(shown),
119
135
  remarks: feedback(shown),
120
136
  })
@@ -5,9 +5,9 @@
5
5
  // asks READ path never minting asks/ on a repo without one. Dedup doctrine
6
6
  // : dedup = acted-on once, NEVER hidden — an
7
7
  // ask-covered missing cred still shows RED in secrets with a pointer to the
8
- // ask; verbs and pings stay with the ask. Each wire line of STATUS.md lives
9
- // in exactly ONE collection: cred probe lines in secrets, everything else
10
- // in repo health.
8
+ // ask; verbs and pings stay with the ask. No provider reads STATUS.md: a
9
+ // verdict comes from the run record, which gate runs and health passes both
10
+ // write, through the checks collection (collections/checks.ts).
11
11
  import { existsSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { isStale } from '../assets/gen/derived.ts'
@@ -19,13 +19,19 @@ import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
19
19
  import type { healIdentity } from '../assets/run-assets.ts'
20
20
  import { doneAsks, openAsks } from '../asks/asks-queue.ts'
21
21
  import { evaluateContracts } from '../contracts/contracts-engine.ts'
22
- import { parseWireOutput } from '../core/wire.ts'
22
+ import { CheckCtx } from '../core/check-ctx.ts'
23
23
  import type { RepoConfig } from '../repo-config.ts'
24
24
  import { activeContracts, contractsConfigFrom } from '../contracts/apply-contracts.ts'
25
- import type { Collection, Instance } from './collection-types.ts'
25
+ import type { CheckFacts, Collection, Instance } from './collection-types.ts'
26
+ import { mdBody } from './md-body.ts'
26
27
  import { socialPostsCollection } from './posts.ts'
28
+ import { assetState, secretState, type SecretProbe } from './state-rules.ts'
29
+ import { reflectionsCollection } from './reflections.ts'
30
+ import { testsCollection } from './tests.ts'
31
+ import { checksCollection } from './checks.ts'
32
+ import { LAYOUT } from '../core/layout.ts'
27
33
 
28
- const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? 'asks')
34
+ const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? LAYOUT.asks)
29
35
  // read path must not mint an asks/ dir (listAsks mkdirs on read)
30
36
  const openAsksSafe = (root: string, c: RepoConfig): ReturnType<typeof openAsks> => {
31
37
  const dir = askDirOf(root, c)
@@ -34,21 +40,81 @@ const openAsksSafe = (root: string, c: RepoConfig): ReturnType<typeof openAsks>
34
40
 
35
41
  const CRED_LINE = /cred ([a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+) missing/
36
42
 
37
- /** STATUS.md's check output, regrouped: one item per ok/WARN/FAIL wire line
38
- * with its indented fix: pointers riding along. The writer's markdown
39
- * wrapper (comment, title, verdict, fences) parses as notes and drops out. */
40
- type WireItem = { kind: 'ok' | 'warn' | 'fail'; text: string; fixes: string[] }
41
- const statusWire = (root: string): WireItem[] => {
42
- const p = join(root, 'STATUS.md')
43
- if (!existsSync(p)) return []
44
- const items: WireItem[] = []
45
- for (const l of parseWireOutput(readFileSync(p, 'utf8'))) {
46
- if (l.kind === 'ok' || l.kind === 'warn' || l.kind === 'fail') items.push({ kind: l.kind, text: l.text, fixes: [] })
47
- else if ((l.kind === 'fix-warn' || l.kind === 'fix-fail') && items.length > 0) items[items.length - 1]!.fixes.push(l.text)
43
+ /** the secret name as a WHOLE token in a wire line: `rv/npm/token` must not
44
+ * read itself in a line about `rv/npm/token-2` */
45
+ const namesSecret = (text: string, name: string): boolean =>
46
+ new RegExp(`(^|[^\\w/-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w/-])`).test(text)
47
+
48
+ /** what a secret row reads off one check: the last recorded verdict and the
49
+ * lines that run printed, the fields of the checks collection's own facts */
50
+ export type CheckVerdict = Pick<CheckFacts, 'checkId' | 'status' | 'lastLines' | 'lastAt' | 'measuredAt' | 'keepsUntil' | 'ttl'>
51
+
52
+ /** a check's printed lines, one item per WARN/FAIL/skip line with its
53
+ * indented fix: pointers riding along */
54
+ type PrintedLine = { kind: 'fail' | 'warn' | 'skip' | 'other'; text: string; fixes: string[] }
55
+ const printed = (lines: string[]): PrintedLine[] => {
56
+ const out: PrintedLine[] = []
57
+ for (const raw of lines) {
58
+ const fix = raw.match(/^\s+fix: (.*)$/)
59
+ if (fix != null) { out.at(-1)?.fixes.push(fix[1]!); continue }
60
+ const m = raw.match(/^(FAIL: |WARN |skip: )(.*)$/)
61
+ const kind = m == null ? 'other' : m[1] === 'FAIL: ' ? 'fail' : m[1] === 'WARN ' ? 'warn' : 'skip'
62
+ out.push({ kind, text: m == null ? raw.replace(/^ok: /, '') : m[2]!, fixes: [] })
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** every secret some check proved MISSING, with the line that said so. A
68
+ * check that cannot block has its FAIL demoted to a WARN on the wire, so a
69
+ * WARN counts too when the run record says the check itself failed */
70
+ const missingFrom = (verdicts: CheckVerdict[]): Map<string, PrintedLine> => {
71
+ const out = new Map<string, PrintedLine>()
72
+ for (const v of verdicts)
73
+ for (const l of printed(v.lastLines)) {
74
+ if (!(l.kind === 'fail' || (l.kind === 'warn' && v.status === 'fail'))) continue
75
+ const name = l.text.match(CRED_LINE)?.[1]
76
+ if (name != null && !out.has(name)) out.set(name, l)
77
+ }
78
+ return out
79
+ }
80
+
81
+ /** `just now`, `4m ago`, `3h ago`, `2d ago` */
82
+ const ago = (ms: number): string => {
83
+ const m = Math.max(0, Math.round(ms / 60_000))
84
+ if (m === 0) return 'just now'
85
+ return `${m < 60 ? `${m}m` : m < 60 * 48 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`} ago`
86
+ }
87
+
88
+ /** what the probing check's last recorded run says about one present secret,
89
+ * and the plain words the row carries for it */
90
+ export function probeOf(name: string, probedBy: string | undefined, verdicts: Map<string, CheckVerdict>, now: number): { probe: SecretProbe; words: string; md?: string } {
91
+ if (probedBy == null) return { probe: 'none', words: 'no check probes this secret' }
92
+ const v = verdicts.get(probedBy)
93
+ if (v == null) return { probe: 'warn', words: `probed by ${probedBy}, which is not a check of this repo` }
94
+ if (v.status == null) return { probe: 'unmeasured', words: `its probe ${probedBy} never ran here` }
95
+ const lines = printed(v.lastLines)
96
+ const md = v.lastLines.length > 0 ? wireMd(v.lastLines.map((l) => l.trim())) : undefined
97
+ if (v.status === 'skip') return { probe: 'unmeasured', words: `its probe ${probedBy} was skipped`, md }
98
+ const unmeasured = lines.find((l) => l.kind === 'skip' && namesSecret(l.text, name))
99
+ if (unmeasured != null) return { probe: 'unmeasured', words: `${probedBy} did not check it: ${unmeasured.text}`, md }
100
+ if (v.status === 'fail') {
101
+ const why = lines.find((l) => l.kind === 'fail') ?? lines.find((l) => l.kind === 'warn')
102
+ return { probe: 'fail', words: `its probe ${probedBy} failed${why != null ? `: ${why.text}` : ''}`, md }
48
103
  }
49
- return items
104
+ if (v.status === 'warn') {
105
+ const why = lines.find((l) => l.kind === 'warn')
106
+ return { probe: 'warn', words: `its probe ${probedBy} warned${why != null ? `: ${why.text}` : ''}`, md }
107
+ }
108
+ const at = v.measuredAt ?? v.lastAt ?? now
109
+ if (v.keepsUntil != null && now > v.keepsUntil)
110
+ return { probe: 'stale', words: `last checked ${ago(now - at)} by ${probedBy}, older than its ${v.ttl} ttl`, md }
111
+ return { probe: 'pass', words: `checked ${ago(now - at)} by ${probedBy}`, md }
50
112
  }
51
113
 
114
+ /** a wire line as ONE path segment. The line text is the instance id too,
115
+ * so equal slugs mean equal lines and nothing distinct collides */
116
+ const slug = (t: string): string => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 72)
117
+
52
118
  /** wire lines re-rendered as a fenced block for an instance's md */
53
119
  const wireMd = (lines: string[]): string => `\`\`\`\n${lines.join('\n')}\n\`\`\``
54
120
 
@@ -58,14 +124,20 @@ export function asksCollection(root: string, c: RepoConfig): Collection {
58
124
  title: 'asks',
59
125
  media: 'text',
60
126
  list: async () => {
61
- const rel = c.asks?.dir ?? 'asks'
127
+ const rel = c.asks?.dir ?? LAYOUT.asks
62
128
  const open = openAsksSafe(root, c).map((a): Instance => ({
63
129
  id: `ask:${a.file}`,
64
130
  text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}`,
131
+ // the tree carries the title and puts the kind in its own
132
+ // slot, so a column of asks reads as a column of kinds
133
+ label: a.title,
134
+ slot: a.kind !== '' ? a.kind : undefined,
135
+ type: 'ask' as const,
136
+ path: `${rel}/${a.file}`,
65
137
  severity: 'red' as const,
66
138
  verbs: ['done' as const, 'paste-proof' as const],
67
139
  file: `${rel}/${a.file}`,
68
- md: askMd(join(root, rel, a.file)),
140
+ md: mdBody(join(root, rel, a.file)),
69
141
  remarks: lastRemarks(join(root, rel, a.file)),
70
142
  }))
71
143
  // the done/ archive rides along QUIET (no severity) — past answers
@@ -73,8 +145,13 @@ export function asksCollection(root: string, c: RepoConfig): Collection {
73
145
  const done = doneAsks(askDirOf(root, c)).map((a): Instance => ({
74
146
  id: `ask:done/${a.file}`,
75
147
  text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}`,
148
+ label: a.title,
149
+ slot: 'done',
150
+ type: 'ask' as const,
151
+ path: `${rel}/done/${a.file}`,
152
+ state: 'quiet' as const,
76
153
  file: `${rel}/done/${a.file}`,
77
- md: askMd(join(root, rel, 'done', a.file)),
154
+ md: mdBody(join(root, rel, 'done', a.file)),
78
155
  remarks: lastRemarks(join(root, rel, 'done', a.file)),
79
156
  }))
80
157
  return [...open, ...done]
@@ -82,19 +159,6 @@ export function asksCollection(root: string, c: RepoConfig): Collection {
82
159
  }
83
160
  }
84
161
 
85
- // a card, not a document dump — the cut is loud (…) so a clamped card never
86
- // passes for the whole truth file
87
- const MD_CAP = 4000
88
-
89
- /** the ask file's markdown body: frontmatter stripped, trimmed, capped */
90
- const askMd = (path: string): string | undefined => {
91
- if (!existsSync(path)) return undefined
92
- const body = readFileSync(path, 'utf8').replace(/^---\n[\s\S]*?\n---\n?/, '').trim()
93
- if (body === '') return undefined
94
- // code-point slice — a UTF-16 cut can strand half a surrogate pair
95
- return body.length > MD_CAP ? `${[...body].slice(0, MD_CAP).join('')}…` : body
96
- }
97
-
98
162
  /** the latest dated remark lines from a file's tail, the boot briefing */
99
163
  const lastRemarks = (path: string, max = 3): string[] | undefined => {
100
164
  if (!existsSync(path)) return undefined
@@ -102,55 +166,73 @@ const lastRemarks = (path: string, max = 3): string[] | undefined => {
102
166
  return lines.length > 0 ? lines.slice(-max) : undefined
103
167
  }
104
168
 
105
- /** repo-declared secrets (repo.config `creds:`) + cred FAIL lines from
106
- * STATUS.md — the tile carries the mint artifact as data. EVERY declared
169
+ export type CredsOpts = {
170
+ /** injected by tests; production reads the checks collection, which is
171
+ * the run record every gate run and health pass writes */
172
+ verdicts?: () => Promise<CheckVerdict[]>
173
+ now?: () => number
174
+ }
175
+
176
+ /** the verdicts a secret row reads, off the checks collection's own rows, so
177
+ * a secret and its probing check can never disagree */
178
+ export const verdictsOf = async (checks: Collection): Promise<CheckVerdict[]> =>
179
+ (await checks.list()).flatMap((i) => (i.check == null ? [] : [i.check]))
180
+
181
+ /** a declared service, in parentheses after the name */
182
+ const named = (name: string, service: string | undefined): string => (service != null ? `${name} (${service})` : name)
183
+
184
+ /** repo-declared secrets (repo.config `creds:`) plus every secret a check
185
+ * proved missing. The tile carries the mint artifact as data. EVERY declared
107
186
  * cred is listed. Missing + unclaimed = red with the set verb + artifact;
108
- * missing + ask-covered = STILL red (never hidden), no verbs — the text
109
- * points at the ask that owns the action. The probe FAIL line (+ its fix:
110
- * pointers) rides the instance's md, never a health line.
111
- * Declared-and-present creds show QUIET, the vault is consultable. */
112
- export function credsCollection(root: string, c: RepoConfig): Collection {
187
+ * missing + ask-covered = STILL red (never hidden), no verbs: the text
188
+ * points at the ask that owns the action. The line that proved it missing
189
+ * (+ its fix: pointers) rides the instance's md, never a health line.
190
+ * A present secret wears the verdict of the check that probes it
191
+ * (`probedBy`), read from the run record (state-rules.ts secretState) */
192
+ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}): Collection {
113
193
  return {
114
194
  id: 'creds',
115
195
  title: 'secrets',
116
196
  media: 'pointer',
117
197
  list: async () => {
118
- const rel = c.asks?.dir ?? 'asks'
198
+ const rel = c.asks?.dir ?? LAYOUT.asks
119
199
  const asks = openAsksSafe(root, c)
120
200
  const coveringAsk = (name: string): { file: string } | undefined =>
121
201
  asks.find((a) => a.title.toLowerCase().includes(name.split('/')[1] ?? name))
122
- // only a FAIL probe proves missing — a WARN/narrative mention never reds
123
- const probes = new Map<string, WireItem>()
124
- for (const i of statusWire(root)) {
125
- if (i.kind !== 'fail') continue
126
- const name = i.text.match(CRED_LINE)?.[1]
127
- if (name != null && !probes.has(name)) probes.set(name, i)
128
- }
202
+ const verdicts = await (o.verdicts ?? (() => verdictsOf(checksCollection(root, c))))()
203
+ const now = (o.now ?? Date.now)()
204
+ const missing = missingFrom(verdicts)
205
+ const byId = new Map(verdicts.map((v) => [v.checkId, v]))
129
206
  const out: Instance[] = []
130
- for (const [name, probe] of probes) {
207
+ for (const [name, line] of missing) {
131
208
  const decl = (c.creds ?? []).find((d) => d.name === name)
132
- const md = wireMd([`FAIL: ${probe.text}`, ...probe.fixes.map((f) => `fix: ${f}`)])
209
+ const md = wireMd([`FAIL: ${line.text}`, ...line.fixes.map((f) => `fix: ${f}`)])
133
210
  const ask = coveringAsk(name)
134
211
  if (ask != null)
135
212
  // dedup = acted-on once, never hidden: the ask keeps the
136
213
  // verbs and the ping; the cred still shows RED here
137
214
  out.push({
138
215
  id: `cred:${name}`,
139
- text: `${name} missing${decl?.service != null ? ` — ${decl.service}` : ''} — ask ${rel}/${ask.file} owns this`,
216
+ text: `${named(name, decl?.service)} missing, ask ${rel}/${ask.file} owns this`,
217
+ ...credPlace(name, true),
140
218
  severity: 'red',
141
219
  md,
142
220
  askOwned: true,
143
221
  })
144
222
  else out.push({ ...credInstance(name, c.credsCommand, decl), md })
145
223
  }
146
- // every declared cred not proven missing is a quiet instance —
147
- // present secrets carry no severity and no copy command (nothing
148
- // to run), just the mint pointer when one is declared
224
+ // every declared cred not proven missing is present: no copy
225
+ // command (nothing to run), the mint pointer when one is declared,
226
+ // and the colour of what its probe last proved
149
227
  for (const d of c.creds ?? []) {
150
- if (probes.has(d.name)) continue
228
+ if (missing.has(d.name)) continue
229
+ const p = probeOf(d.name, d.probedBy, byId, now)
151
230
  out.push({
152
231
  id: `cred:${d.name}`,
153
- text: `${d.name}${d.service != null ? ` — ${d.service}` : ''}`,
232
+ text: `${named(d.name, d.service)} present, ${p.words}`,
233
+ ...credPlace(d.name, false),
234
+ ...secretState({ missing: false, probe: p.probe }),
235
+ md: p.md,
154
236
  artifact: d.mintUrl != null ? { url: d.mintUrl } : undefined,
155
237
  })
156
238
  }
@@ -161,9 +243,16 @@ export function credsCollection(root: string, c: RepoConfig): Collection {
161
243
 
162
244
  // the store command is repo config DATA (`credsCommand` template) — the lib
163
245
  // never names any particular secret manager
246
+ // a secret has no file: the name is already slash shaped, so a virtual root
247
+ // groups it by vendor for free
248
+ const credPlace = (name: string, missing: boolean): Pick<Instance, 'type' | 'path' | 'virtual' | 'label' | 'slot'> =>
249
+ // the tree row is the NAME, and present or missing is the slot beside it
250
+ ({ type: 'secret', path: `secrets/${name}`, virtual: true, label: name, slot: missing ? 'missing' : 'present' })
251
+
164
252
  const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { service?: string; mintUrl?: string; steps?: string[] }): Instance => ({
165
253
  id: `cred:${name}`,
166
- text: `${name} missing${decl?.service != null ? ` — ${decl.service}` : ''}`,
254
+ text: `${named(name, decl?.service)} missing`,
255
+ ...credPlace(name, true),
167
256
  severity: 'red',
168
257
  artifact: { copy: cmdTemplate?.replaceAll('{name}', name), url: decl?.mintUrl, steps: decl?.steps },
169
258
  verbs: ['set'],
@@ -185,7 +274,7 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
185
274
  const idn = await heal(root)
186
275
  const out: Instance[] = []
187
276
  if (idn.state === 'blocked')
188
- out.push({ id: 'media:identity', text: `identity — ${idn.detail ?? 'blocked'}`, severity: 'red', href: '/#identity-logo', verbs: ['pick'] })
277
+ out.push({ id: 'media:identity', text: `identity — ${idn.detail ?? 'blocked'}`, type: 'asset', path: 'assets/identity', virtual: true, severity: 'red', href: '/#identity-logo', verbs: ['pick'] })
189
278
  if (kit == null) return out
190
279
  const notes = assetNotes(root)
191
280
  for (const f of allAssets(kit)) {
@@ -195,32 +284,45 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
195
284
  const base: Instance = {
196
285
  id: `media:${f.asset.id}`,
197
286
  text: f.asset.id,
287
+ // the roll count is the one fact a row of assets is read for
288
+ slot: rolls.length > 0 ? `${rolls.length} roll${rolls.length === 1 ? '' : 's'}` : manual ? 'manual' : undefined,
289
+ type: 'asset',
290
+ // the roll workspace dir, which is where this asset's
291
+ // bytes actually live: on the kit file they all collide
292
+ // on one node
293
+ path: `${kit.workspace}/${f.asset.id}`,
198
294
  href: `/#${f.asset.id}`,
199
295
  // image kinds only — /api/thumb serves raw roll bytes, and
200
296
  // an mp3 inside an <img> is a broken glyph on every tile
201
297
  img: rolls.length > 0 && mediaOf(f.kind) === 'image' ? `/media/api/thumb?id=${encodeURIComponent(f.asset.id)}` : undefined,
202
- file: '.rv-assets/kit.ts',
298
+ file: '.shipkit/assets/kit.ts',
203
299
  remarks: notes.get(f.asset.id),
204
300
  }
205
- if (manual && rolls.length === 0)
301
+ const picked = rolls.length > 0 && (await readPick(dir)).pick != null
302
+ const stale = picked && (await isStale(kit, join(root, kit.workspace), f))
303
+ // the colour comes from the ONE asset rule, the words and the
304
+ // verbs from the case that produced it
305
+ const colour = assetState({ manual, rolls: rolls.length, picked, stale })
306
+ const said: Partial<Instance> = manual && rolls.length === 0
206
307
  // the brief IS the paste-ready artifact ("I don't even
207
308
  // know what to paste" — the tile hands him the prompt)
208
- out.push({ ...base, text: `${f.asset.id} — manual, nothing uploaded`, severity: 'red', verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined })
209
- else if (rolls.length > 0 && (await readPick(dir)).pick == null)
210
- out.push({ ...base, text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, severity: 'yellow', verbs: ['pick'] })
211
- else if (await isStale(kit, join(root, kit.workspace), f))
212
- out.push({ ...base, text: `${f.asset.id} — stale (a parent was re-picked)`, severity: 'yellow', verbs: ['pick'] })
213
- else out.push(base) // healthy — visible, dim, never in needs
309
+ ? { text: `${f.asset.id} — manual, nothing uploaded`, label: f.asset.id, verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined }
310
+ : rolls.length > 0 && !picked
311
+ ? { text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, label: f.asset.id, verbs: ['pick'] }
312
+ : stale
313
+ ? { text: `${f.asset.id} — stale (a parent was re-picked)`, label: f.asset.id, verbs: ['pick'] }
314
+ : {}
315
+ out.push({ ...base, ...said, ...colour })
214
316
  }
215
317
  return out
216
318
  },
217
319
  }
218
320
  }
219
321
 
220
- /** `.rv-assets/notes.md`, dated asset remarks in one git-tracked file:
322
+ /** `.shipkit/assets/notes.md`, dated asset remarks in one git-tracked file:
221
323
  * `- YYYY-MM-DD [asset-id]: text` */
222
324
  const assetNotes = (root: string): Map<string, string[]> => {
223
- const p = join(root, '.rv-assets', 'notes.md')
325
+ const p = join(root, '.shipkit/assets', 'notes.md')
224
326
  const map = new Map<string, string[]>()
225
327
  if (!existsSync(p)) return map
226
328
  for (const m of readFileSync(p, 'utf8').matchAll(/^- (\d{4}-\d{2}-\d{2}) \[([^\]]+)\]: (.+)$/gm)) {
@@ -230,12 +332,13 @@ const assetNotes = (root: string): Map<string, string[]> => {
230
332
  return map
231
333
  }
232
334
 
233
- /** repo health — NON-curatable feed items: EVERY wire line of STATUS.md's
234
- * check output (ok = quiet, WARN = yellow, FAIL = red; the passing lines
235
- * ARE listed — the page shows the whole check run), plus contract
236
- * FAIL/WARN and hub process staleness. fix: pointers ride the owning
237
- * instance's md as a code block. Cred probe FAIL lines (+ their fixes) are
238
- * routed to the secrets collection, never duplicated here. */
335
+ /** repo health — what belongs to no check and no other collection: contract
336
+ * FAIL/WARN lines and hub process staleness. Every check, rule and task of
337
+ * the gate is its own resource now (collections/checks.ts), so re-parsing
338
+ * STATUS.md into one row per wire line would state the same verdicts a
339
+ * second time, under ids that are whole sentences, and the two would
340
+ * disagree the moment one run narrowed. Cred probe FAIL lines (+ their
341
+ * fixes) stay routed to the secrets collection. */
239
342
  export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVersion?: string; libVersion?: () => string }): Collection {
240
343
  return {
241
344
  id: 'health',
@@ -246,35 +349,39 @@ export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVers
246
349
  if (o?.bootVersion != null && o.libVersion != null) {
247
350
  const now = o.libVersion()
248
351
  if (now !== o.bootVersion)
249
- out.push({ id: 'health:stale-hub', severity: 'red', text: `hub is running shipkit ${o.bootVersion} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` })
352
+ out.push({ id: 'health:stale-hub', type: 'health', path: 'health/stale-hub', virtual: true, severity: 'red', text: `hub is running shipkit ${o.bootVersion} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` })
250
353
  }
251
- for (const i of statusWire(root)) {
252
- if (i.kind === 'fail' && CRED_LINE.test(i.text)) continue // secrets own the cred probe + its fix lines
253
- // the feed line carries severity already — a raw FAIL:/WARN marker would stack the signal
254
- // id = full text (never sliced: a shared 60-char prefix must not collide two items — pings dedup on ids)
255
- out.push({
256
- id: `health:${i.text}`,
257
- text: i.text,
258
- severity: i.kind === 'fail' ? 'red' : i.kind === 'warn' ? 'yellow' : undefined,
259
- md: i.fixes.length > 0 ? wireMd(i.fixes.map((f) => `fix: ${f}`)) : undefined,
260
- })
261
- }
262
- const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
354
+ const disk = CheckCtx.open({ root, config: c })
355
+ const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, disk), disk)
263
356
  for (const l of r.lines) {
264
- if (l.kind === 'fail') out.push({ id: `health:contract:${l.text}`, severity: 'red', text: l.text })
265
- else if (l.kind === 'warn') out.push({ id: `health:contractwarn:${l.text}`, severity: 'yellow', text: l.text })
357
+ if (l.kind === 'fail') out.push({ id: `health:contract:${l.text}`, type: 'contract', path: `health/contracts/${slug(l.text)}`, virtual: true, severity: 'red', text: l.text })
358
+ else if (l.kind === 'warn') out.push({ id: `health:contractwarn:${l.text}`, type: 'contract', path: `health/contracts/${slug(l.text)}`, virtual: true, severity: 'yellow', text: l.text })
266
359
  }
267
360
  return out
268
361
  },
269
362
  }
270
363
  }
271
364
 
365
+ /** one listing for every caller while it is in flight: the hub lists every
366
+ * collection at once, and the secrets read the checks listing too */
367
+ const sharedList = (col: Collection): Collection => {
368
+ let inFlight: Promise<Instance[]> | null = null
369
+ return {
370
+ ...col,
371
+ list: () => (inFlight ??= col.list().finally(() => { inFlight = null })),
372
+ }
373
+ }
374
+
272
375
  export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit | null, o?: { bootVersion?: string; libVersion?: () => string }): Collection[] {
376
+ const checks = sharedList(checksCollection(root, c))
273
377
  return [
378
+ checks,
274
379
  repoHealthCollection(root, c, o),
275
380
  asksCollection(root, c),
276
- credsCollection(root, c),
381
+ credsCollection(root, c, { verdicts: () => verdictsOf(checks) }),
277
382
  mediaKitCollection(root, kit),
278
383
  socialPostsCollection(root),
384
+ reflectionsCollection(root),
385
+ testsCollection(root, c),
279
386
  ]
280
387
  }