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
@@ -1,11 +1,16 @@
1
- // the ci gate — floor + contracts + repo tasks through the DAG runner.
2
- // Plain module behind cmd_Ci (logic stays out of command classes) so tests
3
- // can run the gate in-process. Returns the exit code; json prints the
4
- // structured summary. Every run refreshes STATUS.md (write-status.ts —
5
- // atomic + lock) unless status:false.
1
+ // the ci gate — floor + contracts + repo tasks through the DAG runner, THEN
2
+ // every discovered check (core/run-check.ts owns severity). Plain module
3
+ // behind cmd_Ci (logic stays out of command classes) so tests can run the
4
+ // gate in-process. Returns the exit code; json prints the structured summary.
5
+ // Every run refreshes STATUS.md (write-status.ts — atomic + lock) unless
6
+ // status:false, and the fix pass plus some checks WRITE, so a run is not
7
+ // read-only unless frozen is set and --no-fix is passed.
6
8
  import { join } from 'path'
7
- import { allTasks, loadRepoConfig } from '../repo-config.ts'
9
+ import { allTasks, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
8
10
  import { reportToMotherhome } from './motherhome.ts'
11
+ import { collectChecks } from './run-check.ts'
12
+ import { runFixPass } from './run-fixers.ts'
13
+ import { formatLine } from './wire.ts'
9
14
  import { runTasks, type TaskResult } from './task-runner.ts'
10
15
  import { writeStatus } from './write-status.ts'
11
16
 
@@ -17,17 +22,57 @@ export type CiOpts = {
17
22
  onlyIds?: string[]
18
23
  /** refresh STATUS.md after the run (default true — O4: every repo gets it) */
19
24
  status?: boolean
25
+ /** repair the mechanical slots before gating. default on locally, verify
26
+ * mode under CI. core/run-fixers.ts owns the scope. */
27
+ fix?: boolean
28
+ /** also apply the structural SK rule fixers during the fix pass */
29
+ fixRules?: boolean
30
+ /** report-only for self-maintaining rules and checks (SHIPKIT_FROZEN
31
+ * travels to child checks). The release path passes it: a rule that
32
+ * rewrites package.json between the gate and `npm publish` packs a tree
33
+ * that exists in no commit. */
34
+ frozen?: boolean
35
+ /** treat a NON-blocking check failure as fatal too. `release.requireHealthy`
36
+ * is the only caller: a repo that wants a missing cred to stop a release
37
+ * asks for it, and every other run keeps the declared severity. */
38
+ failOnAdvisory?: boolean
39
+ /** run the tasks only. The TUI owns this: it renders the checks in its own
40
+ * panel, and running the suite twice per refresh proves nothing twice. */
41
+ skipChecks?: boolean
20
42
  /** line sink — commands pass their context stream; defaults to console */
21
43
  print?: (line: string) => void
22
44
  /** per-task completion tap — the TUI tasks panel streams results in */
23
45
  onTaskFinish?: (r: TaskResult) => void
46
+ /** the already-resolved config. A caller that holds one MUST pass it: two
47
+ * loads of repo.config.ts can disagree, and the release path classifies
48
+ * rule-vs-probe severity from the config it holds. */
49
+ config?: RepoConfig
50
+ }
51
+
52
+ /** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
53
+ * the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
54
+ * a narrowed run that stamps the full gate's verdict is a green nobody
55
+ * measured. The label names the invocation either way. */
56
+ function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
57
+ const narrowing = [
58
+ ...(o.onlyIds ?? []).map((i) => `--only ${i}`),
59
+ ...(o.skipGroups ?? []).map((g) => `--skip ${g}`),
60
+ ...(o.skipChecks === true ? ['(tasks only)'] : []),
61
+ ]
62
+ const parts = [...narrowing, ...(o.withIds ?? []).map((i) => `--with ${i}`)]
63
+ return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
24
64
  }
25
65
 
26
66
  export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
27
67
  const print = o.print ?? ((l: string) => console.log(l))
28
68
  const collected: string[] = []
29
69
  const sink = (l: string): void => { collected.push(l); print(l) }
30
- const c = await loadRepoConfig(root)
70
+ const c = o.config ?? (await loadRepoConfig(root))
71
+ // mechanical failures are not information, so they never cost a round trip
72
+ let fixFailed = false
73
+ if (o.fix !== false)
74
+ fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
75
+ else sink('skip: fix pass (--no-fix)')
31
76
  const { tasks, disabled, unfilled } = allTasks(c, root)
32
77
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
33
78
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
@@ -44,12 +89,33 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
44
89
  print: sink,
45
90
  onTaskFinish: o.onTaskFinish,
46
91
  })
47
- const code = summary.ok ? 0 : 1
92
+ // the checks, in the SAME command. there is no second surface: a check
93
+ // declares whether it blocks, and that is the only question about it.
94
+ const checks = o.skipChecks === true
95
+ ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
96
+ : await collectChecks(root, { status: false, config: c, frozen: o.frozen })
97
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
98
+ for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
99
+ for (const u of checks.undeclared) {
100
+ sink(`FAIL: ${u} does not declare whether it blocks`)
101
+ sink(` fix: add \`export const blocking = true\` (about this repo) or \`false\` (about the world) to that file`)
102
+ }
103
+ let advisoryFatal = false
104
+ if (checks.advisoryFailures.length > 0) {
105
+ if (o.failOnAdvisory === true) {
106
+ advisoryFatal = true
107
+ sink(`FAIL: ${checks.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${checks.advisoryFailures.join(', ')}`)
108
+ sink(' fix: shipkit check')
109
+ } else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
110
+ }
111
+ const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
48
112
  // motherhome row (docsBase wiki on this disk) — never affects the gate
49
113
  const mh = reportToMotherhome(root, c)
50
114
  if (mh.note != null) print(mh.note)
51
115
  if (o.status !== false) {
52
- const st = writeStatus(root, { name: c.name, command: 'shipkit ci', lines: collected, exitCode: code })
116
+ // scope is named in the file: ci proves the gate, not repo health, and
117
+ // a bare GREEN here would claim clear-to-ship on a repo with red checks
118
+ const st = writeStatus(root, { name: c.name, ...ciScope(o), lines: collected, exitCode: code })
53
119
  if (!st.written) print(`WARN ${st.warn}`)
54
120
  }
55
121
  if (o.json === true)
@@ -0,0 +1,144 @@
1
+ // what `shipkit ci` repairs before it gates.
2
+ //
3
+ // mechanical slots only (formatter, import canonicaliser): they rewrite the
4
+ // files the agent is already editing and their output is total. structural
5
+ // SK rule fixers rewrite repo config, so they stay behind --fix-rules.
6
+ //
7
+ // under CI the same fixers run in verify mode instead: a gate that silently
8
+ // rewrites the tree it checks can turn red green with no commit. one
9
+ // mechanism, two modes, rather than a formatter plus a format:check that
10
+ // drift apart.
11
+ import { existsSync, readFileSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { fileURLToPath } from 'url'
14
+ import type { RepoConfig } from '../repo-config.ts'
15
+ import { runFix } from '../rules/run-fix.ts'
16
+ import { spawnWithTimeout } from './spawn-timeout.ts'
17
+
18
+ const FIXER_TIMEOUT_MS = 120_000
19
+
20
+ export type FixerSlot = {
21
+ id: string
22
+ cmd: string[]
23
+ source: 'package.json script' | 'built-in (rvlib-ts-tools)'
24
+ }
25
+
26
+ export type FixerResolution = {
27
+ slots: FixerSlot[]
28
+ /** slots nothing could fill, printed, never silent */
29
+ unfilled: { id: string; reason: string }[]
30
+ }
31
+
32
+ // one fixer per mechanical concern. `fmt` accepts the two names the house
33
+ // vocabulary allows so a repo that calls it `format` is not silently unfixed.
34
+ const FMT_SCRIPTS = ['fmt', 'format'] as const
35
+
36
+ export function resolveFixers(root: string): FixerResolution {
37
+ const pkgPath = join(root, 'package.json')
38
+ let scripts: Record<string, string> = {}
39
+ if (existsSync(pkgPath)) {
40
+ try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
41
+ catch (e) { throw new Error(`package.json unparseable — fixers cannot register: ${String(e).slice(0, 120)}`) }
42
+ }
43
+ const slots: FixerSlot[] = []
44
+ const unfilled: { id: string; reason: string }[] = []
45
+
46
+ const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null)
47
+ if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
48
+ else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
49
+
50
+ if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
51
+ else {
52
+ let cli: string | null = null
53
+ try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) } catch { cli = null }
54
+ if (cli == null) unfilled.push({ id: 'imports:fix', reason: `no 'imports:fix' script and rvlib-ts-tools is unresolvable` })
55
+ else slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (rvlib-ts-tools)' })
56
+ }
57
+ return { slots, unfilled }
58
+ }
59
+
60
+ /** worktree fingerprint: every dirty path mapped to `<status>:<content hash>`,
61
+ * or null outside a git repo. The fix pass diffs this before and after so the
62
+ * report names real files instead of asserting "fixed" and hoping.
63
+ *
64
+ * The hash is load-bearing, not decoration. Status alone is blind to the most
65
+ * common case there is: a file the agent already edited (` M`) that a fixer
66
+ * then reformats stays ` M`, so a status-only compare reports "nothing to
67
+ * rewrite" while the tree changed under it, and in verify mode that is a
68
+ * silent green on unformatted code. Hashing is confined to the dirty set,
69
+ * which is small by construction. */
70
+ function treeState(root: string): Map<string, string> | null {
71
+ const p = Bun.spawnSync(['git', 'status', '--porcelain'], { cwd: root })
72
+ if (p.exitCode !== 0) return null
73
+ const m = new Map<string, string>()
74
+ for (const l of p.stdout.toString().split('\n')) {
75
+ if (l.trim() === '') continue
76
+ const status = l.slice(0, 2)
77
+ // rename entries read `R old -> new`; the new path is what exists
78
+ const path = l.slice(3).trim().split(' -> ').at(-1) ?? ''
79
+ if (path === '') continue
80
+ const abs = join(root, path.replace(/^"|"$/g, ''))
81
+ let hash = 'absent'
82
+ try { if (existsSync(abs)) hash = String(Bun.hash(readFileSync(abs))) } catch { hash = 'unreadable' }
83
+ m.set(path, `${status}:${hash}`)
84
+ }
85
+ return m
86
+ }
87
+
88
+ export type FixPassOpts = {
89
+ /** also apply the structural SK rule fixers (default false) */
90
+ rules?: boolean
91
+ print: (line: string) => void
92
+ }
93
+
94
+ export type FixPassResult = {
95
+ ran: boolean
96
+ /** paths whose git status changed across the pass */
97
+ touched: string[]
98
+ /** verify mode only: a fixer had to rewrite something, so the commit is not clean */
99
+ failed: boolean
100
+ }
101
+
102
+ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpts): Promise<FixPassResult> {
103
+ // verify mode: same fixers, but rewriting anything means the COMMIT was
104
+ // not clean, and the gate says so instead of quietly repairing it
105
+ const verify = process.env.CI != null && process.env.CI !== ''
106
+ const { slots, unfilled } = resolveFixers(root)
107
+ for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
108
+ const before = treeState(root)
109
+
110
+ for (const s of slots) {
111
+ const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
112
+ // a broken fixer must never be mistaken for a clean one: the gate
113
+ // still runs, but the failure is loud and names the command
114
+ if (r.code !== 0)
115
+ o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
116
+ }
117
+ // structural repo-config repair is never implicit, and never in verify mode
118
+ if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print })
119
+
120
+ const after = treeState(root)
121
+ const touched: string[] = []
122
+ if (before != null && after != null) {
123
+ for (const [path, st] of after) if (before.get(path) !== st) touched.push(path)
124
+ // a file the fixer restored to its committed state disappears from
125
+ // porcelain entirely, that is still a change worth naming
126
+ for (const path of before.keys()) if (!after.has(path)) touched.push(path)
127
+ }
128
+ const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
129
+ if (before == null) {
130
+ o.print(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
131
+ return { ran: true, touched, failed: false }
132
+ }
133
+ if (touched.length === 0) {
134
+ o.print(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
135
+ return { ran: true, touched, failed: false }
136
+ }
137
+ if (verify) {
138
+ o.print(`FAIL: ${touched.length} file(s) are not fixer-clean: ${list}`)
139
+ o.print(` fix: run \`shipkit ci\` locally (the fix pass rewrites them) and commit the result`)
140
+ return { ran: true, touched, failed: true }
141
+ }
142
+ o.print(`ok: fix pass rewrote ${touched.length} file(s): ${list}`)
143
+ return { ran: true, touched, failed: false }
144
+ }
@@ -1,6 +1,5 @@
1
- // THE STATUS.md writer — every repo gets one (O4, one-runner GO
2
- // 2026-07-22): timestamp header + verdict + fenced compact render, the
3
- // shape rv-doctor pioneered. Atomic + lock-guarded via core/locked-write:
1
+ // THE STATUS.md writer — every repo gets one: timestamp header + verdict +
2
+ // fenced compact render. Atomic + lock-guarded via core/locked-write:
4
3
  // concurrent runners never interleave — the loser skips with a WARN
5
4
  // outcome, never corrupts the file.
6
5
 
@@ -12,6 +11,10 @@ export type StatusInput = {
12
11
  name: string
13
12
  /** the command that produced this render, e.g. 'shipkit ci' */
14
13
  command: string
14
+ /** the run covered the WHOLE gate, so a green may say "clear to ship".
15
+ * Absent means it did not: the claim is opt-in, because a caller that
16
+ * forgets it costs a nag and a caller that over-claims costs a release. */
17
+ fullGate?: boolean
15
18
  /** the run's printed lines (ANSI stripped here) */
16
19
  lines: string[]
17
20
  exitCode: number
@@ -27,11 +30,17 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
27
30
  const clean = s.lines.map(stripAnsi)
28
31
  const fails = clean.filter((l) => l.startsWith('FAIL')).length
29
32
  const warns = clean.filter((l) => l.startsWith('WARN')).length
33
+ // only the full gate may say "clear to ship": a checks-only run proves
34
+ // the repo is sound, never that the build passes, and a narrowed gate
35
+ // proves only the slice it ran. the scope rides on the WARN verdict
36
+ // too, or a narrowed green hides behind a nag nobody reads twice
37
+ const full = s.fullGate === true
38
+ const scope = full ? 'clear to ship' : `${s.command} ran, not the whole gate`
30
39
  const verdict = s.exitCode !== 0 || fails > 0
31
40
  ? `RED — ${Math.max(fails, 1)} FAIL line${fails === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
32
41
  : warns > 0
33
- ? `GREEN with nags — ${warns} WARN. Proceed.`
34
- : 'GREEN — clear to ship.'
42
+ ? `GREEN with nags — ${warns} WARN. ${full ? 'Proceed.' : `${scope}.`}`
43
+ : `GREEN — ${scope}.`
35
44
  // local time, not UTC — an evening run must not stamp yesterday's date
36
45
  const now = new Date()
37
46
  const p = (n: number): string => String(n).padStart(2, '0')
@@ -0,0 +1,42 @@
1
+ // the commit-message predicate, shared by the commit-msg hook (blocks at
2
+ // write time) and SK009 (audits history). one implementation, or the two
3
+ // drift and the drift only shows when a repo goes public.
4
+ //
5
+ // mechanical text rules only, so this stays publishable.
6
+
7
+ export type OffenseKind = 'dash' | 'attribution'
8
+ export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
9
+
10
+ /** trailers and footers that name an assistant, plus the robot emoji. */
11
+ export const AI_ATTRIBUTION =
12
+ /(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|🤖/i
13
+
14
+ /** everything below git's scissors line is the verbose-mode diff, which git
15
+ * strips before committing, diff content is not ours to police. */
16
+ export function stripNonMessage(message: string): string {
17
+ const scissors = message.search(/^# -+ >8 -+$/m)
18
+ return scissors === -1 ? message : message.slice(0, scissors)
19
+ }
20
+
21
+ /** offenses in ONE commit message (or one commit body from `git log`).
22
+ * `#` comment lines never land in the message and are ignored; a `Revert "…"`
23
+ * title quotes the reverted subject verbatim and is exempt; a line-start
24
+ * `- ` bullet is a list marker, not punctuation. */
25
+ export function commitMessageOffenses(message: string): Offense[] {
26
+ const offenses: Offense[] = []
27
+ stripNonMessage(message).split('\n').forEach((raw, i) => {
28
+ if (raw.startsWith('#')) return
29
+ if (i === 0 && /^Revert "/.test(raw)) return
30
+ if (/[—–]/.test(raw))
31
+ offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'em/en dash: use a comma, a period, parentheses, or a new sentence' })
32
+ const body = raw.replace(/^\s*[-*] /, '')
33
+ if (body.includes(' - '))
34
+ offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
35
+ if (AI_ATTRIBUTION.test(raw))
36
+ offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, "Generated with" footer, or robot emoji' })
37
+ })
38
+ return offenses
39
+ }
40
+
41
+ export const hasDashPunctuation = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'dash')
42
+ export const hasAiAttribution = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'attribution')
Binary file
@@ -0,0 +1,131 @@
1
+ // the git-hook gates, owned by the toolkit rather than by per-repo shell.
2
+ //
3
+ // hand-rolled hooks are invisible to `shipkit check`: a repo reports green
4
+ // while its next commit is already doomed, and nothing can enumerate what
5
+ // the hooks enforce. `shipkit hooks list` answers that without reading shell.
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { commitMessageOffenses } from './commit-message.ts'
9
+ import { runLeakGuard } from './leak-guard.ts'
10
+ import { spawnWithTimeout } from '../core/spawn-timeout.ts'
11
+ import type { RepoConfig } from '../repo-config.ts'
12
+
13
+ export type HooksConfig = {
14
+ /** repo-specific commands run in the pre-commit gate, after the built-ins.
15
+ * Non-zero exit blocks the commit; stdout/stderr are shown. */
16
+ preCommit?: { id: string; cmd: string[] }[]
17
+ /** repo-specific commands run on the commit message; the message file path
18
+ * is appended as the last argument. */
19
+ commitMsg?: { id: string; cmd: string[] }[]
20
+ /** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
21
+ keywordFile?: string
22
+ /** disable a built-in gate, visible in `hooks list`, never silent */
23
+ disable?: ('leak-guard' | 'commit-message')[]
24
+ }
25
+
26
+ const HOOK_DIR = 'hooks'
27
+ const HOOKS = ['pre-commit', 'commit-msg'] as const
28
+
29
+ /** wire `core.hooksPath` and write the two hook bodies. Idempotent. */
30
+ export function installHooks(root: string, binInvocation: string): { written: string[]; note: string } {
31
+ const dir = join(root, HOOK_DIR)
32
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
33
+ const written: string[] = []
34
+ for (const h of HOOKS) {
35
+ const body = h === 'commit-msg'
36
+ ? `#!/bin/sh\nexec ${binInvocation} hooks commit-msg "$1"\n`
37
+ : `#!/bin/sh\nexec ${binInvocation} hooks pre-commit\n`
38
+ const path = join(dir, h)
39
+ const prev = existsSync(path) ? readFileSync(path, 'utf8') : ''
40
+ if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
41
+ }
42
+ const cfg = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
43
+ return {
44
+ written,
45
+ note: cfg.exitCode === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.exitCode}) — hooks will not fire`,
46
+ }
47
+ }
48
+
49
+ export type GateOutcome = { id: string; ok: boolean; lines: string[] }
50
+
51
+ /** the pre-commit gate: built-in leak guard, then the repo's own commands. */
52
+ export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
53
+ const h = config.hooks ?? {}
54
+ const out: GateOutcome[] = []
55
+ if (!(h.disable ?? []).includes('leak-guard')) {
56
+ const r = runLeakGuard(root, { keywordFile: h.keywordFile })
57
+ if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
58
+ else if (r.status === 'clean') out.push({ id: 'leak-guard', ok: true, lines: [`ok: leak-guard clean (${r.patterns} pattern(s))`] })
59
+ else out.push({
60
+ id: 'leak-guard',
61
+ ok: false,
62
+ lines: [
63
+ `FAIL: leak-guard matched ${r.hits.length} banned pattern(s) — this commit would leak private content`,
64
+ ...r.hits.map((x) => ` ${x.where}: ${x.pattern}`),
65
+ ` fix: remove the content, or edit the keyword file if the pattern is wrong`,
66
+ ],
67
+ })
68
+ }
69
+ for (const c of h.preCommit ?? []) {
70
+ const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
71
+ out.push({
72
+ id: c.id,
73
+ ok: r.code === 0,
74
+ lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
75
+ })
76
+ }
77
+ return out
78
+ }
79
+
80
+ /** the commit-msg gate: the shared message predicate, then the repo's own. */
81
+ export async function runCommitMsg(root: string, config: RepoConfig, messageFile: string): Promise<GateOutcome[]> {
82
+ const h = config.hooks ?? {}
83
+ const out: GateOutcome[] = []
84
+ const message = readFileSync(messageFile, 'utf8')
85
+ if (!(h.disable ?? []).includes('commit-message')) {
86
+ const offenses = commitMessageOffenses(message)
87
+ out.push(offenses.length === 0
88
+ ? { id: 'commit-message', ok: true, lines: ['ok: commit message clean (no dash punctuation, no AI attribution)'] }
89
+ : {
90
+ id: 'commit-message',
91
+ ok: false,
92
+ lines: [
93
+ `FAIL: commit message has ${offenses.length} offense(s)`,
94
+ ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
95
+ ],
96
+ })
97
+ }
98
+ // the leak guard also reads the MESSAGE, a private name pasted into a
99
+ // commit body is as public as one pasted into a file
100
+ if (!(h.disable ?? []).includes('leak-guard')) {
101
+ const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, text: [{ label: 'commit message', content: message }] })
102
+ if (r.status === 'hit')
103
+ out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map((x) => ` ${x.where}: ${x.pattern}`)] })
104
+ }
105
+ for (const c of h.commitMsg ?? []) {
106
+ const r = await spawnWithTimeout([...c.cmd, messageFile], { cwd: root, timeoutMs: 60_000 })
107
+ out.push({
108
+ id: c.id,
109
+ ok: r.code === 0,
110
+ lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
111
+ })
112
+ }
113
+ return out
114
+ }
115
+
116
+ /** what blocks a commit here, the enumeration hand-rolled hooks never had. */
117
+ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
118
+ const h = config.hooks ?? {}
119
+ const off = new Set(h.disable ?? [])
120
+ const kw = h.keywordFile ?? '.rv-private/banned-keywords.txt'
121
+ // a gate listed as active but unable to fire is worse than no listing:
122
+ // this surface exists so nobody has to guess
123
+ const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
124
+ return [
125
+ `pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
126
+ ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
127
+ `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
128
+ `commit-msg leak-guard${leakState} — the message text`,
129
+ ...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
130
+ ]
131
+ }
package/src/init-repo.ts CHANGED
@@ -3,11 +3,13 @@
3
3
  // deps — every gap is printed, never silent). Plain module behind cmd_Init.
4
4
  // Refuses to overwrite an existing config; script merge never clobbers
5
5
  // existing keys. `bin` switches script bodies from the npm bin to an
6
- // in-repo-source CLI path (`bun <path> ci`) for repos that consume shipkit
7
- // by path instead of from node_modules.
6
+ // in-repo-source CLI path for repos that consume shipkit by path instead of
7
+ // from node_modules. Only bodies shipkit cannot express as a verb are
8
+ // scaffolded: an alias script would be a second name for one capability.
8
9
  import { existsSync, readFileSync, writeFileSync } from 'fs'
9
10
  import { join } from 'path'
10
11
  import { canonicalBodies } from './core/canonical-scripts.ts'
12
+ import { hasTestFiles } from './rules/SK001-package-json-shape.ts'
11
13
  import { reportToMotherhome } from './core/motherhome.ts'
12
14
  import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
13
15
  import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
@@ -19,6 +21,9 @@ export default defineRepo({
19
21
  venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
20
22
  social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
21
23
  tasks: [], // repo commit-gate tasks; deps double as race guards
24
+ // the mandated reviewer agent type (rule SK008) — uncomment with YOUR
25
+ // standing brief, then \`shipkit check --fix SK008\` writes the agent file:
26
+ // reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
22
27
  // health checks: no config — drop a <name>.check.ts file anywhere,
23
28
  // \`shipkit check\` auto-discovers and runs it (wire output, exit code)
24
29
  })
@@ -56,7 +61,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
56
61
  // canonical script bodies — ONE source (core/canonical-scripts.ts),
57
62
  // shared with rule SK001's autofix; vocabulary owned by
58
63
  // meta/rv-conventions/package-json.md
64
+ // `test` is scaffolded only once the repo has tests: `bun test` exits
65
+ // non-zero with none, and a scaffold that reds the gate is a bad scaffold
59
66
  for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
67
+ if (k === 'test' && !hasTestFiles(root)) continue
60
68
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
61
69
  else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
62
70
  }
@@ -73,8 +81,8 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
73
81
  const floor = resolveFloor(defineRepo({ name }), root)
74
82
  for (const t of floor.tasks) console.log(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
75
83
  for (const u of floor.unfilled) console.log(`skip: ${u.id} (${u.reason})`)
76
- // scripts call the `shipkit` bin — dead on arrival unless the package is
77
- // actually installed (field bug 2026-07-22: init via bunx, no dep, exit 127)
84
+ // every verb comes from the `shipkit` bin — dead on arrival unless the
85
+ // package is actually installed (bunx runs without adding the dep)
78
86
  if (opts.bin == null) {
79
87
  try {
80
88
  Bun.resolveSync('rvlib-shipkit', root)
@@ -83,7 +91,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
83
91
  console.log(' fix: bun add -d rvlib-shipkit')
84
92
  }
85
93
  }
86
- console.log('next: fill venues/social in repo.config.ts, then `bun run ci`')
94
+ console.log('next: fill venues/social in repo.config.ts, then `shipkit ci`')
87
95
  console.log('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
88
96
  return 0
89
97
  }
@@ -35,8 +35,8 @@ const siblingConfigDirs = (home: string): string[] => {
35
35
  * (`:` or `,` separated) PLUS ~/.claude PLUS any sibling ~/.claude-<name>
36
36
  * holding a projects/ dir. Additive on purpose — a second local account keeps
37
37
  * its own transcripts, and the env var only names the account running RIGHT
38
- * NOW, so honoring it alone hides every session of the other one (field bug
39
- * 2026-07-24: a corpus mined that way lost a third of his prompts). */
38
+ * NOW, so honoring it alone hides every session of the other one — a corpus
39
+ * mined from a single root is silently partial. */
40
40
  export function discoverConfigRoots(home: string = homedir()): ConfigRoot[] {
41
41
  const fromEnv = (process.env.CLAUDE_CONFIG_DIR ?? '')
42
42
  .split(/[:,]/)
@@ -137,8 +137,7 @@ export function promptTextOf(line: string): { text: string; entry: RawEntry } |
137
137
  /** A prompt he typed while the agent was still working: the harness parks it as
138
138
  * `{type:'queue-operation', operation:'enqueue', content}` and, if the run ends
139
139
  * first, it may NEVER become a user message. Skipping these loses exactly the
140
- * mid-flight corrections and standing conventions a retrospective is for
141
- * (measured 2026-07-26: 72 such texts invisible in this repo alone). */
140
+ * mid-flight corrections and standing conventions a retrospective is for */
142
141
  export function queuedTextOf(line: string): { text: string; entry: RawEntry } | null {
143
142
  let parsed: unknown
144
143
  try {
@@ -0,0 +1,53 @@
1
+ // reading a versioned CHANGELOG, in one place, because two readers of the
2
+ // same file always drift and the release then ships notes nobody wrote.
3
+ // House convention: newest first, one `## <version>` per released version,
4
+ // `## Unreleased` on top while work accumulates.
5
+
6
+ /** matches `## 1.2.3`, `## [1.2.3]`, and a dated heading in any of the dash
7
+ * shapes real changelogs use (`## 1.2.3 — 2026-07-28`). The dash here is
8
+ * DATA in someone's changelog, not prose this repo controls. */
9
+ export const changelogHeading = (version: string): RegExp =>
10
+ new RegExp(`^##\\s*\\[?${version.replace(/\./g, '\\.')}\\]?\\s*(?:[-–—:(].*)?$`)
11
+
12
+ /** the `## <version>` section of a versioned changelog, as release notes.
13
+ * Throws when the section is missing or empty: a release with no notes is a
14
+ * release nobody can read later. */
15
+ export function extractChangelogSection(p: { changelog: string; version: string }): string {
16
+ const lines = p.changelog.split('\n')
17
+ const head = changelogHeading(p.version)
18
+ const start = lines.findIndex((l) => head.test(l.trim()))
19
+ if (start === -1) throw new Error(`CHANGELOG has no "## ${p.version}" section — write the notes, then release`)
20
+ let end = lines.length
21
+ for (let i = start + 1; i < lines.length; i++)
22
+ if (lines[i]?.startsWith('## ')) { end = i; break }
23
+ const body = lines.slice(start + 1, end).join('\n').trim()
24
+ if (body === '') throw new Error(`CHANGELOG section "## ${p.version}" is empty — write the notes, then release`)
25
+ return body
26
+ }
27
+
28
+ export const hasVersionSection = (changelog: string, version: string): boolean =>
29
+ changelog.split('\n').some((l) => changelogHeading(version).test(l.trim()))
30
+
31
+ const UNRELEASED = /^##\s*\[?unreleased\]?\s*$/i
32
+
33
+ export const hasUnreleasedHeading = (changelog: string): boolean =>
34
+ changelog.split('\n').some((l) => UNRELEASED.test(l.trim()))
35
+
36
+ /** rename the Unreleased heading to the new version. Returns the new text, or
37
+ * an error when there is no such section or it carries no notes: a version
38
+ * with empty notes is a version nobody can read later. */
39
+ export function promoteUnreleased(changelog: string, version: string): { text: string } | { error: string } {
40
+ const lines = changelog.split('\n')
41
+ const at = lines.findIndex((l) => UNRELEASED.test(l.trim()))
42
+ if (at === -1) return { error: `CHANGELOG has no "## Unreleased" section — write the notes for this version under one, then bump` }
43
+ let end = lines.length
44
+ for (let i = at + 1; i < lines.length; i++) if (lines[i]?.startsWith('## ')) { end = i; break }
45
+ if (lines.slice(at + 1, end).join('\n').trim() === '')
46
+ return { error: `the "## Unreleased" section is empty — write what changed, then bump` }
47
+ lines[at] = `## ${version}`
48
+ return { text: lines.join('\n') }
49
+ }
50
+
51
+ /** does this unit have notes waiting? `--all` selects on exactly this: having
52
+ * something to say IS the decision to ship. */
53
+ export const hasUnreleasedNotes = (changelog: string): boolean => !('error' in promoteUnreleased(changelog, '0.0.0'))