rvlib-shipkit 0.14.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 (65) hide show
  1. package/CHANGELOG.md +129 -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 +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/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 +75 -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/run-hooks.ts +131 -0
  32. package/src/init-repo.ts +10 -5
  33. package/src/reflect/prompt-corpus.ts +3 -4
  34. package/src/release/changelog.ts +53 -0
  35. package/src/release/git-facts.ts +133 -0
  36. package/src/release/run-bump.ts +419 -0
  37. package/src/release/run-release.ts +267 -0
  38. package/src/release/run-releases.ts +230 -0
  39. package/src/release/unit-gate.ts +67 -0
  40. package/src/release/units.ts +246 -0
  41. package/src/repo-config.ts +56 -2
  42. package/src/rules/SK001-package-json-shape.ts +39 -17
  43. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  44. package/src/rules/SK003-claude-md-shape.ts +2 -1
  45. package/src/rules/SK004-gitignore-policy.ts +1 -0
  46. package/src/rules/SK005-rvlib-versions.ts +22 -3
  47. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  48. package/src/rules/SK007-github-visibility.ts +5 -1
  49. package/src/rules/SK008-reviewer-agent.ts +1 -0
  50. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  51. package/src/rules/SK010-undeclared-release-unit.ts +114 -0
  52. package/src/rules/rule-registry.ts +26 -6
  53. package/src/rules/rule.ts +8 -1
  54. package/src/rules/run-fix.ts +15 -8
  55. package/src/serve/hub-app/col-card.tsx +2 -3
  56. package/src/serve/hub-app/hub-app.tsx +6 -8
  57. package/src/serve/hub-app/hub-st.ts +4 -7
  58. package/src/serve/hub-app/media-view.tsx +2 -2
  59. package/src/serve/run-serve.ts +4 -5
  60. package/src/serve/serve-hub.ts +2 -3
  61. package/src/shipkit.ts +9 -1
  62. package/src/tui/checks-panel.ts +1 -1
  63. package/src/tui/run-tui.ts +2 -3
  64. package/src/tui/tasks-panel.ts +10 -4
  65. package/src/tui/tui-store.ts +2 -2
@@ -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'
@@ -59,7 +61,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
59
61
  // canonical script bodies — ONE source (core/canonical-scripts.ts),
60
62
  // shared with rule SK001's autofix; vocabulary owned by
61
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
62
66
  for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
67
+ if (k === 'test' && !hasTestFiles(root)) continue
63
68
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
64
69
  else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
65
70
  }
@@ -76,8 +81,8 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
76
81
  const floor = resolveFloor(defineRepo({ name }), root)
77
82
  for (const t of floor.tasks) console.log(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
78
83
  for (const u of floor.unfilled) console.log(`skip: ${u.id} (${u.reason})`)
79
- // scripts call the `shipkit` bin — dead on arrival unless the package is
80
- // 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)
81
86
  if (opts.bin == null) {
82
87
  try {
83
88
  Bun.resolveSync('rvlib-shipkit', root)
@@ -86,7 +91,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
86
91
  console.log(' fix: bun add -d rvlib-shipkit')
87
92
  }
88
93
  }
89
- 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`')
90
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)')
91
96
  return 0
92
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'))
@@ -0,0 +1,133 @@
1
+ // the git questions a release asks, in one place: which remote to push to,
2
+ // what the worktree is hiding, which tags exist here but nowhere else.
3
+ // Deliberately dependency-free (no config load, no ci) so a check script can
4
+ // import it without dragging the gate in.
5
+ import type { RepoConfig } from '../repo-config.ts'
6
+
7
+ export function git(root: string, args: string[]): { code: number; out: string } {
8
+ const p = Bun.spawnSync(['git', ...args], { cwd: root })
9
+ return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}`.trim() }
10
+ }
11
+
12
+ /** the push remote: the current branch's upstream when it has one, else the
13
+ * repo's single remote, else refuse. Never prefers a name: a repo whose
14
+ * remote is not called `origin` is normal, and guessing strands the tag
15
+ * somewhere nobody looks. */
16
+ export function deriveRemote(root: string): { remote: string } | { error: string } {
17
+ const branch = git(root, ['branch', '--show-current']).out
18
+ if (branch !== '') {
19
+ const r = git(root, ['config', '--get', `branch.${branch}.remote`])
20
+ if (r.code === 0 && r.out !== '') return { remote: r.out }
21
+ }
22
+ const r = git(root, ['remote'])
23
+ if (r.code !== 0) return { error: 'git remote failed (not a repo?)' }
24
+ const remotes = r.out.split('\n').map((l) => l.trim()).filter((l) => l !== '')
25
+ if (remotes.length === 0) return { error: 'no git remote configured' }
26
+ if (remotes.length === 1) return { remote: remotes[0]! }
27
+ return { error: `branch '${branch === '' ? '(detached)' : branch}' has no upstream and ${remotes.length} remotes exist (${remotes.join(', ')}) — set one with git push -u <remote> <branch>` }
28
+ }
29
+
30
+ /** the worktree's dirty paths, minus the runner's own log dir. running the
31
+ * gate creates that dir, so counting it would make every gated release refuse
32
+ * in any repo that does not gitignore it. */
33
+ export function dirtyPaths(root: string, config: RepoConfig, pathspec?: string): string {
34
+ const logRoot = (config.logDir ?? 'tmp/shipkit-ci').replace(/^\.\//, '').split('/')[0]!
35
+ const args = pathspec != null && pathspec !== '' ? ['status', '--porcelain', '--', pathspec] : ['status', '--porcelain']
36
+ return git(root, args).out
37
+ .split('\n')
38
+ .filter((l) => l.trim() !== '' && !l.slice(3).trim().replace(/^"|"$/g, '').startsWith(`${logRoot}/`) && l.slice(3).trim() !== `${logRoot}/`)
39
+ .join('\n')
40
+ }
41
+
42
+ export const tagExistsLocally = (root: string, tag: string): boolean =>
43
+ git(root, ['rev-parse', '--quiet', '--verify', `refs/tags/${tag}`]).code === 0
44
+
45
+ export type RemoteTags = { tags: Set<string> } | { error: string }
46
+ /** tag name → the COMMIT it names on the remote (the peeled oid when the tag
47
+ * is annotated), so two tags of the same name can be compared by what they
48
+ * describe rather than by their spelling */
49
+ export type RemoteTagRefs = { refs: Map<string, string> } | { error: string }
50
+
51
+ /** every tag the remote carries, with its commit. One `ls-remote` per repo,
52
+ * never one per tag: the caller compares locally. Deliberately unpatterned,
53
+ * because `ls-remote <pattern>` drops the peeled `^{}` line and leaves only
54
+ * the tag OBJECT id, which differs between two tags of the same commit. */
55
+ export function remoteTagRefs(root: string, remote: string): RemoteTagRefs {
56
+ const p = Bun.spawnSync(['git', 'ls-remote', '--tags', remote], { cwd: root, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
57
+ if (p.exitCode !== 0) return { error: `${p.stderr.toString()}${p.stdout.toString()}`.trim().split('\n').at(-1) ?? 'ls-remote failed' }
58
+ const refs = new Map<string, string>()
59
+ for (const line of p.stdout.toString().split('\n')) {
60
+ const [oid, ref] = line.split('\t')
61
+ const name = ref?.split('refs/tags/')[1]
62
+ if (oid == null || name == null || name === '') continue
63
+ const peeled = name.endsWith('^{}')
64
+ const key = peeled ? name.slice(0, -3) : name
65
+ // the peeled line always wins, whichever order it arrives in
66
+ if (peeled || !refs.has(key)) refs.set(key, oid)
67
+ }
68
+ return { refs }
69
+ }
70
+
71
+ export function remoteTags(root: string, remote: string): RemoteTags {
72
+ const r = remoteTagRefs(root, remote)
73
+ return 'error' in r ? r : { tags: new Set(r.refs.keys()) }
74
+ }
75
+
76
+ export type RemoteTagState =
77
+ | { state: 'absent' }
78
+ | { state: 'same'; oid: string }
79
+ /** the name is taken on the remote by another commit — the version is burnt */
80
+ | { state: 'different'; remoteOid: string; localOid: string | null }
81
+ | { state: 'unreadable'; error: string }
82
+
83
+ /** what the remote says about ONE tag. Comparing NAMES is what let a burnt
84
+ * version read as "fully released": the tag was there, describing a tree
85
+ * nobody released. */
86
+ export function remoteTagState(root: string, remote: string, tag: string): RemoteTagState {
87
+ const r = remoteTagRefs(root, remote)
88
+ if ('error' in r) return { state: 'unreadable', error: r.error }
89
+ const remoteOid = r.refs.get(tag)
90
+ if (remoteOid == null) return { state: 'absent' }
91
+ const local = git(root, ['rev-parse', `${tag}^{}`])
92
+ const localOid = local.code === 0 ? local.out : null
93
+ return localOid === remoteOid ? { state: 'same', oid: remoteOid } : { state: 'different', remoteOid, localOid }
94
+ }
95
+
96
+ export type MergeProbe =
97
+ | { state: 'clean' }
98
+ | { state: 'conflicts'; paths: string[] }
99
+ /** this git could not answer, so the caller must not claim either way */
100
+ | { state: 'unknown'; reason: string }
101
+
102
+ /** would `from` merge into `tip` cleanly? Answered from the object database:
103
+ * `git merge-tree --write-tree` (git 2.38+) writes nothing but objects, so no
104
+ * ref moves and no file is checked out. That is what lets a REHEARSAL answer
105
+ * the merge question without touching the branch a human treats as a gate.
106
+ *
107
+ * exit 1 covers both conflicts and a ref git could not resolve, so the tree
108
+ * oid on stdout is what separates them: no oid means no merge was attempted. */
109
+ export function mergeProbe(root: string, tip: string, from: string): MergeProbe {
110
+ // env is passed explicitly: Bun resolves argv[0] against the PATH it
111
+ // captured at startup unless it is, so an older git ahead on PATH would
112
+ // never be reached, here or in a test that stages one
113
+ const p = Bun.spawnSync(['git', 'merge-tree', '--write-tree', '--name-only', tip, from], { cwd: root, env: process.env })
114
+ const out = p.stdout.toString()
115
+ const [head, ...rest] = out.split('\n')
116
+ const wroteTree = head != null && /^[0-9a-f]{40}$/.test(head.trim())
117
+ if (p.exitCode === 0 && wroteTree) return { state: 'clean' }
118
+ if (p.exitCode === 1 && wroteTree)
119
+ return { state: 'conflicts', paths: (rest.join('\n').split('\n\n')[0] ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '') }
120
+ const reason = `${p.stderr.toString()}${out}`.trim().split('\n').filter((l) => l !== '').at(-1) ?? `git merge-tree exited ${p.exitCode}`
121
+ return { state: 'unknown', reason: `${reason} (needs git 2.38+)` }
122
+ }
123
+
124
+ /** local tags matching any of the release units' tag shapes that the remote
125
+ * does not have. A stranded tag is invisible where he looks (GitHub), so it
126
+ * is a failure, not a note. */
127
+ export function strandedTags(p: { root: string; globs: string[]; remote: string }): { stranded: string[]; local: string[] } | { error: string } {
128
+ const local = p.globs.flatMap((g) => git(p.root, ['tag', '-l', g]).out.split('\n').map((l) => l.trim()).filter((l) => l !== ''))
129
+ const uniq = [...new Set(local)].sort()
130
+ const r = remoteTags(p.root, p.remote)
131
+ if ('error' in r) return r
132
+ return { stranded: uniq.filter((t) => !r.tags.has(t)), local: uniq }
133
+ }