rvlib-shipkit 0.10.0 → 0.13.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 (61) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +42 -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_Needs.ts +47 -0
  12. package/src/cli/cmd_Reflect.ts +116 -0
  13. package/src/cli/cmd_Rules.ts +32 -0
  14. package/src/cli/cmd_Serve.ts +1 -1
  15. package/src/cli.ts +7 -2
  16. package/src/collections/actions.ts +85 -0
  17. package/src/collections/collection-types.ts +65 -0
  18. package/src/collections/graphs.ts +34 -0
  19. package/src/collections/pings.ts +72 -0
  20. package/src/collections/posts.ts +125 -0
  21. package/src/collections/providers.ts +275 -0
  22. package/src/contracts/apply-contracts.ts +47 -0
  23. package/src/contracts/run-contracts.ts +8 -3
  24. package/src/core/canonical-scripts.ts +46 -0
  25. package/src/core/check-suite.ts +6 -3
  26. package/src/core/locked-write.ts +39 -0
  27. package/src/core/motherhome.ts +87 -0
  28. package/src/core/run-check.ts +28 -15
  29. package/src/core/run-ci.ts +4 -0
  30. package/src/core/write-status.ts +7 -26
  31. package/src/init-repo.ts +24 -11
  32. package/src/reflect/prompt-corpus.ts +305 -0
  33. package/src/repo-config.ts +26 -38
  34. package/src/rules/SK001-package-json-shape.ts +156 -0
  35. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  36. package/src/rules/SK003-claude-md-shape.ts +64 -0
  37. package/src/rules/SK004-gitignore-policy.ts +105 -0
  38. package/src/rules/SK005-rvlib-versions.ts +99 -0
  39. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  40. package/src/rules/SK007-github-visibility.ts +63 -0
  41. package/src/rules/rule-registry.ts +32 -0
  42. package/src/rules/rule.ts +62 -0
  43. package/src/rules/run-fix.ts +57 -0
  44. package/src/serve/hub-app/col-card.tsx +192 -0
  45. package/src/serve/hub-app/cols-svc.ts +136 -0
  46. package/src/serve/hub-app/geometry-view.tsx +155 -0
  47. package/src/serve/hub-app/health-svc.ts +49 -0
  48. package/src/serve/hub-app/hub-app.tsx +240 -127
  49. package/src/serve/hub-app/hub-st.ts +169 -0
  50. package/src/serve/hub-app/hub-types.ts +72 -0
  51. package/src/serve/hub-app/hub.css +286 -35
  52. package/src/serve/hub-app/index.html +1 -1
  53. package/src/serve/hub-app/md.ts +24 -0
  54. package/src/serve/hub-app/media-svc.ts +429 -0
  55. package/src/serve/hub-app/media-view.tsx +299 -0
  56. package/src/serve/run-serve.ts +46 -91
  57. package/src/serve/serve-hub.ts +99 -49
  58. package/src/shipkit.ts +13 -6
  59. package/src/assets/server/studio-page.ts +0 -687
  60. package/src/core/package-json-check.ts +0 -88
  61. package/src/serve/hub-pages.ts +0 -45
@@ -0,0 +1,275 @@
1
+ // The built-in collection providers — each one REPLACED an attentionCollector
2
+ // guard (the collector is gone; never reintroduce a parallel projection).
3
+ // Behaviors preserved by name: per-source failure isolation (projectNeeds
4
+ // guards per collection), the identity self-heal before surfacing, and the
5
+ // asks READ path never minting asks/ on a repo without one. Dedup doctrine
6
+ // (his correction 2026-07-23): dedup = acted-on once, NEVER hidden — an
7
+ // ask-covered missing cred still shows RED in secrets with a pointer to the
8
+ // ask; verbs and pings stay with the ask. Each wire line of STATUS.md lives
9
+ // in exactly ONE collection: cred probe lines in secrets, everything else
10
+ // in repo health.
11
+ import { existsSync, readFileSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { isStale } from '../assets/gen/derived.ts'
14
+ import { allAssets, mediaOf, type ResolvedKit } from '../assets/kit-config.ts'
15
+ import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
16
+ import { healIdentity } from '../assets/run-assets.ts'
17
+ import { doneAsks, openAsks } from '../asks/asks-queue.ts'
18
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
19
+ import { parseWireOutput } from '../core/wire.ts'
20
+ import type { RepoConfig } from '../repo-config.ts'
21
+ import { activeContracts, contractsConfigFrom } from '../contracts/apply-contracts.ts'
22
+ import type { Collection, Instance } from './collection-types.ts'
23
+ import { socialPostsCollection } from './posts.ts'
24
+
25
+ const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? 'asks')
26
+ // read path must not mint an asks/ dir (listAsks mkdirs on read)
27
+ const openAsksSafe = (root: string, c: RepoConfig): ReturnType<typeof openAsks> => {
28
+ const dir = askDirOf(root, c)
29
+ return existsSync(dir) ? openAsks(dir) : []
30
+ }
31
+
32
+ const CRED_LINE = /cred ([a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+) missing/
33
+
34
+ /** STATUS.md's check output, regrouped: one item per ok/WARN/FAIL wire line
35
+ * with its indented fix: pointers riding along. The writer's markdown
36
+ * wrapper (comment, title, verdict, fences) parses as notes and drops out. */
37
+ type WireItem = { kind: 'ok' | 'warn' | 'fail'; text: string; fixes: string[] }
38
+ const statusWire = (root: string): WireItem[] => {
39
+ const p = join(root, 'STATUS.md')
40
+ if (!existsSync(p)) return []
41
+ const items: WireItem[] = []
42
+ for (const l of parseWireOutput(readFileSync(p, 'utf8'))) {
43
+ if (l.kind === 'ok' || l.kind === 'warn' || l.kind === 'fail') items.push({ kind: l.kind, text: l.text, fixes: [] })
44
+ else if ((l.kind === 'fix-warn' || l.kind === 'fix-fail') && items.length > 0) items[items.length - 1]!.fixes.push(l.text)
45
+ }
46
+ return items
47
+ }
48
+
49
+ /** wire lines re-rendered as a fenced block for an instance's md */
50
+ const wireMd = (lines: string[]): string => `\`\`\`\n${lines.join('\n')}\n\`\`\``
51
+
52
+ export function asksCollection(root: string, c: RepoConfig): Collection {
53
+ return {
54
+ id: 'asks',
55
+ title: 'asks',
56
+ media: 'text',
57
+ list: async () => {
58
+ const rel = c.asks?.dir ?? 'asks'
59
+ const open = openAsksSafe(root, c).map((a): Instance => ({
60
+ id: `ask:${a.file}`,
61
+ text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}`,
62
+ severity: 'red' as const,
63
+ verbs: ['done' as const, 'paste-proof' as const],
64
+ file: `${rel}/${a.file}`,
65
+ md: askMd(join(root, rel, a.file)),
66
+ remarks: lastRemarks(join(root, rel, a.file)),
67
+ }))
68
+ // the done/ archive rides along QUIET (no severity) — past answers
69
+ // stay consultable and remarkable; newest first, after every open ask
70
+ const done = doneAsks(askDirOf(root, c)).map((a): Instance => ({
71
+ id: `ask:done/${a.file}`,
72
+ text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}`,
73
+ file: `${rel}/done/${a.file}`,
74
+ md: askMd(join(root, rel, 'done', a.file)),
75
+ remarks: lastRemarks(join(root, rel, 'done', a.file)),
76
+ }))
77
+ return [...open, ...done]
78
+ },
79
+ }
80
+ }
81
+
82
+ // a card, not a document dump — the cut is loud (…) so a clamped card never
83
+ // passes for the whole truth file
84
+ const MD_CAP = 4000
85
+
86
+ /** the ask file's markdown body: frontmatter stripped, trimmed, capped */
87
+ const askMd = (path: string): string | undefined => {
88
+ if (!existsSync(path)) return undefined
89
+ const body = readFileSync(path, 'utf8').replace(/^---\n[\s\S]*?\n---\n?/, '').trim()
90
+ if (body === '') return undefined
91
+ // code-point slice — a UTF-16 cut can strand half a surrogate pair
92
+ return body.length > MD_CAP ? `${[...body].slice(0, MD_CAP).join('')}…` : body
93
+ }
94
+
95
+ /** his latest dated remark lines from a file's tail — the boot briefing */
96
+ const lastRemarks = (path: string, max = 3): string[] | undefined => {
97
+ if (!existsSync(path)) return undefined
98
+ const lines = readFileSync(path, 'utf8').split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2}: /.test(l))
99
+ return lines.length > 0 ? lines.slice(-max) : undefined
100
+ }
101
+
102
+ /** repo-declared secrets (repo.config `creds:`) + cred FAIL lines from
103
+ * STATUS.md — the tile carries the mint artifact as data. EVERY declared
104
+ * cred is listed. Missing + unclaimed = red with the set verb + artifact;
105
+ * missing + ask-covered = STILL red (never hidden), no verbs — the text
106
+ * points at the ask that owns the action. The probe FAIL line (+ its fix:
107
+ * pointers) rides the instance's md, never a health line.
108
+ * Declared-and-present creds show QUIET, the vault is consultable. */
109
+ export function credsCollection(root: string, c: RepoConfig): Collection {
110
+ return {
111
+ id: 'creds',
112
+ title: 'secrets',
113
+ media: 'pointer',
114
+ list: async () => {
115
+ const rel = c.asks?.dir ?? 'asks'
116
+ const asks = openAsksSafe(root, c)
117
+ const coveringAsk = (name: string): { file: string } | undefined =>
118
+ asks.find((a) => a.title.toLowerCase().includes(name.split('/')[1] ?? name))
119
+ // only a FAIL probe proves missing — a WARN/narrative mention never reds
120
+ const probes = new Map<string, WireItem>()
121
+ for (const i of statusWire(root)) {
122
+ if (i.kind !== 'fail') continue
123
+ const name = i.text.match(CRED_LINE)?.[1]
124
+ if (name != null && !probes.has(name)) probes.set(name, i)
125
+ }
126
+ const out: Instance[] = []
127
+ for (const [name, probe] of probes) {
128
+ const decl = (c.creds ?? []).find((d) => d.name === name)
129
+ const md = wireMd([`FAIL: ${probe.text}`, ...probe.fixes.map((f) => `fix: ${f}`)])
130
+ const ask = coveringAsk(name)
131
+ if (ask != null)
132
+ // dedup = acted-on once, never hidden: the ask keeps the
133
+ // verbs and the ping; the cred still shows RED here
134
+ out.push({
135
+ id: `cred:${name}`,
136
+ text: `${name} missing${decl?.service != null ? ` — ${decl.service}` : ''} — ask ${rel}/${ask.file} owns this`,
137
+ severity: 'red',
138
+ md,
139
+ askOwned: true,
140
+ })
141
+ else out.push({ ...credInstance(name, c.credsCommand, decl), md })
142
+ }
143
+ // every declared cred not proven missing is a quiet instance —
144
+ // present secrets carry no severity and no copy command (nothing
145
+ // to run), just the mint pointer when one is declared
146
+ for (const d of c.creds ?? []) {
147
+ if (probes.has(d.name)) continue
148
+ out.push({
149
+ id: `cred:${d.name}`,
150
+ text: `${d.name}${d.service != null ? ` — ${d.service}` : ''}`,
151
+ artifact: d.mintUrl != null ? { url: d.mintUrl } : undefined,
152
+ })
153
+ }
154
+ return out
155
+ },
156
+ }
157
+ }
158
+
159
+ // the store command is repo config DATA (`credsCommand` template) — the lib
160
+ // never names any particular secret manager
161
+ const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { service?: string; mintUrl?: string; steps?: string[] }): Instance => ({
162
+ id: `cred:${name}`,
163
+ text: `${name} missing${decl?.service != null ? ` — ${decl.service}` : ''}`,
164
+ severity: 'red',
165
+ artifact: { copy: cmdTemplate?.replaceAll('{name}', name), url: decl?.mintUrl, steps: decl?.steps },
166
+ verbs: ['set'],
167
+ })
168
+
169
+ /** media kit instances — BROWSE-ALL: every kit asset is an instance (the
170
+ * page shows everything that exists; healthy = no severity, dim tile).
171
+ * Identity self-heals BEFORE surfacing; manual with nothing uploaded =
172
+ * red (provide); rolls with no pick = yellow (a default exists, taste
173
+ * pending — auto-pick-first is blessed); stale = yellow. */
174
+ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal: typeof healIdentity = healIdentity): Collection {
175
+ return {
176
+ id: 'media',
177
+ title: 'assets',
178
+ media: 'image',
179
+ list: async () => {
180
+ const idn = await heal(root)
181
+ const out: Instance[] = []
182
+ if (idn.state === 'blocked')
183
+ out.push({ id: 'media:identity', text: `identity — ${idn.detail ?? 'blocked'}`, severity: 'red', href: '/#identity-logo', verbs: ['pick'] })
184
+ if (kit == null) return out
185
+ const notes = assetNotes(root)
186
+ for (const f of allAssets(kit)) {
187
+ const dir = assetDir(join(root, kit.workspace), f.asset.id)
188
+ const rolls = listRolls(dir)
189
+ const manual = f.kind.generate.backend === 'manual'
190
+ const base: Instance = {
191
+ id: `media:${f.asset.id}`,
192
+ text: f.asset.id,
193
+ href: `/#${f.asset.id}`,
194
+ // image kinds only — /api/thumb serves raw roll bytes, and
195
+ // an mp3 inside an <img> is a broken glyph on every tile
196
+ img: rolls.length > 0 && mediaOf(f.kind) === 'image' ? `/media/api/thumb?id=${encodeURIComponent(f.asset.id)}` : undefined,
197
+ file: '.rv-assets/kit.ts',
198
+ remarks: notes.get(f.asset.id),
199
+ }
200
+ if (manual && rolls.length === 0)
201
+ // the brief IS the paste-ready artifact ("I don't even
202
+ // know what to paste" — the tile hands him the prompt)
203
+ out.push({ ...base, text: `${f.asset.id} — manual, nothing uploaded`, severity: 'red', verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined })
204
+ else if (rolls.length > 0 && (await readPick(dir)).pick == null)
205
+ out.push({ ...base, text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, severity: 'yellow', verbs: ['pick'] })
206
+ else if (await isStale(kit, join(root, kit.workspace), f))
207
+ out.push({ ...base, text: `${f.asset.id} — stale (a parent was re-picked)`, severity: 'yellow', verbs: ['pick'] })
208
+ else out.push(base) // healthy — visible, dim, never in needs
209
+ }
210
+ return out
211
+ },
212
+ }
213
+ }
214
+
215
+ /** `.rv-assets/notes.md` — his dated asset remarks, one git-tracked file:
216
+ * `- YYYY-MM-DD [asset-id]: text` */
217
+ const assetNotes = (root: string): Map<string, string[]> => {
218
+ const p = join(root, '.rv-assets', 'notes.md')
219
+ const map = new Map<string, string[]>()
220
+ if (!existsSync(p)) return map
221
+ for (const m of readFileSync(p, 'utf8').matchAll(/^- (\d{4}-\d{2}-\d{2}) \[([^\]]+)\]: (.+)$/gm)) {
222
+ const key = m[2]!
223
+ map.set(key, [...(map.get(key) ?? []).slice(-2), `- ${m[1]}: ${m[3]}`])
224
+ }
225
+ return map
226
+ }
227
+
228
+ /** repo health — NON-curatable feed items: EVERY wire line of STATUS.md's
229
+ * check output (ok = quiet, WARN = yellow, FAIL = red; the passing lines
230
+ * ARE listed — the page shows the whole check run), plus contract
231
+ * FAIL/WARN and hub process staleness. fix: pointers ride the owning
232
+ * instance's md as a code block. Cred probe FAIL lines (+ their fixes) are
233
+ * routed to the secrets collection, never duplicated here. */
234
+ export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVersion?: string; libVersion?: () => string }): Collection {
235
+ return {
236
+ id: 'health',
237
+ title: 'repo health',
238
+ media: 'health',
239
+ list: async () => {
240
+ const out: Instance[] = []
241
+ if (o?.bootVersion != null && o.libVersion != null) {
242
+ const now = o.libVersion()
243
+ if (now !== o.bootVersion)
244
+ out.push({ id: 'health:stale-hub', severity: 'red', text: `hub is running shipkit ${o.bootVersion} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` })
245
+ }
246
+ for (const i of statusWire(root)) {
247
+ if (i.kind === 'fail' && CRED_LINE.test(i.text)) continue // secrets own the cred probe + its fix lines
248
+ // the feed line carries severity already — a raw FAIL:/WARN marker would stack the signal
249
+ // id = full text (never sliced: a shared 60-char prefix must not collide two items — pings dedup on ids)
250
+ out.push({
251
+ id: `health:${i.text}`,
252
+ text: i.text,
253
+ severity: i.kind === 'fail' ? 'red' : i.kind === 'warn' ? 'yellow' : undefined,
254
+ md: i.fixes.length > 0 ? wireMd(i.fixes.map((f) => `fix: ${f}`)) : undefined,
255
+ })
256
+ }
257
+ const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
258
+ for (const l of r.lines) {
259
+ if (l.kind === 'fail') out.push({ id: `health:contract:${l.text}`, severity: 'red', text: l.text })
260
+ else if (l.kind === 'warn') out.push({ id: `health:contractwarn:${l.text}`, severity: 'yellow', text: l.text })
261
+ }
262
+ return out
263
+ },
264
+ }
265
+ }
266
+
267
+ export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit | null, o?: { bootVersion?: string; libVersion?: () => string }): Collection[] {
268
+ return [
269
+ repoHealthCollection(root, c, o),
270
+ asksCollection(root, c),
271
+ credsCollection(root, c),
272
+ mediaKitCollection(root, kit),
273
+ socialPostsCollection(root),
274
+ ]
275
+ }
@@ -0,0 +1,47 @@
1
+ // The ONE bridge from the contracts engine into wire output — every surface
2
+ // that reports contract fulfillness (`shipkit check` via rule SK002, the ci
3
+ // `contracts` task, `shipkit contracts`) goes through applyContractsResult,
4
+ // so tags, the empty-repo sentence, and severity plumbing exist exactly
5
+ // once. Kinds pass through raw so fix pointers and visible exempts survive
6
+ // every printer.
7
+ import { existsSync } from 'fs'
8
+ import { join } from 'path'
9
+ import type { CheckCtx } from '../core/check-ctx.ts'
10
+ import type { RepoConfig } from '../repo-config.ts'
11
+ import { tagRuleLines, type Rule } from '../rules/rule.ts'
12
+ import { allContracts } from './contract-manifests.ts'
13
+ import type { ContractManifest } from './contract-types.ts'
14
+ import type { ContractsConfig, ContractsResult } from './contracts-engine.ts'
15
+
16
+ export function contractsConfigFrom(c: RepoConfig, root: string): ContractsConfig {
17
+ const venues = c.venues ?? []
18
+ const social = c.social ?? []
19
+ const declared = [
20
+ ...venues,
21
+ ...(social.length > 0 ? ['social'] : []),
22
+ ...(social.includes('discord') ? ['discord'] : []),
23
+ ]
24
+ return {
25
+ root,
26
+ declared,
27
+ social,
28
+ publicSurface: venues.length > 0 || existsSync(join(root, 'deploy.sh')),
29
+ exempt: c.contracts?.exempt,
30
+ docsBase: c.docsBase,
31
+ }
32
+ }
33
+
34
+ export function activeContracts(c: RepoConfig): ContractManifest[] {
35
+ return [...allContracts, ...(c.contracts?.extra ?? [])]
36
+ }
37
+
38
+ export function applyContractsResult(t: CheckCtx, r: ContractsResult, tag?: Rule): void {
39
+ const lines = tag != null ? tagRuleLines(tag, r.lines) : r.lines
40
+ for (const l of lines) t.lines.push(l)
41
+ if (r.exitCode === 1) t.failed = true
42
+ if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
43
+ if (r.lines.length === 0) {
44
+ const text = 'no contracts apply (nothing declared, nothing detected)'
45
+ t.ok(tag != null ? `[${tag.id}] ${text}` : text)
46
+ }
47
+ }
@@ -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}`)