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
@@ -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
  })
@@ -1,6 +1,9 @@
1
1
  // contract fulfillness alone, fast and filesystem-only. Plain module
2
2
  // behind cmd_Contracts; venue deploy scripts run it before any upload.
3
- import { activeContracts, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
3
+ import { loadRepoConfig } from '../repo-config.ts'
4
+ import { CheckCtx } from '../core/check-ctx.ts'
5
+ import { contractsFulfillness } from '../rules/SK002-contracts-fulfillness.ts'
6
+ import { activeContracts, applyContractsResult, contractsConfigFrom } from './apply-contracts.ts'
4
7
  import { evaluateContracts } from './contracts-engine.ts'
5
8
  import { formatLine } from '../core/wire.ts'
6
9
 
@@ -27,7 +30,9 @@ export async function runContracts(root: string, o: ContractsOpts = {}): Promise
27
30
  return r.exitCode
28
31
  }
29
32
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
30
- for (const l of r.lines) print(formatLine(l, color))
31
- if (r.lines.length === 0) print('ok: no contracts apply (nothing declared, nothing detected)')
33
+ // same SK002-tagged rendering as `shipkit check` and the ci task
34
+ const t = new CheckCtx(new Map())
35
+ applyContractsResult(t, r, contractsFulfillness)
36
+ for (const l of t.lines) print(formatLine(l, color))
32
37
  return r.exitCode
33
38
  }
@@ -0,0 +1,46 @@
1
+ // The canonical package.json script bodies for a shipkit repo — vocabulary
2
+ // owned by meta/rv-conventions/package-json.md. ONE source: `shipkit init`
3
+ // scaffolds from it and rule SK001's autofix repairs from it.
4
+ export type ScriptStyle = {
5
+ /** in-repo-source consumers: path to shipkit's cli.ts — bodies become `bun <path> <cmd>` */
6
+ bin?: string
7
+ }
8
+
9
+ export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string> {
10
+ const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
11
+ return {
12
+ ci: run('ci'),
13
+ check: run('check'),
14
+ contracts: run('contracts'),
15
+ tui: run('tui'),
16
+ mv: run('mv'),
17
+ // hub runs in WATCH mode (house default 2026-07-23): lib/config edits
18
+ // restart the server live; the npm shim can't watch, the source path can
19
+ hub: `bun --watch ${opts.bin ?? 'node_modules/rvlib-shipkit/src/cli.ts'} serve`,
20
+ // baseline `test` is universal — lint/fmt bodies are stack-specific,
21
+ // the scaffold pages own those
22
+ test: 'bun test',
23
+ }
24
+ }
25
+
26
+ // conditional canon (bodies stated by the vocabulary page) — keyed by the
27
+ // SK001 requirement name; requirements with no canonical body (assets,
28
+ // comfy:up) are deliberately absent: a fixer must not invent one
29
+ export function conditionalBodies(opts: ScriptStyle = {}): Record<string, string> {
30
+ const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
31
+ return {
32
+ 'up': './deploy.sh',
33
+ 'asks': run('asks'),
34
+ 'assets:identity': run('assets identity'),
35
+ }
36
+ }
37
+
38
+ // recover the consumption style from existing scripts: any shipkit command
39
+ // already running `bun <path>/cli.ts <cmd>` fixes the style for new bodies
40
+ export function inferBin(scripts: Record<string, string>): string | undefined {
41
+ for (const body of Object.values(scripts)) {
42
+ const m = body.match(/^bun (?:--watch )?(\S+cli\.ts) /)
43
+ if (m != null && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
44
+ }
45
+ return undefined
46
+ }
@@ -61,8 +61,8 @@ export class CheckSuite {
61
61
  this.onResult = opts.onResult
62
62
  }
63
63
 
64
- addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; logic: Logic }): void {
65
- this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, logic: spec.logic })
64
+ addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic }): void {
65
+ this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic })
66
66
  }
67
67
 
68
68
  defineRequiredTool(name: string, opts: { candidates?: string[]; probe?: { args: string[]; expect?: RegExp }; fix: string }): void {
@@ -161,7 +161,10 @@ export class CheckSuite {
161
161
  this.onResult?.(r.name, { lines: t.lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
162
162
  if (t.failed) anyFail = true
163
163
  if (r.ttlMs > 0) {
164
- if (!t.failed && !t.warned) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
164
+ // a skip line is an UNKNOWN, not a clean pass — replaying
165
+ // "registry unreachable" for a TTL asserts a dead network as
166
+ // fact long after it recovered (review find 2026-07-26)
167
+ if (!t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
165
168
  // a WARN/FAIL evicts the stale pass — a later run must re-probe,
166
169
  // not replay an ok recorded before the breakage
167
170
  else if (entry != null) { delete cache[r.name]; cacheDirty = true }
@@ -0,0 +1,39 @@
1
+ // ONE file-lock + atomic-write concern (extracted from write-status when
2
+ // motherhome became the second multi-writer): the lock spans a caller's
3
+ // whole read-modify-write, the write lands via tmp + rename so a killed
4
+ // process can never leave a torn file, and the loser of a race SKIPS —
5
+ // never corrupts, never blocks.
6
+ import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
7
+ import { basename, dirname, join } from 'path'
8
+
9
+ // a writer that died without unlocking must not wedge the file forever
10
+ const LOCK_STALE_MS = 120_000
11
+
12
+ export type LockOutcome<T> = { ok: true; value: T } | { ok: false; warn: string }
13
+
14
+ export function withFileLock<T>(path: string, fn: () => T): LockOutcome<T> {
15
+ const lockPath = `${path}.lock`
16
+ const grab = (): boolean => {
17
+ try { writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); return true } catch { return false }
18
+ }
19
+ if (!grab()) {
20
+ let stale = false
21
+ try { stale = Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {
22
+ // lock vanished between grab and stat — the racing writer just
23
+ // finished; still skip this round rather than fight over it
24
+ }
25
+ if (stale) rmSync(lockPath, { force: true })
26
+ if (!stale || !grab()) return { ok: false, warn: `${path} locked by another writer — skipped this round` }
27
+ }
28
+ try {
29
+ return { ok: true, value: fn() }
30
+ } finally {
31
+ rmSync(lockPath, { force: true })
32
+ }
33
+ }
34
+
35
+ export function atomicWrite(path: string, content: string): void {
36
+ const tmpPath = join(dirname(path), `.${basename(path)}.tmp-${process.pid}`)
37
+ writeFileSync(tmpPath, content)
38
+ renameSync(tmpPath, path)
39
+ }
@@ -0,0 +1,87 @@
1
+ // Report to the motherhome — upsert this repo's row into the docsBase
2
+ // wiki's `build/shipkit/local-projects.md`, when that page ALREADY EXISTS
3
+ // on this disk. The lib NEVER creates the page and never writes into a
4
+ // relative or URL docsBase: the page is a tracked wiki file, so a real
5
+ // docs checkout has it, and a consumer that merely set docsBase as a
6
+ // fix-pointer prefix must never find surprise files in its docs tree.
7
+ // The whole read-modify-write runs under the shared file lock
8
+ // (core/locked-write) — concurrent reporters never lose rows or tear the
9
+ // file; the loser skips and self-heals on its next run. Rows whose repo
10
+ // path no longer exists are pruned on every write. Churn control: a row
11
+ // rewrites only when its DATA changes or its last-seen day is older than
12
+ // 7 days. Never fails the calling command.
13
+ import { existsSync, readFileSync } from 'fs'
14
+ import { homedir } from 'os'
15
+ import { join, resolve } from 'path'
16
+ import type { RepoConfig } from '../repo-config.ts'
17
+ import { atomicWrite, withFileLock } from './locked-write.ts'
18
+
19
+ const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
20
+ const END = '<!-- <<< shipkit local-projects -->'
21
+ const STALE_DAYS = 7
22
+
23
+ type Row = { name: string; root: string; version: string; mode: string; seen: string }
24
+
25
+ const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
26
+
27
+ const parseRow = (line: string): Row | null => {
28
+ const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
29
+ return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
30
+ }
31
+
32
+ function libVersion(): string {
33
+ try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
34
+ }
35
+
36
+ // how this repo consumes the lib — `?` on an unreadable package.json (a
37
+ // read failure must never be rendered as the fact 'path')
38
+ function consumptionMode(root: string): string {
39
+ try {
40
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
41
+ const spec = { ...pkg.dependencies, ...pkg.devDependencies }['rvlib-shipkit']
42
+ if (spec == null) return 'path'
43
+ if (spec.startsWith('link:')) return 'link'
44
+ if (spec.startsWith('file:')) return 'file'
45
+ return 'npm'
46
+ } catch { return '?' }
47
+ }
48
+
49
+ export type MotherhomeOutcome = { wrote: boolean; note?: string }
50
+
51
+ export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
52
+ try {
53
+ if (config.docsBase == null) return { wrote: false }
54
+ const base = config.docsBase.startsWith('~/') ? join(homedir(), config.docsBase.slice(2)) : config.docsBase
55
+ // absolute local paths only — a relative docsBase would resolve
56
+ // against CWD, a URL is a display prefix
57
+ if (!base.startsWith('/')) return { wrote: false }
58
+ const page = join(base, 'build/shipkit/local-projects.md')
59
+ if (!existsSync(page)) return { wrote: false }
60
+
61
+ const mode = consumptionMode(root)
62
+ // source-consumed code is not the published release its version
63
+ // string names — disclose it
64
+ const version = mode === 'npm' ? libVersion() : `${libVersion()}+src`
65
+ const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
66
+
67
+ const r = withFileLock(page, (): MotherhomeOutcome => {
68
+ const lines = readFileSync(page, 'utf8').split('\n')
69
+ const b = lines.indexOf(BEGIN)
70
+ const e = lines.indexOf(END)
71
+ if (b === -1 || e === -1 || e < b) return { wrote: false, note: `WARN motherhome upsert skipped — markers missing/corrupted in ${page}` }
72
+ const rows = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
73
+ const cur = rows.find((row) => row.root === me.root)
74
+ const dataSame = cur != null && cur.name === me.name && cur.version === me.version && cur.mode === me.mode
75
+ const fresh = cur != null && (Date.parse(today) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
76
+ if (dataSame && fresh) return { wrote: false }
77
+ // prune rows for repos that no longer exist at their recorded path
78
+ const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
79
+ atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
80
+ return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})` }
81
+ })
82
+ // lock lost to a racing reporter — self-heals on the next run
83
+ return r.ok ? r.value : { wrote: false }
84
+ } catch (err) {
85
+ return { wrote: false, note: `WARN motherhome upsert failed (${String(err).slice(0, 120)}) — registry may be stale` }
86
+ }
87
+ }
@@ -8,9 +8,10 @@ import { join, relative } from 'path'
8
8
  import { CheckSuite, type CheckOutcome } from './check-suite.ts'
9
9
  import { discoverChecks } from './discover-checks.ts'
10
10
  import { spawnWithTimeout } from './spawn-timeout.ts'
11
- import { activeContracts, applyContractsResult, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
12
- import { evaluatePackageJson } from './package-json-check.ts'
13
- import { evaluateContracts } from '../contracts/contracts-engine.ts'
11
+ import { loadRepoConfig } from '../repo-config.ts'
12
+ import { reportToMotherhome } from './motherhome.ts'
13
+ import { RULES } from '../rules/rule-registry.ts'
14
+ import { runRuleLint } from '../rules/rule.ts'
14
15
  import { formatLine, parseWireOutput } from './wire.ts'
15
16
  import { writeStatus } from './write-status.ts'
16
17
 
@@ -22,6 +23,8 @@ const CHECK_TIMEOUT_MS = 120_000
22
23
  export type CheckOpts = {
23
24
  json?: boolean
24
25
  fresh?: boolean
26
+ /** report-only for self-maintaining rules (SK005 never touches package.json) */
27
+ frozen?: boolean
25
28
  /** refresh STATUS.md after the run (default true — O4: every repo gets it) */
26
29
  status?: boolean
27
30
  /** line sink — commands pass their context stream; defaults to console */
@@ -33,20 +36,24 @@ export type CheckOpts = {
33
36
  export async function runCheck(root: string, o: CheckOpts = {}): Promise<number> {
34
37
  const print = o.print ?? ((l: string) => console.log(l))
35
38
  const c = await loadRepoConfig(root)
39
+ // env carries the flags to child checks — restored after the run so a
40
+ // long-lived caller (doctor, TUI, hub) is never poisoned by one
41
+ // fresh/frozen invocation
42
+ const prevFresh = process.env.SHIPKIT_FRESH
43
+ const prevFrozen = process.env.SHIPKIT_FROZEN
36
44
  if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
45
+ if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
37
46
  const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache'), onResult: o.onResult })
38
- suite.addCheck({
39
- name: 'contracts',
40
- logic: async (t) => {
41
- applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
42
- },
43
- })
44
- // built-in shape probe (meta/rv-conventions/package-json.md) — doctor
45
- // surface only, deliberately absent from the ci gate
46
- suite.addCheck({
47
- name: 'package.json',
48
- logic: (t) => evaluatePackageJson(t, root, c),
49
- })
47
+ // built-in SK rules (rules/rule-registry.ts) — id-tagged, crash-safe,
48
+ // fix-pointer floor (rules/rule.ts runRuleLint owns the semantics);
49
+ // doctor surface only, deliberately absent from the ci gate
50
+ for (const rule of RULES)
51
+ suite.addCheck({
52
+ name: rule.title,
53
+ remainValidFor: rule.ttl,
54
+ cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
55
+ logic: (t) => runRuleLint(rule, t, { root, config: c }),
56
+ })
50
57
  const childEnv: Record<string, string | undefined> = {
51
58
  ...process.env,
52
59
  ...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
@@ -69,12 +76,18 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
69
76
  })
70
77
  }
71
78
  const r = await suite.execute()
79
+ if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
80
+ if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
81
+ // report to the motherhome (docsBase wiki on this disk) — never fails
82
+ // the check, notes only when it wrote or broke
83
+ const mh = reportToMotherhome(root, c)
72
84
  const statusOutcome = o.status !== false
73
85
  ? writeStatus(root, { name: c.name, command: 'shipkit check', lines: r.lines.map((l) => formatLine(l, false)), exitCode: r.exitCode })
74
86
  : null
75
87
  if (o.json === true) { print(JSON.stringify(r)); return r.exitCode }
76
88
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
77
89
  for (const l of r.lines) print(formatLine(l, color))
90
+ if (mh.note != null) print(mh.note)
78
91
  if (statusOutcome != null && !statusOutcome.written) print(`WARN ${statusOutcome.warn}`)
79
92
  return r.exitCode
80
93
  }
@@ -5,6 +5,7 @@
5
5
  // atomic + lock) unless status:false.
6
6
  import { join } from 'path'
7
7
  import { allTasks, loadRepoConfig } from '../repo-config.ts'
8
+ import { reportToMotherhome } from './motherhome.ts'
8
9
  import { runTasks, type TaskResult } from './task-runner.ts'
9
10
  import { writeStatus } from './write-status.ts'
10
11
 
@@ -44,6 +45,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
44
45
  onTaskFinish: o.onTaskFinish,
45
46
  })
46
47
  const code = summary.ok ? 0 : 1
48
+ // motherhome row (docsBase wiki on this disk) — never affects the gate
49
+ const mh = reportToMotherhome(root, c)
50
+ if (mh.note != null) print(mh.note)
47
51
  if (o.status !== false) {
48
52
  const st = writeStatus(root, { name: c.name, command: 'shipkit ci', lines: collected, exitCode: code })
49
53
  if (!st.written) print(`WARN ${st.warn}`)
@@ -1,11 +1,11 @@
1
1
  // THE STATUS.md writer — every repo gets one (O4, one-runner GO
2
2
  // 2026-07-22): timestamp header + verdict + fenced compact render, the
3
- // shape rv-doctor pioneered. Atomic (tmp file + rename) and lock-guarded:
3
+ // shape rv-doctor pioneered. Atomic + lock-guarded via core/locked-write:
4
4
  // concurrent runners never interleave — the loser skips with a WARN
5
5
  // outcome, never corrupts the file.
6
6
 
7
- import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
8
7
  import { join } from 'path'
8
+ import { atomicWrite, withFileLock } from './locked-write.ts'
9
9
 
10
10
  export type StatusInput = {
11
11
  /** repo name — headline */
@@ -20,25 +20,10 @@ export type StatusInput = {
20
20
  export type StatusOutcome = { written: boolean; warn?: string }
21
21
 
22
22
  const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '')
23
- // a runner that died without unlocking must not wedge STATUS.md forever
24
- const LOCK_STALE_MS = 120_000
25
23
 
26
24
  export function writeStatus(root: string, s: StatusInput): StatusOutcome {
27
- const lockPath = join(root, 'STATUS.md.lock')
28
- const grab = (): boolean => {
29
- try { writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); return true } catch { return false }
30
- }
31
- if (!grab()) {
32
- let stale = false
33
- try { stale = Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {
34
- // lock vanished between grab and stat — the racing writer just
35
- // finished; still skip this round rather than fight over it
36
- }
37
- if (stale) rmSync(lockPath, { force: true })
38
- if (!stale || !grab())
39
- return { written: false, warn: `STATUS.md locked by another runner (${lockPath}) — skipped this write` }
40
- }
41
- try {
25
+ const target = join(root, 'STATUS.md')
26
+ const r = withFileLock(target, () => {
42
27
  const clean = s.lines.map(stripAnsi)
43
28
  const fails = clean.filter((l) => l.startsWith('FAIL')).length
44
29
  const warns = clean.filter((l) => l.startsWith('WARN')).length
@@ -60,11 +45,7 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
60
45
  ${clean.join('\n').trim()}
61
46
  \`\`\`
62
47
  `
63
- const tmpPath = join(root, `.STATUS.md.tmp-${process.pid}`)
64
- writeFileSync(tmpPath, body)
65
- renameSync(tmpPath, join(root, 'STATUS.md'))
66
- return { written: true }
67
- } finally {
68
- rmSync(lockPath, { force: true })
69
- }
48
+ atomicWrite(target, body)
49
+ })
50
+ return r.ok ? { written: true } : { written: false, warn: `STATUS.md locked by another runner — skipped this write` }
70
51
  }
package/src/init-repo.ts CHANGED
@@ -7,7 +7,10 @@
7
7
  // by path instead of from node_modules.
8
8
  import { existsSync, readFileSync, writeFileSync } from 'fs'
9
9
  import { join } from 'path'
10
- import { defineRepo, resolveFloor } from './repo-config.ts'
10
+ import { canonicalBodies } from './core/canonical-scripts.ts'
11
+ import { reportToMotherhome } from './core/motherhome.ts'
12
+ import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
13
+ import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
11
14
 
12
15
  const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'rvlib-shipkit'
13
16
 
@@ -16,6 +19,9 @@ export default defineRepo({
16
19
  venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
17
20
  social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
18
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' },
19
25
  // health checks: no config — drop a <name>.check.ts file anywhere,
20
26
  // \`shipkit check\` auto-discovers and runs it (wire output, exit code)
21
27
  })
@@ -28,7 +34,18 @@ export type InitOpts = {
28
34
 
29
35
  export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
30
36
  const configPath = join(root, 'repo.config.ts')
31
- if (existsSync(configPath)) { console.log(`skip: repo.config.ts already exists`); return 0 }
37
+ if (existsSync(configPath)) {
38
+ console.log(`skip: repo.config.ts already exists`)
39
+ // adoption path: re-running init on an existing repo still converges
40
+ // its .gitignore policy block (same fixer as `check --fix SK004`),
41
+ // with the repo's own contributed lines honored
42
+ const cfg = await loadRepoConfig(root)
43
+ const g = await gitignorePolicy.fix!({ root, config: cfg })
44
+ console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
45
+ const mh = reportToMotherhome(root, cfg)
46
+ if (mh.note != null) console.log(mh.note)
47
+ return 0
48
+ }
32
49
  const pkgPath = join(root, 'package.json')
33
50
  if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
34
51
  const pkgRaw = readFileSync(pkgPath, 'utf8')
@@ -38,23 +55,22 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
38
55
  console.log('ok: repo.config.ts created')
39
56
  const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
40
57
  pkg.scripts ??= {}
41
- const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
42
58
  let added = 0
43
- // canonical script NAME → shipkit command (hub runs `shipkit serve`) —
44
- // vocabulary owned by meta/rv-conventions/package-json.md
45
- const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve', mv: 'mv' } as const
46
- for (const [k, cmd] of Object.entries(CANONICAL)) {
47
- const v = run(cmd)
59
+ // canonical script bodies — ONE source (core/canonical-scripts.ts),
60
+ // shared with rule SK001's autofix; vocabulary owned by
61
+ // meta/rv-conventions/package-json.md
62
+ for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
48
63
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
49
64
  else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
50
65
  }
51
- // baseline `test` is universal (bun test) — lint/fmt bodies are
52
- // stack-specific, the scaffold pages own those
53
- if (pkg.scripts['test'] == null) { pkg.scripts['test'] = 'bun test'; added++ }
54
66
  if (added > 0) {
55
67
  writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
56
68
  console.log(`ok: ${added} package.json script(s) added`)
57
69
  }
70
+ // the .gitignore policy block (contract git policy) — same fixer as
71
+ // `shipkit check --fix SK004`, one code path
72
+ const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
73
+ console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
58
74
  // show what the gate actually is — a thin floor must be seen at init
59
75
  // time, not discovered after a bad commit slipped through
60
76
  const floor = resolveFloor(defineRepo({ name }), root)