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,305 @@
1
+ // shipkit reflect — mine the user's TYPED prompts out of Claude Code transcripts.
2
+ // Transcripts live at <configRoot>/projects/<slug>/*.jsonl where <slug> is the
3
+ // repo's absolute path with EVERY non-alphanumeric char replaced by '-'.
4
+ //
5
+ // A corpus is only as good as what it excludes: the harness writes many
6
+ // user-role entries the human never typed (tool results, command wrappers,
7
+ // compaction summaries, subagent prompts). Every filter below exists because
8
+ // one of them polluted a real extraction pass.
9
+ import { existsSync, readFileSync, readdirSync } from 'fs'
10
+ import { homedir } from 'os'
11
+ import { basename, join, resolve } from 'path'
12
+
13
+ export type PromptEntry = {
14
+ uuid: string
15
+ timestamp: string
16
+ sessionId: string
17
+ /** config root the prompt came from, e.g. `.claude` */
18
+ account: string
19
+ text: string
20
+ /** 'queued' = typed WHILE the agent was working (a `queue-operation` entry).
21
+ * Same human, higher signal: corrections and standing conventions land there. */
22
+ origin: 'typed' | 'queued'
23
+ }
24
+
25
+ export type ConfigRoot = { account: string; projects: string }
26
+
27
+ const siblingConfigDirs = (home: string): string[] => {
28
+ if (!existsSync(home)) return []
29
+ return readdirSync(home)
30
+ .filter((d) => d.startsWith('.claude-'))
31
+ .map((d) => join(home, d))
32
+ }
33
+
34
+ /** EVERY Claude Code config root on this machine, unioned: $CLAUDE_CONFIG_DIR
35
+ * (`:` or `,` separated) PLUS ~/.claude PLUS any sibling ~/.claude-<name>
36
+ * holding a projects/ dir. Additive on purpose — a second local account keeps
37
+ * its own transcripts, and the env var only names the account running RIGHT
38
+ * NOW, so honoring it alone hides every session of the other one (field bug
39
+ * 2026-07-24: a corpus mined that way lost a third of his prompts). */
40
+ export function discoverConfigRoots(home: string = homedir()): ConfigRoot[] {
41
+ const fromEnv = (process.env.CLAUDE_CONFIG_DIR ?? '')
42
+ .split(/[:,]/)
43
+ .map((p) => p.trim())
44
+ .filter((p) => p !== '')
45
+ const seen = new Set<string>()
46
+ const roots: ConfigRoot[] = []
47
+ for (const dir of [...fromEnv, join(home, '.claude'), ...siblingConfigDirs(home)]) {
48
+ const projects = join(resolve(dir), 'projects')
49
+ if (seen.has(projects) || !existsSync(projects)) continue
50
+ seen.add(projects)
51
+ roots.push({ account: basename(resolve(dir)), projects })
52
+ }
53
+ return roots
54
+ }
55
+
56
+ /** Claude Code slugs EVERY non-alphanumeric char to '-', not just '/' */
57
+ export const projectSlug = (repoPath: string): string => resolve(repoPath).replace(/[^a-zA-Z0-9]/g, '-')
58
+
59
+ // wrappers the harness injects as user-role entries — none of them are typed
60
+ const NOISE_PREFIXES = [
61
+ '<local-command-caveat>',
62
+ '<local-command-stdout>',
63
+ '<command-name>',
64
+ '<command-message>',
65
+ '<system-reminder>',
66
+ '<task-notification>',
67
+ 'Caveat: The messages below',
68
+ '[Request interrupted',
69
+ 'This session is being continued from a previous conversation',
70
+ ]
71
+
72
+ export function isTypedPrompt(text: string): boolean {
73
+ const trimmed = text.trim()
74
+ if (trimmed === '') return false
75
+ return !NOISE_PREFIXES.some((p) => trimmed.startsWith(p))
76
+ }
77
+
78
+ type RawEntry = {
79
+ type?: unknown
80
+ entrypoint?: unknown
81
+ uuid?: unknown
82
+ timestamp?: unknown
83
+ sessionId?: unknown
84
+ isMeta?: unknown
85
+ isSidechain?: unknown
86
+ isCompactSummary?: unknown
87
+ /** queue-operation entries only */
88
+ operation?: unknown
89
+ content?: unknown
90
+ message?: { role?: unknown; content?: unknown }
91
+ }
92
+
93
+ /** His words out of one transcript line, or null when the line is not a prompt
94
+ * he typed. isSidechain = a subagent prompt the AGENT wrote; isMeta and
95
+ * isCompactSummary = harness-generated. Content is a string for a plain prompt,
96
+ * an array of blocks when the message carried pastes or attachments (the text
97
+ * blocks in there are still his). */
98
+ export function promptTextOf(line: string): { text: string; entry: RawEntry } | null {
99
+ let parsed: unknown
100
+ try {
101
+ parsed = JSON.parse(line)
102
+ } catch {
103
+ return null // truncated tail line of a live session
104
+ }
105
+ if (parsed == null || typeof parsed !== 'object') return null
106
+ const entry = parsed as RawEntry
107
+ if (entry.type !== 'user') return null
108
+ if (entry.isMeta === true || entry.isSidechain === true || entry.isCompactSummary === true) return null
109
+ if (entry.entrypoint === 'sdk-cli') return null // a script drove this session, nobody typed it
110
+ if (entry.message?.role !== 'user') return null
111
+ const content = entry.message.content
112
+ let text: string
113
+ if (typeof content === 'string') text = content
114
+ else if (Array.isArray(content)) {
115
+ const parts = content
116
+ .filter((b): b is { type: string; text: string } => b?.type === 'text' && typeof b?.text === 'string')
117
+ .map((b) => b.text)
118
+ if (parts.length === 0) return null // pure tool_result turn
119
+ text = parts.join('\n')
120
+ } else return null
121
+ // the harness appends reminder blocks INSIDE real prompts — strip, keep the text
122
+ text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
123
+ // a slash-command entry is a harness WRAPPER around typed text:
124
+ // <command-name>/cmd</command-name><command-args>THE TEXT</command-args>.
125
+ // Dropping the whole entry throws away real instructions and repro pastes.
126
+ const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)
127
+ if (args?.[1] != null) {
128
+ const body = args[1].trim()
129
+ if (body === '') return null // bare invocation, nothing typed
130
+ const name = text.match(/<command-name>([\s\S]*?)<\/command-name>/)
131
+ text = name?.[1] == null ? body : `${name[1].trim()} ${body}`
132
+ }
133
+ if (!isTypedPrompt(text)) return null
134
+ return { text, entry }
135
+ }
136
+
137
+ /** A prompt he typed while the agent was still working: the harness parks it as
138
+ * `{type:'queue-operation', operation:'enqueue', content}` and, if the run ends
139
+ * first, it may NEVER become a user message. Skipping these loses exactly the
140
+ * mid-flight corrections and standing conventions a retrospective is for
141
+ * (measured 2026-07-26: 72 such texts invisible in this repo alone). */
142
+ export function queuedTextOf(line: string): { text: string; entry: RawEntry } | null {
143
+ let parsed: unknown
144
+ try {
145
+ parsed = JSON.parse(line)
146
+ } catch {
147
+ return null
148
+ }
149
+ if (parsed == null || typeof parsed !== 'object') return null
150
+ const entry = parsed as RawEntry
151
+ if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue') return null
152
+ if (typeof entry.content !== 'string') return null
153
+ const text = entry.content.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
154
+ if (!isTypedPrompt(text)) return null
155
+ return { text, entry }
156
+ }
157
+
158
+ export function extractPrompts(jsonl: string, ctx: { sessionId: string; account: string }): PromptEntry[] {
159
+ const out: PromptEntry[] = []
160
+ for (const line of jsonl.split('\n')) {
161
+ if (line.trim() === '') continue
162
+ const queued = queuedTextOf(line)
163
+ const hit = queued ?? promptTextOf(line)
164
+ if (hit == null) continue
165
+ const timestamp = typeof hit.entry.timestamp === 'string' ? hit.entry.timestamp : ''
166
+ const fallbackId = `${queued == null ? 'typed' : 'queue'}:${ctx.sessionId}:${timestamp}`
167
+ out.push({
168
+ uuid: typeof hit.entry.uuid === 'string' ? hit.entry.uuid : fallbackId,
169
+ timestamp,
170
+ sessionId: typeof hit.entry.sessionId === 'string' ? hit.entry.sessionId : ctx.sessionId,
171
+ account: ctx.account,
172
+ text: hit.text,
173
+ origin: queued == null ? 'typed' : 'queued',
174
+ })
175
+ }
176
+ return out
177
+ }
178
+
179
+ /** Genuine repetition is SIGNAL (saying it twice raises priority), so dedupe
180
+ * must remove only mechanical copies:
181
+ * - a resumed/forked session replaying an entry (same uuid, or same text at
182
+ * the same instant),
183
+ * - a queued prompt that later became a user message (same text, any gap —
184
+ * one keystroke, two records).
185
+ * Anything else repeated is kept, so the corpus never manufactures a priority
186
+ * he did not give. Input must be chronological. */
187
+ const REPEAT_WINDOW_MS = 5 * 60 * 1000
188
+
189
+ const normalize = (text: string): string => text.replace(/\s+/g, ' ').trim().toLowerCase()
190
+
191
+ export function dedupePrompts(prompts: PromptEntry[]): PromptEntry[] {
192
+ const seenUuid = new Set<string>()
193
+ const lastSeenAt = new Map<string, number>()
194
+ /** queued texts not yet matched by their user-message twin — consumption is
195
+ * ONE-time, so a genuine re-ask of the same words later still counts */
196
+ const awaitingConsume = new Set<string>()
197
+ const out: PromptEntry[] = []
198
+ for (const p of prompts) {
199
+ if (seenUuid.has(p.uuid)) continue
200
+ seenUuid.add(p.uuid)
201
+ const key = normalize(p.text)
202
+ const at = Number.isNaN(Date.parse(p.timestamp)) ? 0 : Date.parse(p.timestamp)
203
+ const prev = lastSeenAt.get(key)
204
+ if (prev != null) {
205
+ if (awaitingConsume.has(key) && p.origin === 'typed') {
206
+ awaitingConsume.delete(key) // the queued keystroke arriving as a message
207
+ lastSeenAt.set(key, at)
208
+ continue
209
+ }
210
+ if (at - prev < REPEAT_WINDOW_MS) continue // replay, or a resend seconds apart
211
+ }
212
+ lastSeenAt.set(key, at)
213
+ if (p.origin === 'queued') awaitingConsume.add(key)
214
+ out.push(p)
215
+ }
216
+ return out
217
+ }
218
+
219
+ /** Sessions whose transcript was pruned by retention survive only as a
220
+ * truncated firstPrompt in the per-project sessions-index.json. */
221
+ function indexFallbackPrompts(dir: string, account: string, have: Set<string>): PromptEntry[] {
222
+ const indexPath = join(dir, 'sessions-index.json')
223
+ if (!existsSync(indexPath)) return []
224
+ let index: { entries?: unknown }
225
+ try {
226
+ index = JSON.parse(readFileSync(indexPath, 'utf8')) as { entries?: unknown }
227
+ } catch {
228
+ return []
229
+ }
230
+ const entries = Array.isArray(index.entries) ? index.entries : []
231
+ const out: PromptEntry[] = []
232
+ for (const raw of entries) {
233
+ const e = raw as { sessionId?: string; firstPrompt?: string; created?: string; fullPath?: string }
234
+ if (typeof e.sessionId !== 'string' || have.has(e.sessionId)) continue
235
+ if (typeof e.fullPath === 'string' && existsSync(e.fullPath)) continue
236
+ if (typeof e.firstPrompt !== 'string' || !isTypedPrompt(e.firstPrompt)) continue
237
+ out.push({
238
+ uuid: `index:${e.sessionId}`,
239
+ timestamp: typeof e.created === 'string' ? e.created : '',
240
+ sessionId: e.sessionId,
241
+ account,
242
+ text: `${e.firstPrompt}\n\n[transcript pruned — first prompt only, truncated]`,
243
+ origin: 'typed',
244
+ })
245
+ }
246
+ return out
247
+ }
248
+
249
+ export type RepoCorpus = {
250
+ /** project dirs actually scanned, across every config root */
251
+ dirs: string[]
252
+ /** config roots searched — printed when nothing was found */
253
+ roots: string[]
254
+ prompts: PromptEntry[]
255
+ }
256
+
257
+ /** Every prompt he typed in a repo: all config roots, the repo's project dir
258
+ * PLUS its worktree dirs (`<slug>--claude-worktrees-*`), deduped, chronological. */
259
+ export function collectRepoPrompts(repoPath: string, roots: ConfigRoot[] = discoverConfigRoots()): RepoCorpus {
260
+ const slug = projectSlug(repoPath)
261
+ const dirs: string[] = []
262
+ const prompts: PromptEntry[] = []
263
+ for (const root of roots) {
264
+ const projectDirs = readdirSync(root.projects).filter(
265
+ (d) => d === slug || d.startsWith(`${slug}--claude-worktrees-`),
266
+ )
267
+ for (const projectDir of projectDirs) {
268
+ const dir = join(root.projects, projectDir)
269
+ dirs.push(dir)
270
+ const sessions = new Set<string>()
271
+ for (const file of readdirSync(dir)) {
272
+ if (!file.endsWith('.jsonl')) continue
273
+ const sessionId = file.replace(/\.jsonl$/, '')
274
+ sessions.add(sessionId)
275
+ prompts.push(
276
+ ...extractPrompts(readFileSync(join(dir, file), 'utf8'), { sessionId, account: root.account }),
277
+ )
278
+ }
279
+ prompts.push(...indexFallbackPrompts(dir, root.account, sessions))
280
+ }
281
+ }
282
+ prompts.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
283
+ return { dirs, roots: roots.map((r) => r.projects), prompts: dedupePrompts(prompts) }
284
+ }
285
+
286
+ /** Fence a prompt so its own backticks and markdown cannot break the document */
287
+ function fenced(text: string): string {
288
+ const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length))
289
+ const fence = '`'.repeat(Math.max(3, longest + 1))
290
+ return `${fence}text\n${text}\n${fence}`
291
+ }
292
+
293
+ /** Chronological corpus, one numbered section per prompt — meant to be read as
294
+ * a corpus, not as a task list. */
295
+ export function corpusMarkdown(repoPath: string, prompts: PromptEntry[]): string {
296
+ const queued = prompts.filter((p) => p.origin === 'queued').length
297
+ const count = queued === 0 ? `${prompts.length}` : `${prompts.length}, ${queued} queued mid-run`
298
+ const lines: string[] = [`# prompt corpus — ${resolve(repoPath)} (${count})`, '']
299
+ prompts.forEach((p, i) => {
300
+ const when = p.timestamp.replace('T', ' ').slice(0, 16)
301
+ const tag = p.origin === 'queued' ? ' · queued' : ''
302
+ lines.push(`## ${i + 1} · ${when} · ${p.account} · ${p.sessionId.slice(0, 8)}${tag}`, '', fenced(p.text), '')
303
+ })
304
+ return `${lines.join('\n')}\n`
305
+ }
@@ -11,11 +11,11 @@
11
11
  import { existsSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
- import { allContracts } from './contracts/contract-manifests.ts'
15
- import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts/contracts-engine.ts'
14
+ import { activeContracts, applyContractsResult, contractsConfigFrom } from './contracts/apply-contracts.ts'
15
+ import { evaluateContracts } from './contracts/contracts-engine.ts'
16
16
  import type { ContractManifest } from './contracts/contract-types.ts'
17
+ import { contractsFulfillness } from './rules/SK002-contracts-fulfillness.ts'
17
18
  import type { TaskDef } from './core/task-runner.ts'
18
- import type { CheckCtx } from './core/check-ctx.ts'
19
19
  import type { TuiPanelDef } from './tui/tui-panel.ts'
20
20
  import type { MdGraph } from './wiki/md-graph.ts'
21
21
 
@@ -29,6 +29,10 @@ export type ServeConfig = {
29
29
  wikiGraph?: { title?: string; load: () => MdGraph }
30
30
  /** agent/ graph entrypoint (default: index.md AND README.md) */
31
31
  agentIndex?: string
32
+ /** off-machine hostname for the hub (e.g. a tailnet MagicDNS name) —
33
+ * printed beside localhost and appended to ping messages (phones
34
+ * can't resolve localhost) */
35
+ publicHost?: string
32
36
  }
33
37
 
34
38
  export type RepoConfig = {
@@ -52,6 +56,19 @@ export type RepoConfig = {
52
56
  /** notify command for new RED needs while the hub serves, `{message}` substituted (e.g. a push-notification CLI) */
53
57
  notifyCommand?: string
54
58
  contracts?: { exempt?: Record<string, string>; extra?: ContractManifest[] }
59
+ /** repo-contributed .gitignore lines, rendered INSIDE rule SK004's managed
60
+ * block — for ecosystem-specific names the published lib may not carry */
61
+ gitignore?: string[]
62
+ /** declared github visibility INTENT (absent = 'private') — rule SK007
63
+ * fails loud when `gh repo view` disagrees in either direction */
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[] }
55
72
  /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
56
73
  tui?: { panels?: TuiPanelDef[] }
57
74
  /** the `shipkit serve` hub: port + optional graph hooks */
@@ -59,11 +76,16 @@ export type RepoConfig = {
59
76
  logDir?: string
60
77
  }
61
78
 
62
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', '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'])
63
80
 
64
81
  export function defineRepo(c: RepoConfig): RepoConfig {
65
82
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
66
83
  for (const v of c.venues ?? []) if (!/^[a-z0-9-]+$/.test(v)) throw new Error(`defineRepo: venue '${v}' must be kebab-case`)
84
+ if (c.visibility != null && c.visibility !== 'private' && c.visibility !== 'public')
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 }`)
67
89
  // unknown keys die loud — a typo'd or removed key silently changing the
68
90
  // gate is exactly the never-silence failure mode (A23)
69
91
  for (const k of Object.keys(c))
@@ -83,44 +105,14 @@ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
83
105
  return mod.default
84
106
  }
85
107
 
86
- export function contractsConfigFrom(c: RepoConfig, root: string): ContractsConfig {
87
- const venues = c.venues ?? []
88
- const social = c.social ?? []
89
- const declared = [
90
- ...venues,
91
- ...(social.length > 0 ? ['social'] : []),
92
- ...(social.includes('discord') ? ['discord'] : []),
93
- ]
94
- return {
95
- root,
96
- declared,
97
- social,
98
- publicSurface: venues.length > 0 || existsSync(join(root, 'deploy.sh')),
99
- exempt: c.contracts?.exempt,
100
- docsBase: c.docsBase,
101
- }
102
- }
103
-
104
- export function activeContracts(c: RepoConfig): ContractManifest[] {
105
- return [...allContracts, ...(c.contracts?.extra ?? [])]
106
- }
107
-
108
- // ONE mapping from an engine result into a CheckCtx — used by BOTH the ci
109
- // task and `shipkit check`; kinds pass through raw so fix pointers and
110
- // visible exempts survive every printer
111
- export function applyContractsResult(t: CheckCtx, r: ContractsResult): void {
112
- for (const l of r.lines) t.lines.push(l)
113
- if (r.exitCode === 1) t.failed = true
114
- if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
115
- if (r.lines.length === 0) t.ok('no contracts apply (nothing declared, nothing detected)')
116
- }
117
-
118
- // the contracts commit-gate task — venue deploy tasks should dep on it
108
+ // the contracts commit-gate task — venue deploy tasks should dep on it.
109
+ // Same SK002-tagged rendering as `shipkit check` and `shipkit contracts`
110
+ // (contracts/apply-contracts.ts is the one bridge).
119
111
  export function contractsTask(c: RepoConfig, root: string): TaskDef {
120
112
  return {
121
113
  id: 'contracts',
122
114
  logic: async (t) => {
123
- applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
115
+ applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)), contractsFulfillness)
124
116
  },
125
117
  }
126
118
  }
@@ -0,0 +1,156 @@
1
+ // SK001 — canonical package.json shape (meta/rv-conventions/package-json.md):
2
+ // private:true, no license, capability-required script names FAIL, banned
3
+ // aliases + missing baseline trio WARN. Doctor surface only — never wired
4
+ // into the ci gate. Pure local fs, no TTL.
5
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
6
+ import { join } from 'path'
7
+ import { canonicalBodies, conditionalBodies, inferBin } from '../core/canonical-scripts.ts'
8
+ import type { CheckCtx } from '../core/check-ctx.ts'
9
+ import type { RepoConfig } from '../repo-config.ts'
10
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
11
+
12
+ type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
13
+
14
+ const OWNER = 'meta/rv-conventions/package-json.md'
15
+
16
+ // alias → { canonical, bodyMatch } — WARN only when the body proves the
17
+ // alias really is the canonical capability (a `serve` that serves a dist/
18
+ // folder must not be told to rename)
19
+ const ALIASES: Record<string, { canonical: string; bodyMatch: RegExp }> = {
20
+ 'serve': { canonical: 'hub', bodyMatch: /(shipkit|cli\.ts) serve\b/ },
21
+ 'identity': { canonical: 'assets:identity', bodyMatch: /assets(:| )identity\b/ },
22
+ 'pretty': { canonical: 'fmt', bodyMatch: /./ },
23
+ 'check-types': { canonical: 'typecheck', bodyMatch: /./ },
24
+ }
25
+
26
+ const BASELINE = ['test', 'lint', 'fmt'] as const
27
+
28
+ // no per-line fix pointers here — the rule framework appends ONE pointer
29
+ // (docsBase + OWNER) to a failing/warning run, the same one-per-severity
30
+ // doctrine the contracts engine follows
31
+ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConfig): Promise<void> {
32
+ let pkg: Pkg
33
+ try {
34
+ pkg = (await Bun.file(join(root, 'package.json')).json()) as Pkg
35
+ } catch (e) {
36
+ t.fail(`package.json unreadable (${String(e).slice(0, 120)})`)
37
+ return
38
+ }
39
+ const scripts = pkg.scripts ?? {}
40
+ // rvlib-* as a probe ROOT is theoretical (libs live as sub-packages and
41
+ // the probe only runs where repo.config.ts loads) — field inversion is
42
+ // still handled so a future lib-root repo fails correctly, not absurdly
43
+ const isLib = pkg.name?.startsWith('rvlib-') === true
44
+
45
+ // published rvlib-* roots invert the field rules (distribute/npm owns them)
46
+ if (isLib) {
47
+ if (pkg.license !== 'MIT') t.fail(`package.json: rvlib-* package must carry license MIT`)
48
+ if (pkg.private === true) t.fail(`package.json: rvlib-* package must not be private`)
49
+ } else {
50
+ if (pkg.private !== true) t.fail(`package.json: "private": true is required`)
51
+ if (pkg.license != null) t.fail(`package.json: license field is banned on project repos (rvlib-* sub-packages excepted)`)
52
+ }
53
+
54
+ // aliases first — an alias satisfies the requirement but earns a WARN,
55
+ // never a double report (alias WARN + missing-canonical FAIL). A banned
56
+ // alias lingering NEXT TO its canonical still WARNs (sweep on sight),
57
+ // it just doesn't satisfy anything.
58
+ const aliased = new Set<string>()
59
+ for (const [name, a] of Object.entries(ALIASES)) {
60
+ const body = scripts[name]
61
+ if (body == null || !a.bodyMatch.test(body)) continue
62
+ if (scripts[a.canonical] == null) aliased.add(a.canonical)
63
+ t.warn(`package.json: script '${name}' is the house '${a.canonical}' — ${scripts[a.canonical] == null ? 'rename it' : `delete it ('${a.canonical}' exists)`}`)
64
+ }
65
+
66
+ const has = (name: string): boolean => scripts[name] != null || aliased.has(name)
67
+
68
+ const required = requiredScripts(root, c)
69
+ const missing = required.filter((r) => !has(r.name))
70
+ for (const m of missing) t.fail(`package.json: missing script '${m.name}' (${m.why})`)
71
+
72
+ const missingBaseline = BASELINE.filter((n) => !has(n))
73
+ if (missingBaseline.length > 0)
74
+ t.warn(`package.json: missing baseline script(s): ${missingBaseline.join(', ')}`)
75
+
76
+ if (!t.failed && !t.warned)
77
+ t.ok(`package.json shape ok (private, no license, ${required.length} required scripts present)`)
78
+ }
79
+
80
+ // capability → required script names (FAIL); repo.config.ts existence is
81
+ // implied — this rule only runs under `shipkit check`
82
+ function requiredScripts(root: string, c: RepoConfig): Array<{ name: string; why: string }> {
83
+ const required: Array<{ name: string; why: string }> = [
84
+ { name: 'ci', why: 'shipkit repo' },
85
+ { name: 'check', why: 'shipkit repo' },
86
+ { name: 'contracts', why: 'shipkit repo' },
87
+ { name: 'tui', why: 'shipkit repo' },
88
+ { name: 'hub', why: 'shipkit repo' },
89
+ { name: 'mv', why: 'shipkit repo' },
90
+ ]
91
+ if (existsSync(join(root, '.rv-assets/kit.ts'))) required.push({ name: 'assets', why: '.rv-assets/kit.ts exists' })
92
+ if (existsSync(join(root, '.rv-identity/brand.ts'))) required.push({ name: 'assets:identity', why: '.rv-identity/brand.ts exists' })
93
+ if (existsSync(join(root, '.rv-comfy'))) required.push({ name: 'comfy:up', why: '.rv-comfy/ exists' })
94
+ if (existsSync(join(root, 'deploy.sh'))) required.push({ name: 'up', why: 'deploy.sh exists' })
95
+ if (existsSync(join(root, c.asks?.dir ?? 'asks'))) required.push({ name: 'asks', why: 'ask queue exists' })
96
+ return required
97
+ }
98
+
99
+ export const packageJsonShape: Rule = {
100
+ id: 'SK001',
101
+ title: 'package.json',
102
+ owner: OWNER,
103
+ lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
104
+ // mechanical repairs only: field flips + scripts whose canonical body the
105
+ // vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
106
+ // named in the summary, never invented. rvlib-* roots untouched — their
107
+ // license/private are publish decisions.
108
+ fix: (env: RuleEnv): FixResult => {
109
+ const pkgPath = join(env.root, 'package.json')
110
+ if (!existsSync(pkgPath)) return { changed: false, summary: 'no package.json — nothing to fix' }
111
+ const raw = readFileSync(pkgPath, 'utf8')
112
+ let pkg: Pkg & { scripts?: Record<string, string> }
113
+ try { pkg = JSON.parse(raw) as Pkg } catch { return { changed: false, summary: 'package.json unparseable — repair it by hand first' } }
114
+ if (pkg.name?.startsWith('rvlib-') === true) return { changed: false, summary: 'rvlib-* package — license/private are publish decisions, not autofixable' }
115
+ const changes: string[] = []
116
+ const notes: string[] = []
117
+ // any "I am published" marker → the field flips are a publish
118
+ // decision, not a mechanical repair (the LIBRARY-profile gap in
119
+ // meta/gaps.md is still open)
120
+ const publishMarkers: string[] = ['files', 'exports', 'publishConfig'].filter((k) => (pkg as Record<string, unknown>)[k] != null)
121
+ if (pkg.scripts?.['prepublishOnly'] != null) publishMarkers.push('prepublishOnly')
122
+ if (publishMarkers.length > 0) {
123
+ notes.push(`publishable package (${publishMarkers.join(', ')}) — private/license left for a human`)
124
+ } else {
125
+ if (pkg.private !== true) { pkg.private = true; changes.push('private:true set') }
126
+ if (pkg.license != null) { delete pkg.license; changes.push('license removed') }
127
+ }
128
+ pkg.scripts ??= {}
129
+ const bin = inferBin(pkg.scripts)
130
+ const bodies: Record<string, string> = { ...canonicalBodies({ bin }), ...conditionalBodies({ bin }) }
131
+ const skipped: string[] = []
132
+ // a banned alias satisfies a requirement for the lint — adding its
133
+ // canonical beside it would create the exact duplicate the lint then
134
+ // tells a human to delete
135
+ const aliasSatisfied = new Set<string>()
136
+ for (const [name, a] of Object.entries(ALIASES))
137
+ if (pkg.scripts[name] != null && a.bodyMatch.test(pkg.scripts[name]!)) aliasSatisfied.add(a.canonical)
138
+ for (const m of requiredScripts(env.root, env.config).filter((r) => pkg.scripts![r.name] == null && !aliasSatisfied.has(r.name))) {
139
+ const body = bodies[m.name]
140
+ if (body == null) { skipped.push(m.name); continue }
141
+ pkg.scripts[m.name] = body
142
+ changes.push(`script '${m.name}' added`)
143
+ }
144
+ if (pkg.scripts['test'] == null) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
145
+ if (changes.length > 0) {
146
+ const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
147
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
148
+ }
149
+ const summary = [
150
+ changes.length > 0 ? changes.join(', ') : 'nothing mechanical to change',
151
+ ...notes,
152
+ ...(skipped.length > 0 ? [`left for a human (no canonical body): ${skipped.join(', ')}`] : []),
153
+ ].join(' · ')
154
+ return { changed: changes.length > 0, summary }
155
+ },
156
+ }
@@ -0,0 +1,19 @@
1
+ // SK002 — .rv-* contract fulfillness (meta/contracts.md). The rule is the
2
+ // registration point; the evaluation machinery stays in
3
+ // contracts/contracts-engine.ts (venue manifests are DATA rows, not rules —
4
+ // one SK id covers the invariant "declared contracts are fulfilled, folders
5
+ // can't rot undeclared"). Every surface reporting fulfillness (`check`,
6
+ // `shipkit contracts`, the ci `contracts` task) tags with this id via
7
+ // contracts/apply-contracts.ts.
8
+ import { activeContracts, applyContractsResult, contractsConfigFrom } from '../contracts/apply-contracts.ts'
9
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
10
+ import type { Rule } from './rule.ts'
11
+
12
+ export const contractsFulfillness: Rule = {
13
+ id: 'SK002',
14
+ title: 'contracts',
15
+ owner: 'meta/contracts.md',
16
+ lint: async (t, env) => {
17
+ applyContractsResult(t, await evaluateContracts(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
18
+ },
19
+ }
@@ -0,0 +1,64 @@
1
+ // SK003 — the CLAUDE.md two-line contract (meta/agent-folder.md): CLAUDE.md
2
+ // is an include index, never a doc; `@./agent/index.md` comes LAST (include
3
+ // order is SEMANTIC — base context first, repo docs after so they
4
+ // override; a field bug moved the local include line and inverted it); a
5
+ // public repo's `@./CLAUDE.local.md` line-1 indirection must be gitignored.
6
+ // The lib stays generic: no personal include path is asserted — `shipkit
7
+ // start --include` flags own which base include a machine wires in. A repo
8
+ // with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
9
+ // passes.
10
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
11
+ import { join } from 'path'
12
+ import { spawnWithTimeout } from '../core/spawn-timeout.ts'
13
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
14
+
15
+ const AGENT_LINE = '@./agent/index.md'
16
+ const LOCAL_LINE = '@./CLAUDE.local.md'
17
+
18
+ export const claudeMdShape: Rule = {
19
+ id: 'SK003',
20
+ title: 'CLAUDE.md',
21
+ owner: 'meta/agent-folder.md',
22
+ lint: async (t, env) => {
23
+ const claudePath = join(env.root, 'CLAUDE.md')
24
+ const hasAgent = existsSync(join(env.root, 'agent/index.md'))
25
+ if (!existsSync(claudePath)) {
26
+ if (hasAgent) t.fail(`agent/index.md exists but there is no CLAUDE.md routing it — the two-line contract wants ${AGENT_LINE}`)
27
+ else t.ok('no CLAUDE.md / agent — not a two-line-contract repo')
28
+ return
29
+ }
30
+ const lines = readFileSync(claudePath, 'utf8').split('\n').filter((l) => l.trim() !== '')
31
+ const includes = lines.filter((l) => l.startsWith('@'))
32
+ const prose = lines.length - includes.length
33
+ if (prose > 0)
34
+ t.fail(`CLAUDE.md carries ${prose} non-include line(s) — it is a tiny include index, content belongs in agent/index.md`)
35
+ if (includes.length > 2)
36
+ t.fail(`CLAUDE.md has ${includes.length} include lines — the contract is 2: base context, then ${AGENT_LINE}`)
37
+ if (hasAgent && !lines.includes(AGENT_LINE))
38
+ t.fail(`agent/index.md exists but CLAUDE.md does not import it — add ${AGENT_LINE} as the LAST line`)
39
+ if (lines.includes(AGENT_LINE) && lines.at(-1) !== AGENT_LINE)
40
+ t.fail(`include order inverted — base context first, ${AGENT_LINE} LAST (order is semantic: repo docs override the base)`)
41
+ if (lines.includes(LOCAL_LINE)) {
42
+ const r = await spawnWithTimeout(['git', 'check-ignore', '-q', 'CLAUDE.local.md'], { cwd: env.root, timeoutMs: 5_000 })
43
+ if (r.code === 1)
44
+ t.fail(`${LOCAL_LINE} is imported but CLAUDE.local.md is not gitignored — the machine-local include must never be tracked`)
45
+ else if (r.code !== 0)
46
+ t.note(`CLAUDE.local.md gitignore not verifiable (git says: ${r.out.trim().split('\n')[0] ?? `exit ${r.code}`})`)
47
+ }
48
+ if (!t.failed) t.ok(`CLAUDE.md include-index ok (${lines.length} line(s))`)
49
+ },
50
+ // the one mechanical repair: the missing agent/index.md route, appended
51
+ // to an EXISTING CLAUDE.md. Never creates the file — a created one-line
52
+ // CLAUDE.md would lint green with the base include forever missing,
53
+ // trading a loud FAIL for a permanent silent gap. Creation and prose
54
+ // relocation stay human/machine-local (`shipkit start --include`).
55
+ fix: (env: RuleEnv): FixResult => {
56
+ const claudePath = join(env.root, 'CLAUDE.md')
57
+ if (!existsSync(join(env.root, 'agent/index.md'))) return { changed: false, summary: 'no agent/index.md — nothing mechanical to fix' }
58
+ if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
59
+ const cur = readFileSync(claudePath, 'utf8')
60
+ if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
61
+ writeFileSync(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
62
+ return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
63
+ },
64
+ }