rvlib-shipkit 0.13.0 → 0.16.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 (68) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/README.md +6 -22
  3. package/package.json +2 -2
  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 +52 -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/contract-types.ts +5 -2
  21. package/src/contracts/contracts-engine.ts +13 -3
  22. package/src/contracts/manifests/discord.contract.ts +2 -2
  23. package/src/contracts/manifests/identity.contract.ts +1 -1
  24. package/src/contracts/manifests/itch.contract.ts +17 -2
  25. package/src/core/canonical-scripts.ts +9 -14
  26. package/src/core/check-suite.ts +1 -1
  27. package/src/core/discover-checks.ts +21 -6
  28. package/src/core/run-check.ts +69 -21
  29. package/src/core/run-ci.ts +75 -9
  30. package/src/core/run-fixers.ts +144 -0
  31. package/src/core/write-status.ts +14 -5
  32. package/src/hooks/commit-message.ts +42 -0
  33. package/src/hooks/leak-guard.ts +0 -0
  34. package/src/hooks/run-hooks.ts +131 -0
  35. package/src/init-repo.ts +13 -5
  36. package/src/reflect/prompt-corpus.ts +3 -4
  37. package/src/release/changelog.ts +53 -0
  38. package/src/release/git-facts.ts +133 -0
  39. package/src/release/run-bump.ts +419 -0
  40. package/src/release/run-release.ts +267 -0
  41. package/src/release/run-releases.ts +230 -0
  42. package/src/release/unit-gate.ts +67 -0
  43. package/src/release/units.ts +246 -0
  44. package/src/repo-config.ts +66 -2
  45. package/src/rules/SK001-package-json-shape.ts +39 -17
  46. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  47. package/src/rules/SK003-claude-md-shape.ts +2 -1
  48. package/src/rules/SK004-gitignore-policy.ts +1 -0
  49. package/src/rules/SK005-rvlib-versions.ts +22 -3
  50. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  51. package/src/rules/SK007-github-visibility.ts +5 -1
  52. package/src/rules/SK008-reviewer-agent.ts +99 -0
  53. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  54. package/src/rules/SK010-undeclared-release-unit.ts +114 -0
  55. package/src/rules/rule-registry.ts +28 -6
  56. package/src/rules/rule.ts +8 -1
  57. package/src/rules/run-fix.ts +15 -8
  58. package/src/serve/hub-app/col-card.tsx +2 -3
  59. package/src/serve/hub-app/hub-app.tsx +6 -8
  60. package/src/serve/hub-app/hub-st.ts +4 -7
  61. package/src/serve/hub-app/media-view.tsx +2 -2
  62. package/src/serve/run-serve.ts +4 -5
  63. package/src/serve/serve-hub.ts +2 -3
  64. package/src/shipkit.ts +9 -1
  65. package/src/tui/checks-panel.ts +1 -1
  66. package/src/tui/run-tui.ts +2 -3
  67. package/src/tui/tasks-panel.ts +10 -4
  68. package/src/tui/tui-store.ts +2 -2
@@ -0,0 +1,99 @@
1
+ // SK008 — the mandated reviewer agent type. A repo that declares
2
+ // `reviewer: { name, brief }` must carry `.claude/agents/<name>.md`: an agent
3
+ // definition whose system prompt POINTS at the brief file (ONE home for the
4
+ // text, so review framing can never drift per repo) and whose `tools:` line
5
+ // stays inside a read-only ALLOWLIST — a reviewer's product is findings, never
6
+ // fixes, and a denylist of today's edit-tool names would wave through anything
7
+ // that edits by proxy. An absent `tools:` line is the worst case of all: the
8
+ // harness then inherits every tool. The brief itself must exist. No
9
+ // declaration = not a reviewer repo, and the rule passes silently.
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
11
+ import { homedir } from 'os'
12
+ import { dirname, isAbsolute, join } from 'path'
13
+ import type { RepoConfig } from '../repo-config.ts'
14
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
15
+
16
+ /** default read-only toolset — an ALLOWLIST, not a denylist: a tool that can
17
+ * edit by proxy (spawning a subagent, an MCP writer) is exactly what a
18
+ * denylist of today's edit-tool names would wave through */
19
+ export const REVIEWER_TOOLS = ['Read', 'Grep', 'Glob', 'Bash']
20
+
21
+ export const reviewerAgentPath = (name: string): string => `.claude/agents/${name}.md`
22
+
23
+ // the invariants, as a pure function over the file text — the rule below and
24
+ // any repo-local linter call THIS, so there is one definition of "a valid
25
+ // reviewer agent file" and not one per caller
26
+ export function reviewerAgentIssues(text: string, reviewer: NonNullable<RepoConfig['reviewer']>): string[] {
27
+ const issues: string[] = []
28
+ const allowed = reviewer.tools ?? REVIEWER_TOOLS
29
+ const declared = text.match(/^name:\s*(\S+)\s*$/m)?.[1]
30
+ if (declared !== reviewer.name)
31
+ issues.push(`frontmatter name is ${declared == null ? 'missing' : `'${declared}'`} — it must be '${reviewer.name}', the type spawners ask for`)
32
+ if (!text.includes(reviewer.brief))
33
+ issues.push(`never points at the brief '${reviewer.brief}' — the agent's whole job is to load it before judging anything`)
34
+ const tools = text.match(/^tools:\s*(.+)$/m)?.[1]
35
+ if (tools == null) issues.push(`has no inline 'tools:' allowlist — omitting it inherits every tool, edit tools included`)
36
+ else
37
+ for (const t of tools.split(',').map((x) => x.trim()).filter(Boolean))
38
+ if (!allowed.includes(t)) issues.push(`grants '${t}', outside the allowlist (${allowed.join(', ')}) — a reviewer reports findings, it never edits, directly or by proxy`)
39
+ return issues
40
+ }
41
+
42
+ /** the brief must EXIST: a reviewer whose mandated first action is reading a
43
+ * missing file flounders silently, which is the one thing this rule prevents */
44
+ export function briefMissing(root: string, brief: string): boolean {
45
+ const p = brief.startsWith('~/') ? join(homedir(), brief.slice(2)) : isAbsolute(brief) ? brief : join(root, brief)
46
+ return !existsSync(p)
47
+ }
48
+
49
+ function canonicalAgent(reviewer: NonNullable<RepoConfig['reviewer']>): string {
50
+ return `---
51
+ name: ${reviewer.name}
52
+ description: The mandated critical reviewer for this repo. Use before every commit, and whenever a diff needs judging against what was actually asked for. Judges whether a change should exist at all, not just whether it is correct.
53
+ tools: ${(reviewer.tools ?? REVIEWER_TOOLS).join(', ')}
54
+ model: opus
55
+ ---
56
+
57
+ You are this repo's critical reviewer. You have no Edit or Write tool on
58
+ purpose: your product is findings, never fixes. Bash is for READING the state
59
+ of things (\`git diff\`, the gates, running a claim to check it) — never for
60
+ writing files by another route.
61
+
62
+ FIRST ACTION, always: read \`${reviewer.brief}\` and follow it exactly. It tells
63
+ you what else to read, when to refuse the job, the order of the questions, and
64
+ the output shape.
65
+
66
+ That brief overrides any habit of reviewing a diff for correctness alone.
67
+ `
68
+ }
69
+
70
+ export const reviewerAgent: Rule = {
71
+ id: 'SK008',
72
+ blocking: true,
73
+ title: 'reviewer agent',
74
+ owner: 'identity/how-we-work.md',
75
+ lint: (t, env) => {
76
+ const r = env.config.reviewer
77
+ if (r == null) return t.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
78
+ const rel = reviewerAgentPath(r.name)
79
+ const p = join(env.root, rel)
80
+ if (briefMissing(env.root, r.brief)) t.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
81
+ if (!existsSync(p)) return t.fail(`${rel} missing — the mandated reviewer type is unavailable, so every review runs on whatever framing the spawner improvised`)
82
+ const issues = reviewerAgentIssues(readFileSync(p, 'utf8'), r)
83
+ for (const i of issues) t.fail(`${rel}: ${i}`)
84
+ if (issues.length === 0 && !t.failed) t.ok(`${rel} → ${r.brief}`)
85
+ },
86
+ // create-if-missing only: an existing definition may carry a repo's own
87
+ // tuned prompt, and silently rewriting that would cost more than the
88
+ // finding it repairs. Violations in a present file stay a loud FAIL.
89
+ fix: (env: RuleEnv): FixResult => {
90
+ const r = env.config.reviewer
91
+ if (r == null) return { changed: false, summary: 'no reviewer declared — nothing to write' }
92
+ const rel = reviewerAgentPath(r.name)
93
+ const p = join(env.root, rel)
94
+ if (existsSync(p)) return { changed: false, summary: `${rel} exists — remaining findings are about its content, fix them by hand` }
95
+ mkdirSync(dirname(p), { recursive: true })
96
+ writeFileSync(p, canonicalAgent(r))
97
+ return { changed: true, summary: `${rel} written — points at ${r.brief}, no edit tools` }
98
+ },
99
+ }
@@ -0,0 +1,66 @@
1
+ // SK009 — commit AI tells (identity/how-we-commit.md): a repo that will be
2
+ // seen must not read as AI-driven. Prevention lives at the source (the
3
+ // attribution setting + the standing from-commit-one ban, which binds every
4
+ // repo because history follows a repo public and is never rewritten); this
5
+ // rule is the declared-public tripwire: the FULL git log scanned for AI
6
+ // attribution and dash punctuation, plus the tracked settings file that
7
+ // silences attribution. Declared-private repos idle with an ok that RESTATES
8
+ // the ban — a green line must never teach the wrong rule. The remedy for
9
+ // pushed history is the owner's decision, never an automatic rewrite; only
10
+ // an unpushed tip commit may be amended, and the fails say so.
11
+ import { readFileSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
14
+ import type { Rule } from './rule.ts'
15
+
16
+ // the documented silencer: .claude/settings.json attribution.commit/.pr both
17
+ // empty — the harness then never writes the trailer or the PR footer
18
+ const attributionSilenced = (root: string): boolean => {
19
+ try {
20
+ const s = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8')) as { attribution?: { commit?: string; pr?: string } }
21
+ return s.attribution != null && s.attribution.commit === '' && s.attribution.pr === ''
22
+ } catch {
23
+ return false
24
+ }
25
+ }
26
+
27
+ export const commitAiTells: Rule = {
28
+ id: 'SK009',
29
+ blocking: true,
30
+ title: 'commit ai tells',
31
+ owner: 'identity/how-we-commit.md',
32
+ lint: async (t, env) => {
33
+ const declared = env.config.visibility ?? 'private'
34
+ if (declared !== 'public')
35
+ return t.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
36
+ // %x1f separates hash from body, %x1e terminates the record — commit
37
+ // bodies are free text, so only unprintable separators are safe
38
+ const log = await t.exec(['git', 'log', '--format=%h%x1f%B%x1e'], { cwd: env.root, timeoutMs: 30_000 })
39
+ if (log.code !== 0) {
40
+ const first = log.out.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? `exit ${log.code}`
41
+ return void t.lines.push({ kind: 'skip', text: `commit history unverifiable — git says: ${first}` })
42
+ }
43
+ const commits = log.out
44
+ .split('\x1e')
45
+ .map((r) => r.trim())
46
+ .filter((r) => r !== '')
47
+ .map((r) => {
48
+ const cut = r.indexOf('\x1f')
49
+ return { hash: r.slice(0, cut), body: r.slice(cut + 1) }
50
+ })
51
+ if (commits.length === 0) return void t.lines.push({ kind: 'skip', text: 'commit history unverifiable — no commits yet' })
52
+ const remedy = 'an unpushed TIP commit may be amended; anything deeper is the owner\'s decision (history is never rewritten as a side effect)'
53
+ const sample = (hits: { hash: string }[]): string => hits.slice(0, 5).map((h) => h.hash).join(' ') + (hits.length > 5 ? ' …' : '')
54
+ const attributed = commits.filter((c) => hasAiAttribution(c.body))
55
+ const dashed = commits.filter((c) => hasDashPunctuation(c.body))
56
+ if (attributed.length > 0)
57
+ t.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
58
+ if (dashed.length > 0)
59
+ t.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
60
+ const silenced = attributionSilenced(env.root)
61
+ if (!silenced)
62
+ t.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
63
+ if (attributed.length === 0 && dashed.length === 0 && silenced)
64
+ t.ok(`${commits.length} commits scanned, attribution silenced in .claude/settings.json — no AI tells`)
65
+ },
66
+ }
@@ -0,0 +1,114 @@
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 claimed = [...units.filter((u) => u.rel !== '').map((u) => u.dir), ...excused]
102
+ const orphans = pkgs.filter((d) => !covered(claimed, d))
103
+ if (orphans.length === 0) {
104
+ // PACKAGES, not entries: one entry over a nested tree excuses more
105
+ // than one, and the count is the only place that shows it
106
+ const n = pkgs.filter((d) => covered(excused, d)).length
107
+ 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`)
108
+ }
109
+ for (const d of orphans) {
110
+ const rel = relative(env.root, d)
111
+ 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}']`)
112
+ }
113
+ },
114
+ }
@@ -10,6 +10,9 @@ import { gitignorePolicy } from './SK004-gitignore-policy.ts'
10
10
  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
+ 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'
13
16
 
14
17
  export const RULES: readonly Rule[] = [
15
18
  packageJsonShape,
@@ -19,14 +22,33 @@ export const RULES: readonly Rule[] = [
19
22
  rvlibVersions,
20
23
  npmPublishSafety,
21
24
  githubVisibility,
25
+ reviewerAgent,
26
+ commitAiTells,
27
+ undeclaredReleaseUnit,
22
28
  ]
23
29
 
24
- const seen = new Set<string>()
25
- for (const r of RULES) {
26
- if (!/^SK\d{3}$/.test(r.id)) throw new Error(`rule-registry: bad id '${r.id}' — rules are SK### (3 digits, never reused)`)
27
- if (seen.has(r.id)) throw new Error(`rule-registry: duplicate id ${r.id}`)
28
- if (r.owner === '') throw new Error(`rule-registry: ${r.id} has no owner page`)
29
- 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
+ }
30
48
  }
31
49
 
50
+ validateRules(RULES, { label: 'rule-registry', builtin: true })
51
+
52
+ export const BUILTIN_RULE_IDS: readonly string[] = RULES.map((r) => r.id)
53
+
32
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)