rvlib-shipkit 0.11.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +17 -3
  3. package/package.json +6 -3
  4. package/src/asks/asks-queue.ts +8 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Reflect.ts +116 -0
  12. package/src/cli/cmd_Rules.ts +32 -0
  13. package/src/cli/cmd_Serve.ts +1 -1
  14. package/src/cli.ts +5 -2
  15. package/src/collections/actions.ts +19 -0
  16. package/src/collections/collection-types.ts +13 -0
  17. package/src/collections/graphs.ts +34 -0
  18. package/src/collections/pings.ts +5 -1
  19. package/src/collections/posts.ts +20 -1
  20. package/src/collections/providers.ts +163 -50
  21. package/src/contracts/apply-contracts.ts +47 -0
  22. package/src/contracts/contract-types.ts +5 -2
  23. package/src/contracts/contracts-engine.ts +13 -3
  24. package/src/contracts/manifests/discord.contract.ts +1 -1
  25. package/src/contracts/manifests/identity.contract.ts +1 -1
  26. package/src/contracts/manifests/itch.contract.ts +19 -2
  27. package/src/contracts/run-contracts.ts +8 -3
  28. package/src/core/canonical-scripts.ts +46 -0
  29. package/src/core/check-suite.ts +6 -3
  30. package/src/core/locked-write.ts +39 -0
  31. package/src/core/motherhome.ts +87 -0
  32. package/src/core/run-check.ts +28 -15
  33. package/src/core/run-ci.ts +4 -0
  34. package/src/core/write-status.ts +7 -26
  35. package/src/init-repo.ts +27 -11
  36. package/src/reflect/prompt-corpus.ts +305 -0
  37. package/src/repo-config.ts +30 -38
  38. package/src/rules/SK001-package-json-shape.ts +156 -0
  39. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  40. package/src/rules/SK003-claude-md-shape.ts +64 -0
  41. package/src/rules/SK004-gitignore-policy.ts +105 -0
  42. package/src/rules/SK005-rvlib-versions.ts +99 -0
  43. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  44. package/src/rules/SK007-github-visibility.ts +63 -0
  45. package/src/rules/SK008-reviewer-agent.ts +98 -0
  46. package/src/rules/rule-registry.ts +34 -0
  47. package/src/rules/rule.ts +62 -0
  48. package/src/rules/run-fix.ts +57 -0
  49. package/src/serve/hub-app/col-card.tsx +192 -0
  50. package/src/serve/hub-app/cols-svc.ts +136 -0
  51. package/src/serve/hub-app/geometry-view.tsx +155 -0
  52. package/src/serve/hub-app/health-svc.ts +49 -0
  53. package/src/serve/hub-app/hub-app.tsx +236 -215
  54. package/src/serve/hub-app/hub-st.ts +169 -0
  55. package/src/serve/hub-app/hub-types.ts +72 -0
  56. package/src/serve/hub-app/hub.css +286 -59
  57. package/src/serve/hub-app/index.html +1 -1
  58. package/src/serve/hub-app/md.ts +24 -0
  59. package/src/serve/hub-app/media-svc.ts +429 -0
  60. package/src/serve/hub-app/media-view.tsx +299 -0
  61. package/src/serve/run-serve.ts +23 -9
  62. package/src/serve/serve-hub.ts +62 -47
  63. package/src/shipkit.ts +9 -5
  64. package/src/assets/server/studio-page.ts +0 -687
  65. package/src/core/package-json-check.ts +0 -88
  66. package/src/serve/hub-pages.ts +0 -38
@@ -0,0 +1,105 @@
1
+ // SK004 — the .gitignore policy block (meta/contracts.md git policy; tmp/
2
+ // included because shipkit itself writes tmp/.shipkit-cache in every
3
+ // repo). A marker-keyed managed block so the always-gitignored contract
4
+ // surfaces can never silently rot per repo. Ecosystem-specific names the
5
+ // published lib may not carry ride as repo-contributed lines:
6
+ // `defineRepo({ gitignore: [...] })` renders them INSIDE the block, so the
7
+ // whole policy is machine-enforced. A well-formed block is rewritten IN
8
+ // PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
9
+ // last-match-wins) keep their position; everything outside the block is
10
+ // never touched. `shipkit init` writes the same block through this fixer.
11
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
12
+ import { join } from 'path'
13
+ import type { RepoConfig } from '../repo-config.ts'
14
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
15
+
16
+ const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
17
+ const END = '# <<< shipkit gitignore policy'
18
+ const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
19
+
20
+ const POLICY_LINES = [
21
+ '.rv-reflections/',
22
+ '.rv-social/',
23
+ '.rv-journal/',
24
+ '.rv-domain/',
25
+ '.rv-assets/out/',
26
+ '# venue folders stay ignored until the listing is live — override below the block',
27
+ '.rv-itch/',
28
+ '.rv-steam/',
29
+ '.rv-discord/',
30
+ 'CLAUDE.local.md',
31
+ 'tmp/',
32
+ ]
33
+
34
+ export function expectedBlock(config: RepoConfig): string {
35
+ const extra = config.gitignore ?? []
36
+ return [BEGIN, ...POLICY_LINES, ...(extra.length > 0 ? [CONTRIB, ...extra] : []), END].join('\n')
37
+ }
38
+
39
+ export function policyPatterns(config: RepoConfig): string[] {
40
+ return [...POLICY_LINES.filter((l) => !l.startsWith('#')), ...(config.gitignore ?? [])]
41
+ }
42
+
43
+ type Parsed = { text: string; crlf: boolean; lines: string[]; b: number; e: number }
44
+
45
+ function parse(root: string): Parsed | null {
46
+ const p = join(root, '.gitignore')
47
+ if (!existsSync(p)) return null
48
+ const text = readFileSync(p, 'utf8')
49
+ const crlf = text.includes('\r\n')
50
+ const lines = text.split('\n').map((l) => l.replace(/\r$/, ''))
51
+ return { text, crlf, lines, b: lines.indexOf(BEGIN), e: lines.indexOf(END) }
52
+ }
53
+
54
+ type BlockState = 'missing-file' | 'missing-block' | 'corrupted' | 'stale' | 'current'
55
+
56
+ function blockState(root: string, config: RepoConfig): BlockState {
57
+ const f = parse(root)
58
+ if (f == null) return 'missing-file'
59
+ if (f.b === -1 && f.e === -1) return 'missing-block'
60
+ if (f.b === -1 || f.e === -1 || f.e < f.b) return 'corrupted'
61
+ return f.lines.slice(f.b, f.e + 1).join('\n') === expectedBlock(config) ? 'current' : 'stale'
62
+ }
63
+
64
+ function writeBlock(root: string, config: RuleEnv['config']): void {
65
+ const p = join(root, '.gitignore')
66
+ const f = parse(root)
67
+ const block = expectedBlock(config).split('\n')
68
+ let out: string[]
69
+ if (f == null) out = block
70
+ else if (f.b !== -1 && f.e !== -1 && f.e >= f.b)
71
+ // well-formed block: rewrite IN PLACE so below-block overrides
72
+ // (last-match-wins) keep working
73
+ out = [...f.lines.slice(0, f.b), ...block, ...f.lines.slice(f.e + 1)]
74
+ else {
75
+ // absent or corrupted: drop stray markers, keep every repo line
76
+ // visible, append the canonical block
77
+ const kept = (f.b === -1 && f.e === -1 ? f.lines : f.lines.filter((l) => l !== BEGIN && l !== END))
78
+ while (kept.length > 0 && kept.at(-1)!.trim() === '') kept.pop()
79
+ out = kept.length > 0 ? [...kept, '', ...block] : block
80
+ }
81
+ const eol = f?.crlf === true ? '\r\n' : '\n'
82
+ writeFileSync(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
83
+ }
84
+
85
+ export const gitignorePolicy: Rule = {
86
+ id: 'SK004',
87
+ title: '.gitignore',
88
+ owner: 'meta/contracts.md',
89
+ lint: (t, env) => {
90
+ const s = blockState(env.root, env.config)
91
+ switch (s) {
92
+ case 'missing-file': return t.fail('.gitignore missing — the contract git policy is unenforced')
93
+ case 'missing-block': return t.fail('.gitignore has no shipkit policy block — contract surfaces can leak into git')
94
+ case 'corrupted': return t.fail('.gitignore policy block corrupted (unpaired marker)')
95
+ case 'stale': return t.fail('.gitignore policy block is stale — the policy or repo-contributed lines moved since it was written')
96
+ case 'current': return t.ok(`.gitignore policy block current (${policyPatterns(env.config).length} patterns)`)
97
+ }
98
+ },
99
+ fix: (env: RuleEnv): FixResult => {
100
+ const before = blockState(env.root, env.config)
101
+ if (before === 'current') return { changed: false, summary: 'policy block already current' }
102
+ writeBlock(env.root, env.config)
103
+ return { changed: true, summary: before === 'missing-file' ? '.gitignore created with the policy block' : before === 'missing-block' ? 'policy block appended (repo lines untouched)' : before === 'corrupted' ? 'policy block rebuilt (stray markers dropped, repo lines kept visible)' : 'policy block rewritten in place (repo lines untouched)' }
104
+ },
105
+ }
@@ -0,0 +1,99 @@
1
+ // SK005 — rvlib-* dependency freshness, and the self-update that keeps
2
+ // every repo converging on the current toolkit (a stale repo silently
3
+ // missing months of rules is the failure; machine-clearable drift clears
4
+ // ITSELF). Per rvlib-* dep:
5
+ // link: → the symlink must resolve (a broken link dies cryptically at
6
+ // import time otherwise) — nothing to update, code is live
7
+ // semver → installed vs npm latest; NEWER LIVE → this lint runs
8
+ // `bun update --latest` itself, loudly, guarded:
9
+ // - SHIPKIT_FROZEN=1 (`check --frozen`) → WARN, never touch
10
+ // - package.json/lockfile git-dirty (or no git) → WARN deferred,
11
+ // a tool must not mix its writes into human-staged work
12
+ // registry unreachable → loud skip, never red (offline is not a defect)
13
+ // Committing the bump stays with the working agent — shipkit never commits.
14
+ // Clean passes cache 24h (rule ttl), so the registry is fetched at most
15
+ // daily per repo; skip/WARN/FAIL runs are never cached. peerDependencies
16
+ // are NOT scanned: keeping a published lib's peer ranges consistent after
17
+ // a bump stays the agent's job.
18
+ import { existsSync, readFileSync } from 'fs'
19
+ import { join } from 'path'
20
+ import type { CheckCtx } from '../core/check-ctx.ts'
21
+ import type { Rule, RuleEnv } from './rule.ts'
22
+
23
+ type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
24
+
25
+ const installedVersion = (root: string, name: string): string | null => {
26
+ const p = join(root, 'node_modules', name, 'package.json')
27
+ if (!existsSync(p)) return null
28
+ try { return (JSON.parse(readFileSync(p, 'utf8')) as Pkg).version ?? null } catch { return null }
29
+ }
30
+
31
+ async function latestVersion(t: CheckCtx, name: string): Promise<string> {
32
+ const res = await t.fetch(`https://registry.npmjs.org/${name}/latest`)
33
+ if (!res.ok) throw new Error(`registry ${res.status}`)
34
+ const v = ((await res.json()) as Pkg).version
35
+ if (v == null) throw new Error('registry payload has no version')
36
+ return v
37
+ }
38
+
39
+ export const rvlibVersions: Rule = {
40
+ id: 'SK005',
41
+ title: 'rvlib versions',
42
+ owner: 'build/shipkit.md',
43
+ ttl: '24h',
44
+ lint: async (t, env: RuleEnv) => {
45
+ let pkg: Pkg
46
+ try { pkg = JSON.parse(readFileSync(join(env.root, 'package.json'), 'utf8')) as Pkg } catch { return t.note('no readable package.json — rvlib versions not applicable') }
47
+ const deps = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter(([n]) => n.startsWith('rvlib-'))
48
+ if (deps.length === 0) return t.ok('no rvlib-* deps')
49
+
50
+ const stale: Array<{ name: string; installed: string; latest: string }> = []
51
+ for (const [name, spec] of deps) {
52
+ if (spec.startsWith('link:')) {
53
+ if (installedVersion(env.root, name) == null) t.fail(`${name} is link: but the symlink does not resolve — re-run \`bun link ${name}\` (a broken link dies cryptically at import time)`)
54
+ else t.ok(`${name} (linked, live)`)
55
+ continue
56
+ }
57
+ const installed = installedVersion(env.root, name)
58
+ if (installed == null) { t.fail(`${name} declared (${spec}) but not installed — run: bun install`); continue }
59
+ let latest: string
60
+ try { latest = await latestVersion(t, name) } catch (e) {
61
+ t.lines.push({ kind: 'skip', text: `${name} ${installed} — registry unreachable (${String(e).slice(0, 80)}), version drift unknown` })
62
+ continue
63
+ }
64
+ // real semver order, not string difference — a dist-tag pointing
65
+ // BEHIND the installed version (prerelease, unpublish window)
66
+ // must never trigger a silent downgrade
67
+ const cmp = Bun.semver.order(latest, installed)
68
+ if (cmp === 0) t.ok(`${name} ${installed} (latest)`)
69
+ else if (cmp < 0) t.ok(`${name} ${installed} ahead of npm latest ${latest} (prerelease or dist-tag lag) — not touching`)
70
+ else stale.push({ name, installed, latest })
71
+ }
72
+ if (stale.length === 0) return
73
+
74
+ const drift = stale.map((s) => `${s.name} ${s.installed} → ${s.latest}`).join(', ')
75
+ if (process.env['SHIPKIT_FROZEN'] === '1') {
76
+ t.warn(`newer rvlib live: ${drift} (frozen — apply with: bun update --latest ${stale.map((s) => s.name).join(' ')})`)
77
+ return
78
+ }
79
+ const dirty = await t.exec(['git', 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb'], { cwd: env.root })
80
+ if (dirty.code !== 0 || dirty.out.trim() !== '') {
81
+ t.warn(`newer rvlib live: ${drift} — self-update deferred (${dirty.code !== 0 ? `git says: ${dirty.out.trim().split('\n')[0] ?? `exit ${dirty.code}`}` : 'package.json/lockfile dirty'}); commit first, then re-run check`)
82
+ return
83
+ }
84
+ const names = stale.map((s) => s.name)
85
+ const up = await t.exec(['bun', 'update', '--latest', ...names], { cwd: env.root, timeoutMs: 120_000 })
86
+ if (up.code !== 0) {
87
+ t.fail(`self-update failed (bun update --latest ${names.join(' ')} exited ${up.code}${up.timedOut ? ', timed out' : ''}) — package.json/lockfile may be partially written, \`git status\` first: ${up.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}`)
88
+ return
89
+ }
90
+ for (const s of stale) {
91
+ const now = installedVersion(env.root, s.name)
92
+ // WARN on purpose: the run MUTATED the repo and a follow-up
93
+ // (restart watchers, commit) is owed — an ok here would be a
94
+ // green, cacheable line replaying a past event as current state
95
+ if (now === s.latest) t.warn(`${s.name} ${s.installed} → ${now} self-updated — restart watchers, commit the bump`)
96
+ else t.warn(`${s.name} updated but reads ${now ?? 'missing'} (wanted ${s.latest}) — check the lockfile`)
97
+ }
98
+ },
99
+ }
@@ -0,0 +1,42 @@
1
+ // SK006 — npm publish safety (meta/rv-conventions/package-json.md).
2
+ // `private: true` is npm's own unconditional publish refusal — THE lock
3
+ // (SK001 enforces its presence). This rule closes the contradiction class
4
+ // around it: a private package carrying publish machinery is either a
5
+ // leftover (dead weight that confuses agents) or a loaded gun for the day
6
+ // someone flips `private` — both fail loud. rvlib-* roots publish by
7
+ // design and are exempt. Lint-only: deleting publish machinery is a
8
+ // judgment call (a `publish` script may publish a SITE, not npm).
9
+ // SCOPE, stated: the checked surface is publishConfig + prepublish*/
10
+ // postpublish + a script literally NAMED `publish` running npm/bun
11
+ // publish. Machinery reachable under OTHER names (release scripts,
12
+ // np/release-it deps, a control room publishing its own sub-libs) is
13
+ // deliberately out of scope — `private: true` on the root already refuses,
14
+ // and sanctioned sub-lib publishing must not turn this rule red.
15
+ import { existsSync, readFileSync } from 'fs'
16
+ import { join } from 'path'
17
+ import type { Rule } from './rule.ts'
18
+
19
+ type Pkg = { name?: string; private?: boolean; publishConfig?: unknown; scripts?: Record<string, string> }
20
+
21
+ export const npmPublishSafety: Rule = {
22
+ id: 'SK006',
23
+ title: 'npm publish safety',
24
+ owner: 'meta/rv-conventions/package-json.md',
25
+ lint: (t, env) => {
26
+ const p = join(env.root, 'package.json')
27
+ if (!existsSync(p)) return t.ok('no package.json — nothing publishable')
28
+ let pkg: Pkg
29
+ try { pkg = JSON.parse(readFileSync(p, 'utf8')) as Pkg } catch { return t.note('package.json unreadable — SK001 reports it') }
30
+ if (pkg.name?.startsWith('rvlib-') === true) return t.ok('rvlib-* package — publishes by design (distribute/npm owns it)')
31
+ if (pkg.private !== true) return t.note('private:true missing — SK001 reports it; publish machinery not judged until the lock is on')
32
+ const machinery: string[] = []
33
+ if (pkg.publishConfig != null) machinery.push('publishConfig')
34
+ for (const s of ['prepublishOnly', 'prepublish', 'postpublish'])
35
+ if (pkg.scripts?.[s] != null) machinery.push(`scripts.${s}`)
36
+ if (pkg.scripts?.['publish'] != null && /npm publish|bun publish/.test(pkg.scripts['publish']))
37
+ machinery.push(`scripts.publish (runs npm/bun publish)`)
38
+ if (machinery.length > 0)
39
+ t.fail(`private package carries publish machinery: ${machinery.join(', ')} — dead weight now, a loaded gun the day private flips; delete it or make the repo a published lib on purpose`)
40
+ else t.ok(`private package — no publishConfig / prepublish* / npm-publishing 'publish' script on the root`)
41
+ },
42
+ }
@@ -0,0 +1,63 @@
1
+ // SK007 — github visibility tripwire (meta/contracts.md pre-public gate).
2
+ // `repo.config.ts visibility: 'private' | 'public'` (absent = 'private')
3
+ // states the INTENT; gh states the FACT; a mismatch in either direction is
4
+ // red — public-while-declared-private is the leak case,
5
+ // private-while-declared-public is a stale config lying to agents. The
6
+ // TARGET repo is resolved explicitly from the remote url (preferring
7
+ // `github`, then `origin`, then the first remote) and passed to gh — gh's
8
+ // own base-repo resolution prefers `upstream`, which in a fork-shaped
9
+ // checkout would confidently validate the WRONG repository. Flipping a
10
+ // repo public stays a human act behind the pre-public audit; this rule is
11
+ // the standing tripwire if it ever happens out of band. Clean passes cache
12
+ // 7d, keyed on the declared value (a flip re-probes immediately); every
13
+ // unverifiable path (no git, no remote, non-github remote, gh missing or
14
+ // erroring, unreadable answer) is a loud skip — an UNKNOWN, never red,
15
+ // never cached.
16
+ import type { Rule } from './rule.ts'
17
+
18
+ export const githubVisibility: Rule = {
19
+ id: 'SK007',
20
+ title: 'github visibility',
21
+ owner: 'meta/contracts.md',
22
+ ttl: '7d',
23
+ cacheKey: (env) => env.config.visibility ?? 'private',
24
+ lint: async (t, env) => {
25
+ const declared = env.config.visibility ?? 'private'
26
+ const skip = (why: string): void => void t.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
27
+ const firstLine = (s: string): string => s.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? ''
28
+
29
+ const remotes = await t.exec(['git', 'remote'], { cwd: env.root })
30
+ if (remotes.code !== 0) return skip(`git says: ${firstLine(remotes.out) || `exit ${remotes.code}`}`)
31
+ const names = remotes.out.trim().split('\n').filter((l) => l !== '')
32
+ if (names.length === 0) return skip('no git remote')
33
+ // his repos name it `github`, not `origin` (field catch 2026-07-26)
34
+ const pick = ['github', 'origin'].find((n) => names.includes(n)) ?? names[0]!
35
+ const url = await t.exec(['git', 'remote', 'get-url', pick], { cwd: env.root })
36
+ if (url.code !== 0) return skip(`git says: ${firstLine(url.out)}`)
37
+ const m = url.out.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\s*$/m)
38
+ if (m == null) return skip(`remote '${pick}' is not github (${firstLine(url.out)})`)
39
+ const target = m[1]!
40
+
41
+ const r = await t.exec(['gh', 'repo', 'view', target, '--json', 'isPrivate,nameWithOwner'], { cwd: env.root, timeoutMs: 30_000 })
42
+ if (r.code !== 0) return skip(`gh failed (${firstLine(r.out)})`)
43
+ // gh mixes stderr chatter (release notices, SSO warnings) into the
44
+ // captured output — carve the JSON object out before parsing, and an
45
+ // unreadable answer stays an UNKNOWN, never a FAIL
46
+ const start = r.out.indexOf('{')
47
+ const end = r.out.lastIndexOf('}')
48
+ let parsed: { isPrivate?: boolean; nameWithOwner?: string }
49
+ try {
50
+ if (start === -1 || end < start) throw new Error('no JSON object in output')
51
+ parsed = JSON.parse(r.out.slice(start, end + 1)) as typeof parsed
52
+ } catch {
53
+ return skip(`gh answer unreadable (${firstLine(r.out)})`)
54
+ }
55
+ const actual = parsed.isPrivate === true ? 'private' : 'public'
56
+ const who = parsed.nameWithOwner ?? target
57
+ if (actual === declared) t.ok(`github ${who} is ${actual} — matches repo.config visibility`)
58
+ else if (actual === 'public')
59
+ t.fail(`github ${who} is PUBLIC but repo.config declares '${declared}' — the leak case; make it private again or run the pre-public gate and declare visibility: 'public'`)
60
+ else
61
+ t.fail(`github ${who} is private but repo.config declares 'public' — stale config lying to agents; fix the declaration`)
62
+ },
63
+ }
@@ -0,0 +1,98 @@
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
+ title: 'reviewer agent',
73
+ owner: 'identity/how-we-work.md',
74
+ lint: (t, env) => {
75
+ const r = env.config.reviewer
76
+ if (r == null) return t.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
77
+ const rel = reviewerAgentPath(r.name)
78
+ const p = join(env.root, rel)
79
+ if (briefMissing(env.root, r.brief)) t.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
80
+ if (!existsSync(p)) return t.fail(`${rel} missing — the mandated reviewer type is unavailable, so every review runs on whatever framing the spawner improvised`)
81
+ const issues = reviewerAgentIssues(readFileSync(p, 'utf8'), r)
82
+ for (const i of issues) t.fail(`${rel}: ${i}`)
83
+ if (issues.length === 0 && !t.failed) t.ok(`${rel} → ${r.brief}`)
84
+ },
85
+ // create-if-missing only: an existing definition may carry a repo's own
86
+ // tuned prompt, and silently rewriting that would cost more than the
87
+ // finding it repairs. Violations in a present file stay a loud FAIL.
88
+ fix: (env: RuleEnv): FixResult => {
89
+ const r = env.config.reviewer
90
+ if (r == null) return { changed: false, summary: 'no reviewer declared — nothing to write' }
91
+ const rel = reviewerAgentPath(r.name)
92
+ const p = join(env.root, rel)
93
+ if (existsSync(p)) return { changed: false, summary: `${rel} exists — remaining findings are about its content, fix them by hand` }
94
+ mkdirSync(dirname(p), { recursive: true })
95
+ writeFileSync(p, canonicalAgent(r))
96
+ return { changed: true, summary: `${rel} written — points at ${r.brief}, no edit tools` }
97
+ },
98
+ }
@@ -0,0 +1,34 @@
1
+ // THE rule registry — single source of every SK rule. `shipkit check` runs
2
+ // them, `shipkit rules` lists them, fix pointers and wiki pages name their
3
+ // ids. A duplicate or malformed id throws at import time: every surface
4
+ // that loads rules dies loud rather than run with an ambiguous registry.
5
+ import type { Rule } from './rule.ts'
6
+ import { packageJsonShape } from './SK001-package-json-shape.ts'
7
+ import { contractsFulfillness } from './SK002-contracts-fulfillness.ts'
8
+ import { claudeMdShape } from './SK003-claude-md-shape.ts'
9
+ import { gitignorePolicy } from './SK004-gitignore-policy.ts'
10
+ import { rvlibVersions } from './SK005-rvlib-versions.ts'
11
+ import { npmPublishSafety } from './SK006-npm-publish-safety.ts'
12
+ import { githubVisibility } from './SK007-github-visibility.ts'
13
+ import { reviewerAgent } from './SK008-reviewer-agent.ts'
14
+
15
+ export const RULES: readonly Rule[] = [
16
+ packageJsonShape,
17
+ contractsFulfillness,
18
+ claudeMdShape,
19
+ gitignorePolicy,
20
+ rvlibVersions,
21
+ npmPublishSafety,
22
+ githubVisibility,
23
+ reviewerAgent,
24
+ ]
25
+
26
+ const seen = new Set<string>()
27
+ for (const r of RULES) {
28
+ if (!/^SK\d{3}$/.test(r.id)) throw new Error(`rule-registry: bad id '${r.id}' — rules are SK### (3 digits, never reused)`)
29
+ if (seen.has(r.id)) throw new Error(`rule-registry: duplicate id ${r.id}`)
30
+ if (r.owner === '') throw new Error(`rule-registry: ${r.id} has no owner page`)
31
+ seen.add(r.id)
32
+ }
33
+
34
+ export const ruleById = (id: string): Rule | undefined => RULES.find((r) => r.id === id)
@@ -0,0 +1,62 @@
1
+ // Rule — one machine-enforced invariant with a stable id. One module per
2
+ // rule (`src/rules/SK###-<slug>.ts`), registered in rule-registry.ts; ids
3
+ // are never reused or renumbered. `shipkit check` runs every rule's lint;
4
+ // a rule may also carry an idempotent fix() (the `check --fix` runner).
5
+ // Every wire line a rule emits is tagged `[SK###]` so output is greppable
6
+ // by rule and a fix pointer names the rule that can repair it.
7
+ import type { CheckCtx } from '../core/check-ctx.ts'
8
+ import type { Line } from '../core/wire.ts'
9
+ import type { RepoConfig } from '../repo-config.ts'
10
+
11
+ export type RuleEnv = { root: string; config: RepoConfig }
12
+
13
+ export type FixResult = { changed: boolean; summary: string }
14
+
15
+ export type Rule = {
16
+ /** stable `SK` + 3 digits — never reused, never renumbered (registry throws) */
17
+ id: string
18
+ /** check-suite display name — stable: the check name in STATUS.md, the TUI row, and the suite cache */
19
+ title: string
20
+ /** wiki page owning the invariant — the run-check framework appends `docsBase + owner`
21
+ * as the fix: pointer whenever a failing/warning lint attached none */
22
+ owner: string
23
+ /** clean-pass cache duration (check-suite TTL, e.g. '24h') — for rules that
24
+ * probe the network; skip/WARN/FAIL runs are never cached */
25
+ ttl?: string
26
+ /** cache-identity contribution for TTL'd rules whose verdict depends on
27
+ * config — a changed key busts the cache instantly instead of replaying
28
+ * a stale assertion for the TTL */
29
+ cacheKey?: (env: RuleEnv) => string
30
+ lint: (t: CheckCtx, env: RuleEnv) => void | Promise<void>
31
+ /** idempotent auto-repair; `check --fix` applies it then re-lints — never interactive */
32
+ fix?: (env: RuleEnv) => FixResult | Promise<FixResult>
33
+ }
34
+
35
+ // THE one way to run a rule's lint (check suite + the --fix runner): a
36
+ // throwing lint still fails with its id, a failing/warning run always
37
+ // carries at least one fix: pointer (the lint's own precise ones win, else
38
+ // docsBase + the rule's owner page), and every line gets tagged.
39
+ export async function runRuleLint(rule: Rule, t: CheckCtx, env: RuleEnv): Promise<void> {
40
+ try {
41
+ await rule.lint(t, env)
42
+ } catch (e) {
43
+ t.fail(`${rule.title} lint errored (${String(e).slice(0, 200)}) — network down, or a bug in this rule`)
44
+ }
45
+ if ((t.failed || t.warned) && !t.lines.some((l) => l.kind === 'fix-fail' || l.kind === 'fix-warn'))
46
+ t.lines.push({ kind: t.failed ? 'fix-fail' : 'fix-warn', text: `${env.config.docsBase ?? ''}${rule.owner}` })
47
+ t.lines = tagRuleLines(rule, t.lines)
48
+ }
49
+
50
+ // tag every severity line with the rule id; fix pointers of a fixable rule
51
+ // advertise the autofix. Idempotent — already-tagged lines pass through
52
+ // (cached suite replays re-enter here).
53
+ export function tagRuleLines(rule: Rule, lines: Line[]): Line[] {
54
+ return lines.map((l) => {
55
+ if (l.kind === 'fix-warn' || l.kind === 'fix-fail')
56
+ return rule.fix == null || l.text.includes('--fix ')
57
+ ? l
58
+ : { ...l, text: `${l.text} · autofix: shipkit check --fix ${rule.id}` }
59
+ if (l.kind === 'note') return l
60
+ return l.text.startsWith(`[${rule.id}]`) ? l : { ...l, text: `[${rule.id}] ${l.text}` }
61
+ })
62
+ }
@@ -0,0 +1,57 @@
1
+ // `shipkit check --fix [SK### …]` — apply rule autofixes, then re-lint and
2
+ // show the real end state. Never runs under ci (the gate stays pure), never
3
+ // caches (a fixer must see reality). No ids = every fixable rule; a
4
+ // requested id that is unknown or unfixable fails loud instead of silently
5
+ // narrowing.
6
+ import { CheckCtx } from '../core/check-ctx.ts'
7
+ import { formatLine } from '../core/wire.ts'
8
+ import { loadRepoConfig } from '../repo-config.ts'
9
+ import { RULES, ruleById } from './rule-registry.ts'
10
+ import { runRuleLint, type RuleEnv } from './rule.ts'
11
+
12
+ export type FixOpts = {
13
+ ids?: string[]
14
+ print?: (line: string) => void
15
+ }
16
+
17
+ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
18
+ const print = o.print ?? ((l: string) => console.log(l))
19
+ const config = await loadRepoConfig(root)
20
+ const env: RuleEnv = { root, config }
21
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
22
+
23
+ let targets = RULES.filter((r) => r.fix != null)
24
+ if (o.ids != null && o.ids.length > 0) {
25
+ for (const id of o.ids) {
26
+ const r = ruleById(id)
27
+ if (r == null) { print(`FAIL: unknown rule '${id}' — see: shipkit rules`); return 1 }
28
+ if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
29
+ }
30
+ const wanted = new Set(o.ids)
31
+ targets = targets.filter((r) => wanted.has(r.id))
32
+ }
33
+
34
+ let exit = 0
35
+ for (const rule of targets) {
36
+ const before = new CheckCtx(new Map())
37
+ await runRuleLint(rule, before, env)
38
+ if (!before.failed && !before.warned) {
39
+ print(`ok: [${rule.id}] already clean — nothing to fix`)
40
+ continue
41
+ }
42
+ const f = await rule.fix!(env)
43
+ // an unchanged fix is the DESIGNED outcome for judgment findings —
44
+ // a note, not a warning
45
+ print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `[${rule.id}] fix changed nothing — ${f.summary}`)
46
+ const after = new CheckCtx(new Map())
47
+ await runRuleLint(rule, after, env)
48
+ // the fix already ran: advertising it again on surviving findings
49
+ // 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))
51
+ if (after.failed) exit = 1
52
+ }
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`)
56
+ return exit
57
+ }