rvlib-shipkit 0.14.0 → 0.17.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 (66) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +6 -22
  3. package/package.json +1 -1
  4. package/src/assets/kits/identity-kit.ts +1 -1
  5. package/src/assets/panels/geometry-rects.ts +3 -17
  6. package/src/assets/server/studio-server.ts +1 -1
  7. package/src/cli/cmd_Bump.ts +61 -0
  8. package/src/cli/cmd_Check.ts +2 -2
  9. package/src/cli/cmd_Ci.ts +10 -2
  10. package/src/cli/cmd_Commands.ts +126 -0
  11. package/src/cli/cmd_Hooks.ts +66 -0
  12. package/src/cli/cmd_Mv.ts +29 -6
  13. package/src/cli/cmd_Release.ts +41 -0
  14. package/src/cli/cmd_Releases.ts +42 -0
  15. package/src/cli/cmd_Rules.ts +20 -6
  16. package/src/cli/cmd_Start.ts +2 -2
  17. package/src/cli.ts +14 -1
  18. package/src/collections/posts.ts +1 -1
  19. package/src/collections/providers.ts +1 -1
  20. package/src/contracts/manifests/discord.contract.ts +1 -1
  21. package/src/contracts/manifests/itch.contract.ts +2 -4
  22. package/src/core/canonical-scripts.ts +9 -14
  23. package/src/core/check-suite.ts +1 -1
  24. package/src/core/discover-checks.ts +21 -6
  25. package/src/core/run-check.ts +69 -21
  26. package/src/core/run-ci.ts +78 -9
  27. package/src/core/run-fixers.ts +144 -0
  28. package/src/core/write-status.ts +14 -5
  29. package/src/hooks/commit-message.ts +42 -0
  30. package/src/hooks/leak-guard.ts +0 -0
  31. package/src/hooks/root-identity.ts +64 -0
  32. package/src/hooks/run-hooks.ts +149 -0
  33. package/src/init-repo.ts +10 -5
  34. package/src/reflect/prompt-corpus.ts +3 -4
  35. package/src/release/changelog.ts +70 -0
  36. package/src/release/git-facts.ts +133 -0
  37. package/src/release/run-bump.ts +436 -0
  38. package/src/release/run-release.ts +271 -0
  39. package/src/release/run-releases.ts +230 -0
  40. package/src/release/unit-gate.ts +67 -0
  41. package/src/release/units.ts +246 -0
  42. package/src/repo-config.ts +56 -2
  43. package/src/rules/SK001-package-json-shape.ts +39 -17
  44. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  45. package/src/rules/SK003-claude-md-shape.ts +2 -1
  46. package/src/rules/SK004-gitignore-policy.ts +4 -0
  47. package/src/rules/SK005-rvlib-versions.ts +22 -3
  48. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  49. package/src/rules/SK007-github-visibility.ts +5 -1
  50. package/src/rules/SK008-reviewer-agent.ts +1 -0
  51. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  52. package/src/rules/SK010-undeclared-release-unit.ts +125 -0
  53. package/src/rules/rule-registry.ts +26 -6
  54. package/src/rules/rule.ts +8 -1
  55. package/src/rules/run-fix.ts +15 -8
  56. package/src/serve/hub-app/col-card.tsx +2 -3
  57. package/src/serve/hub-app/hub-app.tsx +6 -8
  58. package/src/serve/hub-app/hub-st.ts +4 -7
  59. package/src/serve/hub-app/media-view.tsx +2 -2
  60. package/src/serve/run-serve.ts +4 -5
  61. package/src/serve/serve-hub.ts +2 -3
  62. package/src/shipkit.ts +9 -1
  63. package/src/tui/checks-panel.ts +1 -1
  64. package/src/tui/run-tui.ts +2 -3
  65. package/src/tui/tasks-panel.ts +10 -4
  66. package/src/tui/tui-store.ts +2 -2
@@ -0,0 +1,125 @@
1
+ // SK010 — undeclared release unit (build/shipkit.md). Release units are
2
+ // DECLARED, never inferred, so resolution is deterministic. This rule is the
3
+ // other half of that deal: a package.json sitting in the repo that no unit
4
+ // claims would be invisible to every release surface, and its version would
5
+ // drift forever with nothing reporting it. Two declarations clear it, so the
6
+ // green is always reachable: `units: [{ dir }]` for a releasable package,
7
+ // `notReleasable: [dir]` for one that ships inside something else. That second
8
+ // escape has teeth on purpose, because it is the one an agent staring at a red
9
+ // gate reaches for: an entry must cover at least one real package and never the
10
+ // whole repo, and the green counts the PACKAGES it excused, so a wide entry
11
+ // cannot read as a narrow one.
12
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
13
+ import { join, relative } from 'path'
14
+ import { resolveUnits } from '../release/units.ts'
15
+ import type { Rule } from './rule.ts'
16
+
17
+ const SKIP = new Set(['node_modules', 'references', 'tmp', 'stats', 'dist', 'build'])
18
+
19
+ /** package dirs git knows about (tracked + untracked-not-ignored), so a
20
+ * gitignored vendor tree or a python venv never trips the gate. Null when
21
+ * git cannot answer, and the caller falls back to walking. */
22
+ function gitPackageDirs(root: string): string[] | null {
23
+ const p = Bun.spawnSync(['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root })
24
+ if (p.exitCode !== 0) return null
25
+ return p.stdout.toString().split('\0')
26
+ .filter((f) => f === 'package.json' || f.endsWith('/package.json'))
27
+ .map((f) => join(root, f, '..'))
28
+ }
29
+
30
+ /** the fs fallback: an unreadable entry (a dangling symlink, an unmounted
31
+ * volume) is skipped, never thrown — a broken link is not a release fact */
32
+ function walkPackageDirs(root: string): string[] {
33
+ const out: string[] = [root]
34
+ const walk = (dir: string): void => {
35
+ let entries: string[]
36
+ try { entries = readdirSync(dir) } catch { return }
37
+ for (const e of entries) {
38
+ if (SKIP.has(e) || e.startsWith('.')) continue
39
+ const p = join(dir, e)
40
+ try { if (!statSync(p).isDirectory()) continue } catch { continue }
41
+ if (existsSync(join(p, 'package.json'))) out.push(p)
42
+ walk(p)
43
+ }
44
+ }
45
+ walk(root)
46
+ return out
47
+ }
48
+
49
+ /** every package.json below the root that carries a name, root excluded */
50
+ export function packageDirs(root: string): string[] {
51
+ const dirs = gitPackageDirs(root) ?? walkPackageDirs(root)
52
+ const named = dirs.filter((d) => {
53
+ if (d === root) return false
54
+ if (relative(root, d).split('/').some((seg) => SKIP.has(seg) || seg.startsWith('.'))) return false
55
+ try {
56
+ const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string }
57
+ return typeof pkg.name === 'string' && pkg.name !== ''
58
+ } catch { return false } // a broken package.json is SK001's story, not this rule's
59
+ })
60
+ return [...new Set(named)].sort()
61
+ }
62
+
63
+ export const undeclaredReleaseUnit: Rule = {
64
+ id: 'SK010',
65
+ blocking: true,
66
+ title: 'release units declared',
67
+ owner: 'build/shipkit.md',
68
+ lint: (t, env) => {
69
+ const { units, errors } = resolveUnits(env.root, env.config)
70
+ // a DECLARED unit that does not resolve is a declaration bug and
71
+ // belongs here. A root package that is not release-shaped (no version
72
+ // yet) is not: the release path refuses on its own, the day it matters.
73
+ const declared = errors.filter((e) => e.id !== 'root')
74
+ if (declared.length > 0) {
75
+ for (const e of declared) t.fail(`declared unit '${e.id}' does not resolve: ${e.message}`, 'repo.config units[]')
76
+ return
77
+ }
78
+ // an entry is a PREFIX over the packages under it, so `examples/` can
79
+ // excuse examples/a and examples/b without enumerating them. Two shapes
80
+ // are refused, both being the kill switch this rule exists to prevent:
81
+ // an entry covering the whole repo, and one covering nothing today that
82
+ // stands ready to excuse whatever lands there tomorrow. defineRepo
83
+ // refuses the root too, and a repo.config.ts that never calls it is
84
+ // exactly why the check also lives in the blocking rule.
85
+ const decls = env.config.notReleasable ?? []
86
+ const covered = (list: string[], d: string): boolean => list.some((c) => d === c || d.startsWith(`${c}/`))
87
+ const pkgs = packageDirs(env.root)
88
+ const wide = decls.filter((d) => join(env.root, d) === env.root)
89
+ if (wide.length > 0) {
90
+ for (const d of wide)
91
+ t.fail(`notReleasable '${d}' resolves to the repo root, so it excuses every package at once`, `repo.config notReleasable: name one package dir (or one container dir) per entry`)
92
+ return
93
+ }
94
+ const empty = decls.filter((d) => !pkgs.some((p) => covered([join(env.root, d)], p)))
95
+ if (empty.length > 0) {
96
+ for (const d of empty)
97
+ t.fail(`notReleasable '${d}' covers no package.json, so it excuses no package today and every package that lands there tomorrow`, `repo.config notReleasable: point it at a package dir (or a dir holding some), or drop the entry`)
98
+ return
99
+ }
100
+ const excused = decls.map((d) => join(env.root, d))
101
+ const declaredDirs = units.filter((u) => u.rel !== '').map((u) => u.dir)
102
+ // an excuse that also covers a RELEASE UNIT is the kill switch one
103
+ // level down: `notReleasable: ['libs']` beside `units: [libs/a]` reads
104
+ // as green while every new package under libs/ hides. The two lists
105
+ // describe opposite intents, so they must not overlap.
106
+ const overlapping = decls.filter((d) => declaredDirs.some((u) => u === join(env.root, d) || u.startsWith(`${join(env.root, d)}/`)))
107
+ if (overlapping.length > 0) {
108
+ for (const d of overlapping)
109
+ t.fail(`notReleasable '${d}' also covers a declared release unit, so it excuses every package under it while reading as green`, `repo.config notReleasable: name the dirs that are NOT release units, never a container holding one`)
110
+ return
111
+ }
112
+ const claimed = [...declaredDirs, ...excused]
113
+ const orphans = pkgs.filter((d) => !covered(claimed, d))
114
+ if (orphans.length === 0) {
115
+ // PACKAGES, not entries: one entry over a nested tree excuses more
116
+ // than one, and the count is the only place that shows it
117
+ const n = pkgs.filter((d) => covered(excused, d)).length
118
+ return t.ok(`${units.length} release unit(s) declared${units.length === 1 ? ' (the repo root)' : ''}${n > 0 ? ` + ${n} package(s) declared not releasable` : ''} — no package hides from a release`)
119
+ }
120
+ for (const d of orphans) {
121
+ const rel = relative(env.root, d)
122
+ t.fail(`${rel}/package.json is claimed by nothing — declare what it is, or its version drifts with nothing watching`, `repo.config units: [{ dir: '${rel}' }] if it releases, else notReleasable: ['${rel}']`)
123
+ }
124
+ },
125
+ }
@@ -11,6 +11,8 @@ import { rvlibVersions } from './SK005-rvlib-versions.ts'
11
11
  import { npmPublishSafety } from './SK006-npm-publish-safety.ts'
12
12
  import { githubVisibility } from './SK007-github-visibility.ts'
13
13
  import { reviewerAgent } from './SK008-reviewer-agent.ts'
14
+ import { commitAiTells } from './SK009-commit-ai-tells.ts'
15
+ import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
14
16
 
15
17
  export const RULES: readonly Rule[] = [
16
18
  packageJsonShape,
@@ -21,14 +23,32 @@ export const RULES: readonly Rule[] = [
21
23
  npmPublishSafety,
22
24
  githubVisibility,
23
25
  reviewerAgent,
26
+ commitAiTells,
27
+ undeclaredReleaseUnit,
24
28
  ]
25
29
 
26
- const seen = new Set<string>()
27
- for (const r of RULES) {
28
- if (!/^SK\d{3}$/.test(r.id)) throw new Error(`rule-registry: bad id '${r.id}' — rules are SK### (3 digits, never reused)`)
29
- if (seen.has(r.id)) throw new Error(`rule-registry: duplicate id ${r.id}`)
30
- if (r.owner === '') throw new Error(`rule-registry: ${r.id} has no owner page`)
31
- seen.add(r.id)
30
+ // THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
31
+ // reserved for the built-ins above so a repo rule can never shadow one.
32
+ export const RULE_ID_RE = /^[A-Z]{2,4}\d{3}$/
33
+
34
+ /** validate a rule set and its ids against everything already registered.
35
+ * Throws on a malformed id, a duplicate, a missing owner page, or a repo
36
+ * rule squatting the reserved SK prefix. Shared by the built-in registry
37
+ * and by `defineRepo`, so both die loud at import time. */
38
+ export function validateRules(rules: readonly Rule[], o: { label: string; builtin?: boolean; taken?: Iterable<string> } = { label: 'rule-registry' }): void {
39
+ const seen = new Set<string>(o.taken ?? [])
40
+ for (const r of rules) {
41
+ if (!RULE_ID_RE.test(r.id)) throw new Error(`${o.label}: bad id '${r.id}' — rules are PREFIX+3 digits (e.g. SK001, RV001)`)
42
+ if (o.builtin !== true && r.id.startsWith('SK'))
43
+ throw new Error(`${o.label}: id '${r.id}' uses the reserved SK prefix — repo rules pick their own (e.g. RV${r.id.slice(-3)})`)
44
+ if (seen.has(r.id)) throw new Error(`${o.label}: duplicate id ${r.id}`)
45
+ if (r.owner === '') throw new Error(`${o.label}: ${r.id} has no owner page`)
46
+ seen.add(r.id)
47
+ }
32
48
  }
33
49
 
50
+ validateRules(RULES, { label: 'rule-registry', builtin: true })
51
+
52
+ export const BUILTIN_RULE_IDS: readonly string[] = RULES.map((r) => r.id)
53
+
34
54
  export const ruleById = (id: string): Rule | undefined => RULES.find((r) => r.id === id)
package/src/rules/rule.ts CHANGED
@@ -13,10 +13,17 @@ export type RuleEnv = { root: string; config: RepoConfig }
13
13
  export type FixResult = { changed: boolean; summary: string }
14
14
 
15
15
  export type Rule = {
16
- /** stable `SK` + 3 digits — never reused, never renumbered (registry throws) */
16
+ /** stable PREFIX + 3 digits, never reused or renumbered (the registry throws).
17
+ * `SK###` is reserved for shipkit's built-ins; a repo registering its own
18
+ * rules through `repo.config.ts rules: []` picks its own prefix (`RV###`). */
17
19
  id: string
18
20
  /** check-suite display name — stable: the check name in STATUS.md, the TUI row, and the suite cache */
19
21
  title: string
22
+ /** can this stop you? REQUIRED, no default. true = a failure is about THIS
23
+ * repo and blocks. false = it reports the world (a box that is off, a cred
24
+ * nobody minted), so a failure prints as a warning and never blocks.
25
+ * The only question worth asking about a check, asked once, in one place. */
26
+ blocking: boolean
20
27
  /** wiki page owning the invariant — the run-check framework appends `docsBase + owner`
21
28
  * as the fix: pointer whenever a failing/warning lint attached none */
22
29
  owner: string
@@ -6,11 +6,14 @@
6
6
  import { CheckCtx } from '../core/check-ctx.ts'
7
7
  import { formatLine } from '../core/wire.ts'
8
8
  import { loadRepoConfig } from '../repo-config.ts'
9
- import { RULES, ruleById } from './rule-registry.ts'
9
+ import { RULES } from './rule-registry.ts'
10
10
  import { runRuleLint, type RuleEnv } from './rule.ts'
11
11
 
12
12
  export type FixOpts = {
13
13
  ids?: string[]
14
+ /** drop the closing advisory: inside the ci fix pass it is noise, and it is
15
+ * not wire-shaped, which the frozen output contract forbids */
16
+ quiet?: boolean
14
17
  print?: (line: string) => void
15
18
  }
16
19
 
@@ -19,11 +22,13 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
19
22
  const config = await loadRepoConfig(root)
20
23
  const env: RuleEnv = { root, config }
21
24
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
25
+ // the repo's own rules fix exactly like the built-ins, one code path
26
+ const all = [...RULES, ...(config.rules ?? [])]
22
27
 
23
- let targets = RULES.filter((r) => r.fix != null)
28
+ let targets = all.filter((r) => r.fix != null)
24
29
  if (o.ids != null && o.ids.length > 0) {
25
30
  for (const id of o.ids) {
26
- const r = ruleById(id)
31
+ const r = all.find((x) => x.id === id)
27
32
  if (r == null) { print(`FAIL: unknown rule '${id}' — see: shipkit rules`); return 1 }
28
33
  if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
29
34
  }
@@ -36,7 +41,9 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
36
41
  const before = new CheckCtx(new Map())
37
42
  await runRuleLint(rule, before, env)
38
43
  if (!before.failed && !before.warned) {
39
- print(`ok: [${rule.id}] already clean — nothing to fix`)
44
+ // inside the ci fix pass, a line per already-clean rule is a wall of
45
+ // nothing above the run that reports those same rules for real
46
+ if (o.quiet !== true) print(`ok: [${rule.id}] already clean — nothing to fix`)
40
47
  continue
41
48
  }
42
49
  const f = await rule.fix!(env)
@@ -47,11 +54,11 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
47
54
  await runRuleLint(rule, after, env)
48
55
  // the fix already ran: advertising it again on surviving findings
49
56
  // would send an agent in a loop
50
- for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix SK\d{3}$/, '') }, color))
57
+ for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix [A-Z]{2,4}\d{3}$/, '') }, color))
51
58
  if (after.failed) exit = 1
52
59
  }
53
- const unfixable = RULES.filter((r) => r.fix == null).length
54
- if (unfixable > 0 && (o.ids == null || o.ids.length === 0))
55
- print(`${unfixable} rule(s) have no autofix — run \`shipkit check\` for the full repo state`)
60
+ const unfixable = all.filter((r) => r.fix == null).length
61
+ if (unfixable > 0 && o.quiet !== true && (o.ids == null || o.ids.length === 0))
62
+ print(`${unfixable} rule(s) have no autofix — they report in the run below`)
56
63
  return exit
57
64
  }
@@ -104,7 +104,7 @@ export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id
104
104
  })
105
105
 
106
106
  // collection view — one card per visible instance, the stage-side twin of a
107
- // sidebar section (his rule 2026-07-23: every section gets both views)
107
+ // sidebar section (every section gets both views)
108
108
  export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
109
109
  const c = app.cols
110
110
  const col = c.sidebarCols.find((x) => x.id === id)
@@ -145,8 +145,7 @@ export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
145
145
  )
146
146
  })
147
147
 
148
- // graph item view — the graph ITSELF fills the stage (his order 2026-07-23:
149
- // "if I select a graph, I want to see it"), lint line as a slim header
148
+ // graph item view — the graph ITSELF fills the stage, lint line as a slim header
150
149
  export const GraphView = observer(({ app, id }: { app: HubSt; id: string }) => {
151
150
  const c = app.cols
152
151
  const i = c.find('graphs', id)
@@ -1,8 +1,7 @@
1
1
  // THE page (Bun.serve HTML import — no vite, no build step). One surface
2
- // per repo (his rules 2026-07-23): media atelier + asks + creds + posts +
3
- // health + graphs as foldable sections, one severity filter persisted at
4
- // /?f=needs · /?f=red (legacy /?needs=1 reads as needs). Section title =
5
- // collection view (grid of cards), row = item view. mobx+react per
2
+ // per repo: media atelier + asks + creds + posts + health + graphs as
3
+ // foldable sections, one severity filter persisted at /?f=needs · /?f=red.
4
+ // Section title = collection view (grid of cards), row = item view. mobx+react per
6
5
  // build/app-state-tree: HubSt is the single root (window.app), services
7
6
  // own state, components read and call.
8
7
  import { runInAction } from 'mobx'
@@ -66,8 +65,7 @@ const ColSections = observer(() => {
66
65
  {c.sidebarCols.map((col) => {
67
66
  const items = c.visibleInstances(col)
68
67
  // every section header ALWAYS renders, 0 items included — a
69
- // quiet section must stay reachable (his order 2026-07-23:
70
- // done asks, present secrets)
68
+ // quiet section must stay reachable (done asks, present secrets)
71
69
  const open = c.isOpen(col)
72
70
  const health = col.id === 'health'
73
71
  return (
@@ -121,8 +119,8 @@ const Sidebar = observer(() => (
121
119
  </aside>
122
120
  ))
123
121
 
124
- // ── the icon rail: quick jump to a section header (his order 2026-07-23:
125
- // scroll + unfold, NEVER a navigation) — red dot = the section holds a red ──
122
+ // ── the icon rail: quick jump to a section header (scroll + unfold,
123
+ // NEVER a navigation) — red dot = the section holds a red ──
126
124
  const RAIL_ICON: Record<string, string> = { asks: '🙋', creds: '🔑', posts: '📣', health: '📊', graphs: '🕸' }
127
125
  const Rail = observer(() => {
128
126
  const c = app.cols
@@ -1,8 +1,8 @@
1
1
  // HubSt — the ONE root store (build/app-state-tree: one central tree,
2
2
  // instantiated once at boot, services with backrefs, exposed as
3
3
  // window.app). Owns cross-service state: selection, the severity filter
4
- // (an ADDRESS: /?f=needs · /?f=red; legacy /?needs=1 reads as needs),
5
- // hash deep-links (#<asset-id> · #cat-<Category>).
4
+ // (an ADDRESS: /?f=needs · /?f=red), hash deep-links (#<asset-id> ·
5
+ // #cat-<Category>).
6
6
  import { makeAutoObservable } from 'mobx'
7
7
  import { ColsSvc } from './cols-svc.ts'
8
8
  import { HealthSvc } from './health-svc.ts'
@@ -22,10 +22,8 @@ export type Jump = { id: string; label: string; section: string; run: () => void
22
22
  /** all · needs (yellow+red) · red — one severity ladder for every section */
23
23
  export type Filter = 'all' | 'needs' | 'red'
24
24
  const parseFilter = (search: string): Filter => {
25
- const sp = new URLSearchParams(search)
26
- const f = sp.get('f')
27
- if (f === 'needs' || f === 'red') return f
28
- return sp.get('needs') === '1' ? 'needs' : 'all' // legacy address keeps working
25
+ const f = new URLSearchParams(search).get('f')
26
+ return f === 'needs' || f === 'red' ? f : 'all'
29
27
  }
30
28
 
31
29
  export class HubSt {
@@ -64,7 +62,6 @@ export class HubSt {
64
62
  setFilter(v: Filter): void {
65
63
  this.filter = v
66
64
  const u = new URL(location.href)
67
- u.searchParams.delete('needs') // legacy param never survives a click
68
65
  v === 'all' ? u.searchParams.delete('f') : u.searchParams.set('f', v)
69
66
  history.replaceState(null, '', u)
70
67
  }
@@ -197,8 +197,8 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
197
197
  {m.catRows.map((row) => {
198
198
  const fl = row.rect?.flip === true
199
199
  return (
200
- // the WHOLE card jumps into the asset (his order
201
- // 2026-07-23) — inner controls stopPropagation
200
+ // the WHOLE card jumps into the asset —
201
+ // inner controls stopPropagation
202
202
  <div className="card" key={row.id} onClick={() => app.selectAsset(row.id)}>
203
203
  {isAud(row.name)
204
204
  ? <div className="aud">🔊<audio controls src={rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
@@ -34,7 +34,7 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
34
34
  const agentGraph = existsSync(agentRoot)
35
35
  ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
36
36
  : null
37
- // ONE page for every repo (his rule 2026-07-23): the studio ALWAYS
37
+ // ONE page for every repo: the studio ALWAYS
38
38
  // mounts — kit-less repos get an empty media section, never a second
39
39
  // page. A broken kit degrades to the same empty kit, loudly.
40
40
  let kit: ResolvedKit | null = null
@@ -86,8 +86,8 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
86
86
  }
87
87
  }
88
88
 
89
- // the hub must KNOW when it is outdated (field bug 2026-07-22: a morning
90
- // process served stale code all day, picks looked broken): version read at
89
+ // the hub must KNOW when it is outdated — a stale morning
90
+ // process serves broken-looking picks all day: version read at
91
91
  // boot vs on disk per attention load; mismatch = red line telling him to
92
92
  // restart the task
93
93
  const libVersion = (): string => {
@@ -96,8 +96,7 @@ const libVersion = (): string => {
96
96
  const BOOT_VERSION = libVersion()
97
97
 
98
98
  // the / dashboard's data now comes from the collection providers
99
- // (src/collections/providers.ts) — the old attentionCollector was DELETED
100
- // when they landed; never reintroduce a parallel projection.
99
+ // (src/collections/providers.ts) — never a parallel projection.
101
100
 
102
101
  export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
103
102
  const c = await loadRepoConfig(root)
@@ -1,5 +1,4 @@
1
- // shipkit serve — the repo's ONE Bun.serve, serving the ONE page (his
2
- // rules 2026-07-23: one server per repo, one surface, mobx+react). `/` is
1
+ // shipkit serve — the repo's ONE Bun.serve, serving the ONE page (one server per repo, one surface, mobx+react). `/` is
3
2
  // the hub app (Bun HTML import — media studio + asks + creds + posts +
4
3
  // health + graphs as sections); the studio route table mounts as the media
5
4
  // API under /media/*; graph canvases stay as drill-down pages. Route logic
@@ -32,7 +31,7 @@ export type HubDeps = {
32
31
  * collection providers (run-serve builds it) */
33
32
  needs: () => Promise<NeedItem[]>
34
33
  /** BROWSE-ALL: the collections with every instance incl. healthy —
35
- * the page shows everything that exists (his trust rule) */
34
+ * the page shows everything that exists, dimmed never hidden */
36
35
  collections: () => Collection[]
37
36
  /** asks dir (repo-root-relative) for the act-in-place ask verbs */
38
37
  asksDir: string
package/src/shipkit.ts CHANGED
@@ -11,6 +11,14 @@ export { projectNeeds, redCount } from './collections/collection-types.ts'
11
11
  export type { Collection, Instance, NeedItem, NeedSeverity, CurationVerb } from './collections/collection-types.ts'
12
12
  export { buildCollections } from './collections/providers.ts'
13
13
  export { socialPostsCollection, listPosts } from './collections/posts.ts'
14
+ // release units — the ONE discovery every release surface reads (drift
15
+ // probes, per-unit tests, per-unit typecheck, the stranded-tag probe)
16
+ export { resolveUnits, releaseOrder, unitById, renderTag, tagGlobs, prepublishScripts, unitPrepublishScripts } from './release/units.ts'
17
+ export type { ReleaseUnit, ReleaseUnitDecl, UnitResolution } from './release/units.ts'
18
+ export { deriveRemote, dirtyPaths, remoteTags, strandedTags, tagExistsLocally } from './release/git-facts.ts'
19
+ export { collectReports, renderReports, attentionLines } from './release/run-releases.ts'
20
+ export { extractChangelogSection, promoteUnreleased, hasUnreleasedNotes, hasVersionSection } from './release/changelog.ts'
21
+ export type { UnitReport } from './release/run-releases.ts'
14
22
  export { discoverChecks } from './core/discover-checks.ts'
15
23
  export type { DiscoveredCheck } from './core/discover-checks.ts'
16
24
  export { runTasks, selectTasks } from './core/task-runner.ts'
@@ -50,7 +58,7 @@ export { studioRoutes } from './assets/server/studio-server.ts'
50
58
  export type { StudioDeps, StudioRoutes } from './assets/server/studio-server.ts'
51
59
  export { StudioQueue } from './assets/server/studio-queue.ts'
52
60
  export type { Job, QueueState } from './assets/server/studio-queue.ts'
53
- export { cleanRect, liveRect, defaultLegs, migrateCircle } from './assets/panels/geometry-rects.ts'
61
+ export { cleanRect, liveRect, defaultLegs } from './assets/panels/geometry-rects.ts'
54
62
  export type { GroundRect, LegRect } from './assets/panels/geometry-rects.ts'
55
63
  export { generate, submit, waitFor, applyInputs, expectNode, randomSeed, batchFolderName } from './assets/gen/comfy-gen.ts'
56
64
  export type { ComfyOpts } from './assets/gen/comfy-gen.ts'
@@ -1,6 +1,6 @@
1
1
  // built-in panel: contracts + every discovered *.check.ts through runCheck
2
2
  // (both TTL cache layers intact) — per-check results stream in via the
3
- // onResult tap. status:false: the tasks panel owns STATUS.md in a TUI run.
3
+ // onResult tap. status:false: no TUI panel writes STATUS.md (tasks-panel.ts).
4
4
  import { discoverChecks } from '../core/discover-checks.ts'
5
5
  import { runCheck } from '../core/run-check.ts'
6
6
  import { formatLine } from '../core/wire.ts'
@@ -1,7 +1,6 @@
1
1
  // The tui command body — a separate module so cli.ts NEVER loads
2
- // react/ink/mobx for non-tui commands (a fresh consumer running `shipkit ci`
3
- // must not touch the react dependency chain; field bug 2026-07-22: 0.3.0
4
- // crashed every command on repos without react-dom).
2
+ // react/ink/mobx for non-tui commands: a fresh consumer running `shipkit ci`
3
+ // must not touch the react dependency chain.
5
4
  import React from 'react'
6
5
  import { render } from 'ink'
7
6
  import { configure } from 'mobx'
@@ -1,7 +1,10 @@
1
- // built-in panel: the ci gate (floor + contracts + repo tasks) through
2
- // runCi — results stream in via the onTaskFinish tap. STATUS.md refreshes
3
- // exactly like a terminal `shipkit ci`; the checks panel writes nothing
4
- // (one STATUS writer per TUI session).
1
+ // built-in panel: the ci gate's TASKS (floor + contracts + repo tasks)
2
+ // through runCi — results stream in via the onTaskFinish tap. The checks are
3
+ // skipped here on purpose: the Checks panel runs the same suite and renders
4
+ // it, and running it twice per refresh costs twice and proves the same thing.
5
+ // no panel writes STATUS.md: a dashboard refresh runs the tasks alone, so
6
+ // stamping a verdict from it would overwrite what a real gate proved with a
7
+ // narrower claim, just by looking at the repo.
5
8
  import type { RepoConfig } from '../repo-config.ts'
6
9
  import { allTasks } from '../repo-config.ts'
7
10
  import { runCi } from '../core/run-ci.ts'
@@ -38,6 +41,9 @@ export function tasksPanel(root: string, config: RepoConfig): TuiPanelDef {
38
41
  const byKey = new Map(rows.map((r, i) => [r.key, i]))
39
42
  update()
40
43
  await runCi(root, {
44
+ config,
45
+ skipChecks: true,
46
+ status: false,
41
47
  print: () => {}, // rows carry everything the wire lines would
42
48
  onTaskFinish: (r) => {
43
49
  const i = byKey.get(r.id)
@@ -1,6 +1,6 @@
1
1
  // TUI state — MobX per the house playbook (build/app-state-tree + build/mobx-mechanics):
2
- // `makeAutoObservable` WITH exceptions (the 2026-07-21 doctrine — no
3
- // decorators), runInAction around every post-await mutation. Panels are
2
+ // `makeAutoObservable` WITH exceptions (no decorators), runInAction around
3
+ // every post-await mutation. Panels are
4
4
  // plain data providers (no mobx): they signal mutations through update(),
5
5
  // which bumps the observable `version` counter every rows() read tracks.
6
6
  import { makeAutoObservable, runInAction } from 'mobx'