rvlib-shipkit 0.11.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 (60) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  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/run-contracts.ts +8 -3
  23. package/src/core/canonical-scripts.ts +46 -0
  24. package/src/core/check-suite.ts +6 -3
  25. package/src/core/locked-write.ts +39 -0
  26. package/src/core/motherhome.ts +87 -0
  27. package/src/core/run-check.ts +28 -15
  28. package/src/core/run-ci.ts +4 -0
  29. package/src/core/write-status.ts +7 -26
  30. package/src/init-repo.ts +24 -11
  31. package/src/reflect/prompt-corpus.ts +305 -0
  32. package/src/repo-config.ts +20 -38
  33. package/src/rules/SK001-package-json-shape.ts +156 -0
  34. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  35. package/src/rules/SK003-claude-md-shape.ts +64 -0
  36. package/src/rules/SK004-gitignore-policy.ts +105 -0
  37. package/src/rules/SK005-rvlib-versions.ts +99 -0
  38. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  39. package/src/rules/SK007-github-visibility.ts +63 -0
  40. package/src/rules/rule-registry.ts +32 -0
  41. package/src/rules/rule.ts +62 -0
  42. package/src/rules/run-fix.ts +57 -0
  43. package/src/serve/hub-app/col-card.tsx +192 -0
  44. package/src/serve/hub-app/cols-svc.ts +136 -0
  45. package/src/serve/hub-app/geometry-view.tsx +155 -0
  46. package/src/serve/hub-app/health-svc.ts +49 -0
  47. package/src/serve/hub-app/hub-app.tsx +236 -215
  48. package/src/serve/hub-app/hub-st.ts +169 -0
  49. package/src/serve/hub-app/hub-types.ts +72 -0
  50. package/src/serve/hub-app/hub.css +286 -59
  51. package/src/serve/hub-app/index.html +1 -1
  52. package/src/serve/hub-app/md.ts +24 -0
  53. package/src/serve/hub-app/media-svc.ts +429 -0
  54. package/src/serve/hub-app/media-view.tsx +299 -0
  55. package/src/serve/run-serve.ts +23 -9
  56. package/src/serve/serve-hub.ts +62 -47
  57. package/src/shipkit.ts +9 -5
  58. package/src/assets/server/studio-page.ts +0 -687
  59. package/src/core/package-json-check.ts +0 -88
  60. package/src/serve/hub-pages.ts +0 -38
package/src/cli.ts CHANGED
@@ -5,13 +5,15 @@
5
5
  import { readFileSync } from 'fs'
6
6
  import { Builtins, Cli } from 'clipanion'
7
7
  import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
- import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand, AssetsServeCommand } from './cli/cmd_Assets.ts'
8
+ import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
9
9
  import { CheckCommand } from './cli/cmd_Check.ts'
10
10
  import { CiCommand } from './cli/cmd_Ci.ts'
11
11
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
12
12
  import { InitCommand } from './cli/cmd_Init.ts'
13
13
  import { MvCommand } from './cli/cmd_Mv.ts'
14
14
  import { NeedsCommand } from './cli/cmd_Needs.ts'
15
+ import { ReflectCommand } from './cli/cmd_Reflect.ts'
16
+ import { RulesCommand } from './cli/cmd_Rules.ts'
15
17
  import { ServeCommand } from './cli/cmd_Serve.ts'
16
18
  import { StartCommand } from './cli/cmd_Start.ts'
17
19
  import { TuiCommand } from './cli/cmd_Tui.ts'
@@ -40,7 +42,6 @@ cli.register(AsksCheckCommand)
40
42
  cli.register(AskNewCommand)
41
43
  cli.register(WikiServeCommand)
42
44
  cli.register(WikiLintCommand)
43
- cli.register(AssetsServeCommand)
44
45
  cli.register(AssetsFillCommand)
45
46
  cli.register(AssetsAddCommand)
46
47
  cli.register(AssetsGenCommand)
@@ -49,5 +50,7 @@ cli.register(AssetsCheckCommand)
49
50
  cli.register(InitCommand)
50
51
  cli.register(StartCommand)
51
52
  cli.register(NeedsCommand)
53
+ cli.register(ReflectCommand)
54
+ cli.register(RulesCommand)
52
55
 
53
56
  cli.runExit(process.argv.slice(2))
@@ -55,6 +55,25 @@ export function appendPostFeedback(root: string, channel: string, file: string,
55
55
  writeFileSync(p, /^## Feedback\s*$/m.test(md) ? `${md.trimEnd()}\n${line}\n` : `${md.trimEnd()}\n\n## Feedback\n${line}\n`)
56
56
  }
57
57
 
58
+ /** his asset remark → `.rv-assets/notes.md` (git-tracked; the studio
59
+ * workspace is gitignored, so notes live beside kit.ts) */
60
+ export function appendAssetNote(root: string, assetId: string, text: string): void {
61
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(assetId)) throw new Error('bad asset id')
62
+ const p = join(root, '.rv-assets', 'notes.md')
63
+ const line = `- ${today()} [${assetId}]: ${text.replace(/\n+/g, ' ').trim()}`
64
+ writeFileSync(p, existsSync(p) ? `${readFileSync(p, 'utf8').trimEnd()}\n${line}\n` : `# asset notes — his remarks, read by agents at boot\n\n${line}\n`)
65
+ }
66
+
67
+ /** his remark on an ask → appended to the ask file, dated, non-destructive.
68
+ * Accepts exactly one optional `done/` prefix (archived asks stay
69
+ * annotatable); any other slash or traversal is a bad path. */
70
+ export function appendAskRemark(root: string, asksDir: string, file: string, text: string): void {
71
+ if (!/^(done\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(file) || file.includes('..')) throw new Error('bad path')
72
+ const p = join(root, asksDir, file)
73
+ if (!existsSync(p)) throw new Error(`no such ask: ${file}`)
74
+ writeFileSync(p, `${readFileSync(p, 'utf8').trimEnd()}\n- ${today()}: ${text.replace(/\n+/g, ' ').trim()}\n`)
75
+ }
76
+
58
77
  export function askDone(root: string, asksDir: string, file: string): void {
59
78
  if (file.includes('/') || file.includes('..')) throw new Error('bad path')
60
79
  archiveAsk(join(root, asksDir), file)
@@ -23,6 +23,19 @@ export type Instance = {
23
23
  verbs?: CurationVerb[]
24
24
  /** roll-strip analog: pickable variants (post drafts, rolls) */
25
25
  candidates?: { id: string; label: string; hint?: string }[]
26
+ /** TRUST: the git file this instance IS — shown on the tile; the file
27
+ * always wins over the page */
28
+ file?: string
29
+ /** thumbnail URL for media tiles */
30
+ img?: string
31
+ /** his latest remarks from the item's own file — agents boot on these */
32
+ remarks?: string[]
33
+ /** the truth file's markdown BODY (frontmatter stripped) — clamped card
34
+ * in the grid, full in the item view; capped server-side, never a dump */
35
+ md?: string
36
+ /** an open ask owns the action (dedup = acted-on once, never hidden):
37
+ * the instance stays red HERE but verbs and pings live with the ask */
38
+ askOwned?: boolean
26
39
  }
27
40
 
28
41
  export type Collection = {
@@ -0,0 +1,34 @@
1
+ // graphs collection — the mounted md graphs as CHECKED section entries:
2
+ // each graph instance carries its lint state (orphans + dangling refs =
3
+ // yellow with counts; clean = quiet). Custom wiki-graph providers (repos
4
+ // with their own loader + their own lint) get a link-only instance — their
5
+ // repo lint owns correctness, never double-judged here.
6
+ import { existsSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { lintMd } from '../wiki/lint-md.ts'
9
+ import type { Collection, Instance } from './collection-types.ts'
10
+
11
+ export function graphsCollection(root: string, o: { customWiki: boolean; agentIndex?: string }): Collection {
12
+ return {
13
+ id: 'graphs',
14
+ title: 'graphs',
15
+ media: 'data',
16
+ list: async () => {
17
+ const out: Instance[] = []
18
+ const checked = (id: string, title: string, href: string, dir: string, index?: string): Instance => {
19
+ const r = lintMd({ root: dir, index })
20
+ const issues = r.orphans.length + r.dangling.length
21
+ return {
22
+ id,
23
+ text: issues === 0 ? title : `${title} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
24
+ severity: issues === 0 ? undefined : 'yellow',
25
+ href,
26
+ }
27
+ }
28
+ if (o.customWiki) out.push({ id: 'graph:wiki', text: 'wiki graph (repo-owned lint)', href: '/graph/wiki' })
29
+ else out.push(checked('graph:wiki', 'wiki graph', '/graph/wiki', root))
30
+ if (existsSync(join(root, 'agent'))) out.push(checked('graph:agent', 'agent graph', '/graph/agent', join(root, 'agent'), o.agentIndex))
31
+ return out
32
+ },
33
+ }
34
+ }
@@ -17,6 +17,9 @@ export type PingIo = {
17
17
  notifyCommand?: string
18
18
  /** additive desktop notification on macOS */
19
19
  osascript?: boolean
20
+ /** hub URL appended to the message — the OFF-MACHINE form
21
+ * (serve.publicHost); a phone can't resolve localhost */
22
+ link?: string
20
23
  }
21
24
 
22
25
  const stateDir = (root: string): string | null => (existsSync(join(root, 'node_modules')) ? join(root, 'node_modules', '.cache', 'shipkit') : null)
@@ -46,6 +49,7 @@ export function pingTick(root: string, asksDir: string, needs: NeedItem[], io: P
46
49
  const fresh: NeedItem[] = []
47
50
  for (const n of needs) {
48
51
  if (n.severity !== 'red') continue
52
+ if (n.askOwned === true) continue // the covering ask's pinged: field already fired the once-ever ping
49
53
  if (n.collection === 'asks') {
50
54
  // ask file owns the once-ever marker (shared with agent pings)
51
55
  const file = n.id.replace(/^ask:/, '')
@@ -58,7 +62,7 @@ export function pingTick(root: string, asksDir: string, needs: NeedItem[], io: P
58
62
  savePinged(root, pinged)
59
63
  if (fresh.length === 0) return []
60
64
  const head = fresh.slice(0, 3).map((n) => n.text.slice(0, 80)).join(' · ')
61
- const message = `${fresh.length} new item(s) need you: ${head}${fresh.length > 3 ? ' · …' : ''}`
65
+ const message = `${fresh.length} new item(s) need you: ${head}${fresh.length > 3 ? ' · …' : ''}${io.link != null ? ` → ${io.link}` : ''}`
62
66
  // item texts come from repo data — strip shell-active chars before the template substitution
63
67
  const safe = message.replace(/[`$\\'"]/g, '')
64
68
  if (io.notifyCommand != null) io.run(['sh', '-c', io.notifyCommand.replaceAll('{message}', safe)])
@@ -66,13 +66,19 @@ export function socialPostsCollection(root: string): Collection {
66
66
  bySlug.set(key, [...(bySlug.get(key) ?? []), p])
67
67
  }
68
68
  const out: Instance[] = []
69
+ const feedback = (v: PostFile): string[] | undefined => {
70
+ const lines = v.body.split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2}: /.test(l))
71
+ return lines.length > 0 ? lines.slice(-3) : undefined
72
+ }
69
73
  for (const [key, variants] of bySlug) {
74
+ const fileOf = (v: PostFile): string => `.rv-social/${v.channel}/${v.file}`
70
75
  const missing = variants.filter((v) => v.status == null)
71
76
  if (missing.length > 0) {
72
77
  out.push({
73
78
  id: `post:${key}:invalid`,
74
79
  text: `${key} — missing/invalid status: frontmatter (${missing.map((v) => v.file).join(', ')})`,
75
80
  severity: 'red',
81
+ file: fileOf(missing[0]!),
76
82
  })
77
83
  continue
78
84
  }
@@ -85,6 +91,8 @@ export function socialPostsCollection(root: string): Collection {
85
91
  artifact: { copy: postSection(picked.body) },
86
92
  verbs: ['paste-proof', 'rate'],
87
93
  candidates: [{ id: picked.file, label: picked.variant ?? 'post' }],
94
+ file: fileOf(picked),
95
+ remarks: feedback(picked),
88
96
  })
89
97
  continue
90
98
  }
@@ -96,9 +104,20 @@ export function socialPostsCollection(root: string): Collection {
96
104
  severity: 'yellow',
97
105
  verbs: ['pick', 'rate'],
98
106
  candidates: drafts.map((v) => ({ id: v.file, label: v.variant ?? 'draft', hint: postSection(v.body).slice(0, 200) })),
107
+ file: fileOf(drafts[0]!),
108
+ remarks: feedback(drafts[0]!),
99
109
  })
110
+ continue
100
111
  }
101
- // posted / rejected-only: quiet — history is training data, not attention
112
+ // posted / rejected history: VISIBLE (browse-all), never in needs
113
+ const posted = variants.find((v) => v.status === 'posted')
114
+ const shown = posted ?? variants[0]!
115
+ out.push({
116
+ id: `post:${key}:${posted != null ? 'posted' : 'rejected'}`,
117
+ text: `${key} — ${posted != null ? `posted${posted.posted != null ? ` (${posted.posted})` : ''}` : 'rejected'}`,
118
+ file: fileOf(shown),
119
+ remarks: feedback(shown),
120
+ })
102
121
  }
103
122
  return out
104
123
  },
@@ -1,19 +1,24 @@
1
1
  // The built-in collection providers — each one REPLACED an attentionCollector
2
2
  // guard (the collector is gone; never reintroduce a parallel projection).
3
3
  // Behaviors preserved by name: per-source failure isolation (projectNeeds
4
- // guards per collection), cred-FAIL↔ask dedup, the identity self-heal
5
- // before surfacing, and the asks READ path never minting asks/ on a repo
6
- // without one. Dedup chain for one real-world action:
7
- // ask > cred-instance > health-line.
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.
8
11
  import { existsSync, readFileSync } from 'fs'
9
12
  import { join } from 'path'
10
13
  import { isStale } from '../assets/gen/derived.ts'
11
- import { allAssets, type ResolvedKit } from '../assets/kit-config.ts'
14
+ import { allAssets, mediaOf, type ResolvedKit } from '../assets/kit-config.ts'
12
15
  import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
13
16
  import { healIdentity } from '../assets/run-assets.ts'
14
- import { openAsks } from '../asks/asks-queue.ts'
17
+ import { doneAsks, openAsks } from '../asks/asks-queue.ts'
15
18
  import { evaluateContracts } from '../contracts/contracts-engine.ts'
16
- import { activeContracts, contractsConfigFrom, type RepoConfig } from '../repo-config.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'
17
22
  import type { Collection, Instance } from './collection-types.ts'
18
23
  import { socialPostsCollection } from './posts.ts'
19
24
 
@@ -26,50 +31,126 @@ const openAsksSafe = (root: string, c: RepoConfig): ReturnType<typeof openAsks>
26
31
 
27
32
  const CRED_LINE = /cred ([a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+) missing/
28
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
+
29
52
  export function asksCollection(root: string, c: RepoConfig): Collection {
30
53
  return {
31
54
  id: 'asks',
32
- title: 'asks — waiting on you',
55
+ title: 'asks',
33
56
  media: 'text',
34
- list: async () =>
35
- openAsksSafe(root, c).map((a) => ({
57
+ list: async () => {
58
+ const rel = c.asks?.dir ?? 'asks'
59
+ const open = openAsksSafe(root, c).map((a): Instance => ({
36
60
  id: `ask:${a.file}`,
37
61
  text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}`,
38
62
  severity: 'red' as const,
39
63
  verbs: ['done' as const, 'paste-proof' as const],
40
- })),
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
+ },
41
79
  }
42
80
  }
43
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
+
44
102
  /** repo-declared secrets (repo.config `creds:`) + cred FAIL lines from
45
- * STATUS.md — the tile carries the mint artifact as data. Skips any secret
46
- * covered by an open ask (the ask wins: his hands, one surface). */
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. */
47
109
  export function credsCollection(root: string, c: RepoConfig): Collection {
48
110
  return {
49
111
  id: 'creds',
50
- title: 'missing secrets',
112
+ title: 'secrets',
51
113
  media: 'pointer',
52
114
  list: async () => {
53
- const askText = openAsksSafe(root, c).map((a) => a.title.toLowerCase()).join(' ')
54
- const covered = (name: string): boolean => askText.includes(name.split('/')[1] ?? name)
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
+ }
55
126
  const out: Instance[] = []
56
- const seen = new Set<string>()
57
- const statusPath = join(root, 'STATUS.md')
58
- const statusCreds = existsSync(statusPath)
59
- ? readFileSync(statusPath, 'utf8')
60
- .split('\n')
61
- .filter((l) => l.trimStart().startsWith('FAIL')) // only a FAIL probe proves missing — a WARN/narrative mention never reds
62
- .map((l) => l.match(CRED_LINE)?.[1])
63
- .filter((n): n is string => n != null)
64
- : []
65
- for (const name of statusCreds) {
66
- if (seen.has(name) || covered(name)) continue
67
- seen.add(name)
127
+ for (const [name, probe] of probes) {
68
128
  const decl = (c.creds ?? []).find((d) => d.name === name)
69
- out.push(credInstance(name, c.credsCommand, decl))
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
+ })
70
153
  }
71
- // declared-but-not-probed creds stay quiet: only a STATUS.md
72
- // probe line proves "missing" — declaration alone never reds
73
154
  return out
74
155
  },
75
156
  }
@@ -85,9 +166,11 @@ const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { se
85
166
  verbs: ['set'],
86
167
  })
87
168
 
88
- /** media kit instances — identity self-heals BEFORE surfacing; manual with
89
- * nothing uploaded = red (provide); rolls with no pick = yellow (a default
90
- * exists, taste pending — auto-pick-first is blessed); stale = yellow. */
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. */
91
174
  export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal: typeof healIdentity = healIdentity): Collection {
92
175
  return {
93
176
  id: 'media',
@@ -97,29 +180,57 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
97
180
  const idn = await heal(root)
98
181
  const out: Instance[] = []
99
182
  if (idn.state === 'blocked')
100
- out.push({ id: 'media:identity', text: `identity — ${idn.detail ?? 'blocked'}`, severity: 'red', href: '/assets/#identity-logo', verbs: ['pick'] })
183
+ out.push({ id: 'media:identity', text: `identity — ${idn.detail ?? 'blocked'}`, severity: 'red', href: '/#identity-logo', verbs: ['pick'] })
101
184
  if (kit == null) return out
185
+ const notes = assetNotes(root)
102
186
  for (const f of allAssets(kit)) {
103
187
  const dir = assetDir(join(root, kit.workspace), f.asset.id)
104
188
  const rolls = listRolls(dir)
105
189
  const manual = f.kind.generate.backend === 'manual'
106
- const href = `/assets/#${f.asset.id}`
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
+ }
107
200
  if (manual && rolls.length === 0)
108
201
  // the brief IS the paste-ready artifact ("I don't even
109
202
  // know what to paste" — the tile hands him the prompt)
110
- out.push({ id: `media:${f.asset.id}`, text: `${f.asset.id} — manual, nothing uploaded`, severity: 'red', href, verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined })
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 })
111
204
  else if (rolls.length > 0 && (await readPick(dir)).pick == null)
112
- out.push({ id: `media:${f.asset.id}`, text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, severity: 'yellow', href, verbs: ['pick'] })
205
+ out.push({ ...base, text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, severity: 'yellow', verbs: ['pick'] })
113
206
  else if (await isStale(kit, join(root, kit.workspace), f))
114
- out.push({ id: `media:${f.asset.id}`, text: `${f.asset.id} — stale (a parent was re-picked)`, severity: 'yellow', href, verbs: ['pick'] })
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
115
209
  }
116
210
  return out
117
211
  },
118
212
  }
119
213
  }
120
214
 
121
- /** repo health — NON-curatable feed items: failing checks (minus cred lines
122
- * the creds provider owns), contract FAIL/WARN, hub process staleness. */
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. */
123
234
  export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVersion?: string; libVersion?: () => string }): Collection {
124
235
  return {
125
236
  id: 'health',
@@ -132,15 +243,17 @@ export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVers
132
243
  if (now !== o.bootVersion)
133
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` })
134
245
  }
135
- const statusPath = join(root, 'STATUS.md')
136
- if (existsSync(statusPath))
137
- for (const l of readFileSync(statusPath, 'utf8').split('\n'))
138
- if (l.trimStart().startsWith('FAIL') && !CRED_LINE.test(l)) {
139
- // the feed line carries severity already — a raw FAIL: marker would stack three signals
140
- const text = l.trim().replace(/^(FAIL|WARN):\s*/, '')
141
- // id = full text (never sliced: a shared 60-char prefix must not collide two items — pings dedup on ids)
142
- out.push({ id: `health:${text}`, severity: 'red', text, href: '/status' })
143
- }
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
+ }
144
257
  const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
145
258
  for (const l of r.lines) {
146
259
  if (l.kind === 'fail') out.push({ id: `health:contract:${l.text}`, severity: 'red', text: l.text })
@@ -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 }