rvlib-shipkit 0.13.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 — 2026-07-27
4
+
5
+ New rule SK008 — the mandated reviewer agent type. A repo declares
6
+ `reviewer: { name, brief, tools? }` in `repo.config.ts`; the rule then
7
+ requires `.claude/agents/<name>.md` to exist, carry that type name, point at
8
+ the brief file (which must itself exist), and keep `tools:` inside a
9
+ read-only ALLOWLIST (default `Read, Grep, Glob, Bash`; widen it deliberately
10
+ with `reviewer.tools`). A denylist of today's edit-tool names would wave
11
+ through anything that edits by proxy, and an ABSENT `tools:` line is the
12
+ worst case: the harness inherits every tool. Fixable and create-only —
13
+ `shipkit check --fix SK008` writes a valid definition when the file is
14
+ missing and never overwrites an existing one, which may be hand-tuned.
15
+ Undeclared repos skip, and `shipkit init` scaffolds the key commented out.
16
+ `reviewerAgentIssues` is exported so a repo's own linter can enforce the same
17
+ invariants in its pre-commit hook without a second implementation. Note the
18
+ written agent file embeds the brief PATH: a repo that may go public should
19
+ gitignore it and let the fixer regenerate it per machine.
20
+
21
+ Contract rule `png(path, w, h, { atLeast: true })` — the venue's numbers as a
22
+ MINIMUM at a fixed aspect ratio (relative tolerance), for stores that DISPLAY
23
+ a size but accept larger uploads. Without it an exact-size rule fails a better
24
+ 2x asset and blocks the release it was meant to gate. Applied to the itch
25
+ cover and the identity/discord image rules; Steam capsules stay exact, Valve
26
+ mandates those.
27
+
28
+ Dep: `rvlib-ts-tools` ^0.2.0 → ^0.3.0, so the zero-config `imports:check`
29
+ floor task inherits its honest reporting (a run that scanned zero files now
30
+ fails instead of printing green).
31
+
32
+ MIGRATION: the itch venue contract gains a required `.rv-itch/listing.md`
33
+ (every store-form field answered, paste-ready). A repo already shipping on itch
34
+ will see one new FAIL until the file exists; the docs that own the itch
35
+ contract carry a fill-in template for it.
36
+
3
37
  ## 0.13.0 — 2026-07-26
4
38
 
5
39
  Stable rule ids. Every built-in invariant `shipkit check` enforces is now
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "mobx-react-lite": "^4.1.1",
34
34
  "react": "^19.2.7",
35
35
  "react-dom": "^19.2.7",
36
- "rvlib-ts-tools": "^0.2.0",
36
+ "rvlib-ts-tools": "^0.3.0",
37
37
  "sanitize-html": "^2.17.6",
38
38
  "sharp": "^0.35.3"
39
39
  }
@@ -6,7 +6,10 @@
6
6
 
7
7
  export type RuleOpts = { warnOnly?: boolean }
8
8
  export type FileRule = RuleOpts & { kind: 'file'; path: string; nonEmpty: boolean }
9
- export type PngRule = RuleOpts & { kind: 'png'; path: string; width: number; height: number }
9
+ /** `atLeast`: the venue's numbers are a MINIMUM at a fixed aspect ratio (itch
10
+ * displays a 630x500 cover but accepts larger, and a 2x asset is better) —
11
+ * without it an exact-size rule blocks the release over a better file */
12
+ export type PngRule = RuleOpts & { kind: 'png'; path: string; width: number; height: number; atLeast?: boolean }
10
13
  export type GlobRule = RuleOpts & { kind: 'glob'; pattern: string; min: number; width?: number; height?: number }
11
14
  export type MdRule = RuleOpts & { kind: 'md'; path: string; mustContain: string[] }
12
15
  export type DirRule = RuleOpts & { kind: 'dir'; path: string }
@@ -41,7 +44,7 @@ export type ContractManifest = {
41
44
  }
42
45
 
43
46
  export const file = (path: string, p?: RuleOpts & { nonEmpty?: boolean }): FileRule => ({ kind: 'file', path, nonEmpty: p?.nonEmpty ?? true, warnOnly: p?.warnOnly })
44
- export const png = (path: string, width: number, height: number, p?: RuleOpts): PngRule => ({ kind: 'png', path, width, height, warnOnly: p?.warnOnly })
47
+ export const png = (path: string, width: number, height: number, p?: RuleOpts & { atLeast?: boolean }): PngRule => ({ kind: 'png', path, width, height, atLeast: p?.atLeast, warnOnly: p?.warnOnly })
45
48
  export const glob = (pattern: string, p: RuleOpts & { min: number; width?: number; height?: number }): GlobRule => ({ kind: 'glob', pattern, min: p.min, width: p.width, height: p.height, warnOnly: p.warnOnly })
46
49
  export const md = (path: string, p?: RuleOpts & { mustContain?: string[] }): MdRule => ({ kind: 'md', path, mustContain: p?.mustContain ?? [], warnOnly: p?.warnOnly })
47
50
  export const dir = (path: string, p?: RuleOpts): DirRule => ({ kind: 'dir', path, warnOnly: p?.warnOnly })
@@ -57,11 +57,21 @@ async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]
57
57
  }
58
58
  case 'png': {
59
59
  const p = abs(env.root, rule.path)
60
- if (!existsSync(p)) { problems.push(`${rule.path} missing — need png ${rule.width}x${rule.height}`); break }
60
+ const need = rule.atLeast === true ? `>= ${rule.width}x${rule.height} at ${rule.width}:${rule.height}` : `${rule.width}x${rule.height}`
61
+ if (!existsSync(p)) { problems.push(`${rule.path} missing — need png ${need}`); break }
61
62
  const dims = pngDims(p)
62
63
  if (dims == null) problems.push(`${rule.path} is not a readable png`)
63
- else if (dims.width !== rule.width || dims.height !== rule.height)
64
- problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${rule.width}x${rule.height}`)
64
+ else if (rule.atLeast !== true) {
65
+ if (dims.width !== rule.width || dims.height !== rule.height) problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${need}`)
66
+ } else {
67
+ const want = rule.width / rule.height
68
+ // RELATIVE tolerance: an absolute one is 0.8% at 630:500 and
69
+ // 3% at a tall ratio, i.e. a different rule per venue
70
+ const ratioOff = Math.abs(dims.width / dims.height - want) / want > 0.01
71
+ const tooSmall = dims.width < rule.width || dims.height < rule.height
72
+ if (tooSmall || ratioOff)
73
+ problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${need}${ratioOff ? ' (the ratio is off; a bigger asset at the same ratio is fine)' : ' (bigger is fine, smaller is not)'}`)
74
+ }
65
75
  break
66
76
  }
67
77
  case 'glob': {
@@ -10,6 +10,6 @@ export default defineContract({
10
10
  detect: [dirExists('.rv-discord')],
11
11
  artifacts: [
12
12
  md('.rv-discord/server.md', { mustContain: ['invite'] }),
13
- png('.rv-discord/icon.png', 512, 512),
13
+ png('.rv-discord/icon.png', 512, 512, { atLeast: true }),
14
14
  ],
15
15
  })
@@ -13,7 +13,7 @@ export default defineContract({
13
13
  artifacts: [
14
14
  file('.rv-identity/brand.ts'),
15
15
  png('public/favicon.png', 512, 512),
16
- png('public/og.png', 1200, 630),
16
+ png('public/og.png', 1200, 630, { atLeast: true }),
17
17
  glob('public/logo.*', { min: 1 }),
18
18
  ],
19
19
  })
@@ -1,5 +1,7 @@
1
1
  // normative WHAT for .rv-itch/ — the WHY + workflow live in the sibling page.
2
- import { defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
2
+ import { custom, defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
3
+ import { existsSync, readFileSync } from 'fs'
4
+ import { join } from 'path'
3
5
 
4
6
  export default defineContract({
5
7
  venue: 'itch',
@@ -10,7 +12,22 @@ export default defineContract({
10
12
  artifacts: [
11
13
  // one AI-disclosure wording everywhere — page.md is its canonical home
12
14
  md('.rv-itch/page.md', { mustContain: ['## AI disclosure'] }),
13
- png('.rv-itch/cover.png', 630, 500),
15
+ // every field of itch's create-project form, answered and ready to
16
+ // paste — the listing is a browser-only act, so the artifact IS the
17
+ // handoff (references/listing.template.md is the fill-in copy)
18
+ md('.rv-itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
19
+ // headings alone would pass the TEMPLATE copied in verbatim, and this
20
+ // file exists to be pasted with no decision left in it (field
21
+ // 2026-07-27: a reviewer proved `cp template listing.md` went green)
22
+ custom('.rv-itch/listing.md answered', (env) => {
23
+ const p = join(env.root, '.rv-itch/listing.md')
24
+ if (!existsSync(p)) return []
25
+ const left = [...readFileSync(p, 'utf8').matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
26
+ return left.length === 0 ? [] : [`.rv-itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`]
27
+ }),
28
+ // itch DISPLAYS 630x500 and accepts larger — an exact-size rule would
29
+ // block a release over a better cover (audit 2026-07-27)
30
+ png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
14
31
  glob('.rv-itch/screenshots/*.png', { min: 3 }),
15
32
  ],
16
33
  })
package/src/init-repo.ts CHANGED
@@ -19,6 +19,9 @@ export default defineRepo({
19
19
  venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
20
20
  social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
21
21
  tasks: [], // repo commit-gate tasks; deps double as race guards
22
+ // the mandated reviewer agent type (rule SK008) — uncomment with YOUR
23
+ // standing brief, then \`shipkit check --fix SK008\` writes the agent file:
24
+ // reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
22
25
  // health checks: no config — drop a <name>.check.ts file anywhere,
23
26
  // \`shipkit check\` auto-discovers and runs it (wire output, exit code)
24
27
  })
@@ -62,6 +62,13 @@ export type RepoConfig = {
62
62
  /** declared github visibility INTENT (absent = 'private') — rule SK007
63
63
  * fails loud when `gh repo view` disagrees in either direction */
64
64
  visibility?: 'private' | 'public'
65
+ /** the reviewer agent type this repo mandates: `.claude/agents/<name>.md`,
66
+ * whose system prompt points at `brief` (an md file holding the standing
67
+ * review framing — one home, shared across repos). `tools` overrides the
68
+ * read-only allowlist SK008 enforces (default: Read, Grep, Glob, Bash).
69
+ * Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
70
+ * mandated reviewer. */
71
+ reviewer?: { name: string; brief: string; tools?: string[] }
65
72
  /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
66
73
  tui?: { panels?: TuiPanelDef[] }
67
74
  /** the `shipkit serve` hub: port + optional graph hooks */
@@ -69,13 +76,16 @@ export type RepoConfig = {
69
76
  logDir?: string
70
77
  }
71
78
 
72
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'visibility', 'tui', 'serve', 'logDir'])
79
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'visibility', 'reviewer', 'tui', 'serve', 'logDir'])
73
80
 
74
81
  export function defineRepo(c: RepoConfig): RepoConfig {
75
82
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
76
83
  for (const v of c.venues ?? []) if (!/^[a-z0-9-]+$/.test(v)) throw new Error(`defineRepo: venue '${v}' must be kebab-case`)
77
84
  if (c.visibility != null && c.visibility !== 'private' && c.visibility !== 'public')
78
85
  throw new Error(`defineRepo: visibility '${String(c.visibility)}' — 'private' or 'public' only`)
86
+ // a half-declared reviewer would lint a path like `.claude/agents/.md`
87
+ if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
88
+ throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
79
89
  // unknown keys die loud — a typo'd or removed key silently changing the
80
90
  // gate is exactly the never-silence failure mode (A23)
81
91
  for (const k of Object.keys(c))
@@ -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
+ }
@@ -10,6 +10,7 @@ import { gitignorePolicy } from './SK004-gitignore-policy.ts'
10
10
  import { rvlibVersions } from './SK005-rvlib-versions.ts'
11
11
  import { npmPublishSafety } from './SK006-npm-publish-safety.ts'
12
12
  import { githubVisibility } from './SK007-github-visibility.ts'
13
+ import { reviewerAgent } from './SK008-reviewer-agent.ts'
13
14
 
14
15
  export const RULES: readonly Rule[] = [
15
16
  packageJsonShape,
@@ -19,6 +20,7 @@ export const RULES: readonly Rule[] = [
19
20
  rvlibVersions,
20
21
  npmPublishSafety,
21
22
  githubVisibility,
23
+ reviewerAgent,
22
24
  ]
23
25
 
24
26
  const seen = new Set<string>()