rvlib-shipkit 0.6.0 → 0.8.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.
@@ -5,7 +5,7 @@ import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/
5
5
  export default defineContract({
6
6
  venue: 'discord',
7
7
  dir: '.rv-discord',
8
- owner: 'distribute/social.md',
8
+ owner: 'distribute/post-publicly.md',
9
9
  appliesTo: 'declared',
10
10
  detect: [dirExists('.rv-discord')],
11
11
  artifacts: [
@@ -6,7 +6,7 @@ import { defineContract, file, glob, png } from 'rvlib-shipkit/src/contracts/con
6
6
  export default defineContract({
7
7
  venue: 'identity',
8
8
  dir: '.rv-identity',
9
- owner: 'build/assets.md',
9
+ owner: 'build/generate-assets.md',
10
10
  appliesTo: 'always',
11
11
  gate: 'public-surface',
12
12
  detect: [],
@@ -5,7 +5,7 @@ import { custom, defineContract, dirExists } from 'rvlib-shipkit/src/contracts/c
5
5
  export default defineContract({
6
6
  venue: 'social',
7
7
  dir: '.rv-social',
8
- owner: 'distribute/social.md',
8
+ owner: 'distribute/post-publicly.md',
9
9
  appliesTo: 'declared',
10
10
  detect: [dirExists('.rv-social')],
11
11
  artifacts: [
@@ -68,6 +68,7 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
68
68
  { name: 'contracts', why: 'shipkit repo' },
69
69
  { name: 'tui', why: 'shipkit repo' },
70
70
  { name: 'hub', why: 'shipkit repo' },
71
+ { name: 'mv', why: 'shipkit repo' },
71
72
  ]
72
73
  if (existsSync(join(root, '.rv-assets/kit.ts'))) required.push({ name: 'assets', why: '.rv-assets/kit.ts exists' })
73
74
  if (existsSync(join(root, '.rv-identity/brand.ts'))) required.push({ name: 'assets:identity', why: '.rv-identity/brand.ts exists' })
package/src/init-repo.ts CHANGED
@@ -42,7 +42,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
42
42
  let added = 0
43
43
  // canonical script NAME → shipkit command (hub runs `shipkit serve`) —
44
44
  // vocabulary owned by meta/rv-conventions/package-json.md
45
- const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve' } as const
45
+ const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve', mv: 'mv' } as const
46
46
  for (const [k, cmd] of Object.entries(CANONICAL)) {
47
47
  const v = run(cmd)
48
48
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
@@ -0,0 +1,90 @@
1
+ // shipkit mv — rename an md page the AGILE way: one command moves the .md,
2
+ // its sibling asset dir and <name>.check.ts, then sweeps EVERY tracked text
3
+ // file for refs (full path, docs-root-relative id, check filename). Dry by
4
+ // default; --write applies; --commit adds one conventional commit (the
5
+ // repo's pre-commit hook is the gate). If package.json has a `mv:after`
6
+ // script it runs after the moves (repo-specific re-stamps). TS module moves
7
+ // are a different concern (a TS-aware renamer owns import graphs).
8
+ import { execSync } from 'child_process'
9
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
10
+ import { basename, dirname, join } from 'path'
11
+
12
+ export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
13
+ export type MvOpts = { write?: boolean; commit?: boolean }
14
+
15
+ const sh = (cmd: string, cwd: string): string => execSync(cmd, { cwd }).toString()
16
+
17
+ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo, o: MvOpts = {}): number {
18
+ const die = (m: string): number => { io.printErr(`FAIL: ${m}`); return 1 }
19
+ if (!oldRel.endsWith('.md') || !newRel.endsWith('.md')) return die('both paths must be .md (TS moves are not this tool\'s job)')
20
+ if (!existsSync(join(root, oldRel))) return die(`${oldRel} does not exist`)
21
+ if (existsSync(join(root, newRel))) return die(`${newRel} already exists`)
22
+ if (dirname(oldRel) !== dirname(newRel)) return die('cross-directory moves not supported yet, same dir only')
23
+
24
+ const write = o.write === true || o.commit === true
25
+ const oldName = basename(oldRel, '.md')
26
+ const newName = basename(newRel, '.md')
27
+ const oldBase = oldRel.slice(0, -3) // full path sans .md — the sibling dir
28
+ const newBase = newRel.slice(0, -3)
29
+ // docs-root-relative id: refs inside a wiki usually omit the top dir
30
+ // (`build/state`, not `wiki/build/state`) — sweep both forms
31
+ const tail = (p: string): string | null => { const seg = p.split('/'); return seg.length >= 3 ? seg.slice(1).join('/') : null }
32
+ const oldId = tail(oldBase)
33
+ const newId = tail(newBase)
34
+
35
+ // longest-first so `wiki/x/state.md` beats `x/state` beats bare tokens
36
+ const pairs: [string, string][] = [
37
+ [`${oldBase}.md`, `${newBase}.md`],
38
+ ...(oldId != null ? [[`${oldId}.md`, `${newId}.md`] as [string, string]] : []),
39
+ // probe file is named after its page; DIR-qualified so another
40
+ // page's same-named check.ts in a different dir never gets rewritten
41
+ [`${oldBase}/${oldName}.check.ts`, `${newBase}/${newName}.check.ts`],
42
+ ...(oldId != null ? [[`${oldId}/${oldName}.check.ts`, `${newId}/${newName}.check.ts`] as [string, string]] : []),
43
+ [`${oldBase}/`, `${newBase}/`],
44
+ ...(oldId != null ? [[`${oldId}/`, `${newId}/`] as [string, string]] : []),
45
+ [oldBase, newBase],
46
+ ...(oldId != null ? [[oldId, newId!] as [string, string]] : []),
47
+ ]
48
+ const bound = (t: string): RegExp =>
49
+ new RegExp('(?<![\\w-])' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + (/[/.]$/.test(t) || t.endsWith('.ts') ? '' : '(?![\\w-])'), 'g')
50
+
51
+ const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
52
+ .filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
53
+ let touched = 0
54
+ for (const f of tracked) {
55
+ const p = join(root, f)
56
+ if (!existsSync(p)) continue
57
+ const before = readFileSync(p, 'utf8')
58
+ let after = before
59
+ for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
60
+ if (after !== before) {
61
+ io.print(` ${f}`)
62
+ if (write) writeFileSync(p, after)
63
+ touched++
64
+ }
65
+ }
66
+ io.print(`${write ? 'renamed' : 'DRY RUN'}: ${oldBase} → ${newBase}, refs in ${touched} file(s)`)
67
+ if (!write) { io.print('re-run with --write (or --commit) to apply'); return 0 }
68
+
69
+ // moves AFTER the sweep (the sweep rewrote files at their old paths)
70
+ sh(`git mv ${JSON.stringify(oldRel)} ${JSON.stringify(newRel)}`, root)
71
+ if (existsSync(join(root, oldBase))) {
72
+ sh(`git mv ${JSON.stringify(oldBase)} ${JSON.stringify(newBase)}`, root)
73
+ const oldCheck = join(newBase, `${oldName}.check.ts`)
74
+ if (existsSync(join(root, oldCheck))) sh(`git mv ${JSON.stringify(oldCheck)} ${JSON.stringify(join(newBase, `${newName}.check.ts`))}`, root)
75
+ }
76
+
77
+ // repo-specific re-stamps (e.g. wiki depth frontmatter) — loud on failure
78
+ const pkg = existsSync(join(root, 'package.json')) ? (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }) : {}
79
+ if (pkg.scripts?.['mv:after'] != null) {
80
+ try { execSync('bun run mv:after', { cwd: root, stdio: 'inherit' }) } catch { return die('mv:after failed, fix before committing') }
81
+ }
82
+ if (o.commit === true) {
83
+ sh('git add -A', root)
84
+ try {
85
+ execSync(`git commit -m ${JSON.stringify(`docs: rename ${oldBase} → ${newBase}`)}`, { cwd: root, stdio: 'inherit' })
86
+ } catch { return die('commit failed (pre-commit gate?). The rename is applied, fix and commit yourself') }
87
+ }
88
+ io.print(`ok: ${oldBase} → ${newBase}${o.commit === true ? ' (committed)' : ''}`)
89
+ return 0
90
+ }
@@ -0,0 +1,156 @@
1
+ // The hub's React app (Bun.serve HTML import — no vite, no build step).
2
+ // Two views routed by pathname: / = attention dashboard (everything red),
3
+ // /status = STATUS.md + streamed check runs. The rail lists every mounted
4
+ // tool (from /hub.json) — legacy pages (graphs, studio) get the same rail
5
+ // injected server-side until they migrate.
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import { observer } from 'mobx-react-lite'
8
+ import { StrictMode } from 'react'
9
+ import { createRoot } from 'react-dom/client'
10
+
11
+ type HubTool = { path: string; label: string; desc: string }
12
+ type AttentionLine = { text: string; href?: string }
13
+ type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
14
+
15
+ const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
16
+
17
+ class HubStore {
18
+ name = ''
19
+ tools: HubTool[] = []
20
+ sections: AttentionSection[] | null = null
21
+ statusMd: string | null = null
22
+ running = false
23
+ note = ''
24
+ error: string | null = null
25
+
26
+ constructor() {
27
+ makeAutoObservable(this)
28
+ }
29
+
30
+ get redCount(): number {
31
+ return (this.sections ?? []).filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
32
+ }
33
+
34
+ async load(): Promise<void> {
35
+ try {
36
+ const hub = (await (await fetch('/hub.json')).json()) as { name: string; tools: HubTool[] }
37
+ const att = (await (await fetch('/api/attention.json')).json()) as { sections: AttentionSection[] }
38
+ runInAction(() => {
39
+ this.name = hub.name
40
+ this.tools = hub.tools
41
+ this.sections = att.sections
42
+ })
43
+ if (location.pathname === '/status') {
44
+ const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
45
+ runInAction(() => { this.statusMd = s.statusMd })
46
+ }
47
+ } catch (e) {
48
+ runInAction(() => { this.error = (e as Error).message })
49
+ }
50
+ }
51
+
52
+ async runCheck(): Promise<void> {
53
+ this.running = true
54
+ this.note = 'running…'
55
+ let text = ''
56
+ try {
57
+ const r = await fetch('/status/run', { method: 'POST' })
58
+ const rd = r.body!.getReader()
59
+ const dec = new TextDecoder()
60
+ for (;;) {
61
+ const { done, value } = await rd.read()
62
+ if (done) break
63
+ text += dec.decode(value, { stream: true })
64
+ runInAction(() => { this.statusMd = text })
65
+ }
66
+ runInAction(() => { this.note = 'done — STATUS.md refreshed on disk' })
67
+ void this.load() // red counts moved — refresh the dashboard data
68
+ } catch (e) {
69
+ runInAction(() => {
70
+ this.statusMd = `${text}\nFAIL stream broke: ${(e as Error).message}`
71
+ this.note = 'stream broke'
72
+ })
73
+ }
74
+ runInAction(() => { this.running = false })
75
+ }
76
+ }
77
+
78
+ const store = new HubStore()
79
+ void store.load()
80
+
81
+ const Rail = observer(() => {
82
+ const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...store.tools.filter((t) => t.path !== '/')]
83
+ return (
84
+ <nav className="skrail">
85
+ {items.map((t) => {
86
+ const on = location.pathname === t.path || (t.path !== '/' && location.pathname.startsWith(t.path))
87
+ return (
88
+ <a key={t.path} className={on ? 'on' : ''} href={t.path} title={`${t.label} — ${t.desc}`}>
89
+ {RAIL_ICONS[t.path] ?? '·'}
90
+ {t.path === '/' && store.redCount > 0 ? <i /> : null}
91
+ </a>
92
+ )
93
+ })}
94
+ </nav>
95
+ )
96
+ })
97
+
98
+ const Attention = observer(() => {
99
+ const live = (store.sections ?? []).filter((s) => s.lines.length > 0)
100
+ return (
101
+ <div className="wrap">
102
+ <div className="crumb">shipkit hub · attention</div>
103
+ <h1>{store.name}<small>everything that needs you, one page</small></h1>
104
+ {store.sections == null ? <div className="sub">loading…</div>
105
+ : live.length === 0 ? <div className="clear">✅ all clear — nothing needs you</div>
106
+ : live.map((s) => (
107
+ <section key={s.title} className={s.tone}>
108
+ <h2>{s.href != null ? <a href={s.href}>{s.title}</a> : s.title}<em>{s.lines.length}</em></h2>
109
+ {s.lines.map((l, i) => (
110
+ <div key={i} className="line">{l.href != null ? <a href={l.href}>{l.text}</a> : l.text}</div>
111
+ ))}
112
+ </section>
113
+ ))}
114
+ <div className="sub">refreshes on reload · checks refresh via <a href="/status">status → run check</a></div>
115
+ </div>
116
+ )
117
+ })
118
+
119
+ const wireClass = (l: string): string => {
120
+ const t = l.trimStart()
121
+ if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
122
+ if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
123
+ if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
124
+ if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
125
+ return ''
126
+ }
127
+
128
+ const Status = observer(() => (
129
+ <div className="wrap">
130
+ <div className="crumb">shipkit hub · status</div>
131
+ <h1>{store.name}<small>STATUS.md</small></h1>
132
+ <div className="bar">
133
+ <button disabled={store.running} onClick={() => void store.runCheck()}>run check</button>
134
+ <span className="note">{store.note !== '' ? store.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
135
+ </div>
136
+ <pre>
137
+ {(store.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
138
+ .split('\n')
139
+ .map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
140
+ </pre>
141
+ </div>
142
+ ))
143
+
144
+ const App = observer(() => (
145
+ <div className="app">
146
+ <Rail />
147
+ {store.error != null ? <div className="wrap err">hub api unreachable: {store.error}</div>
148
+ : location.pathname === '/status' ? <Status /> : <Attention />}
149
+ </div>
150
+ ))
151
+
152
+ createRoot(document.getElementById('root')!).render(
153
+ <StrictMode>
154
+ <App />
155
+ </StrictMode>,
156
+ )
@@ -0,0 +1,41 @@
1
+ /* house palette — matches the studio/graph family. Plain CSS on purpose:
2
+ a published lib must not impose bunfig plugins (tailwind) on consumers. */
3
+ :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
4
+ --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
5
+ *{box-sizing:border-box}
6
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
7
+ .app{display:flex;min-height:100vh}
8
+ .wrap{max-width:900px;margin:0 auto;padding:34px 26px;flex:1}
9
+ h1{font-size:20px;margin:0}
10
+ h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
11
+ .crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
12
+
13
+ .skrail{position:sticky;top:0;align-self:flex-start;height:100vh;width:46px;display:flex;flex-direction:column;
14
+ align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid var(--line);flex:none}
15
+ .skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
16
+ font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
17
+ .skrail a:hover{background:var(--panel2);filter:none;opacity:1}
18
+ .skrail a.on{background:var(--panel2);border-left-color:var(--accent);filter:none;opacity:1}
19
+ .skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:var(--red)}
20
+
21
+ .clear{margin-top:30px;font-size:17px;color:var(--teal)}
22
+ section{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:12px;
23
+ padding:14px 18px;margin-top:16px}
24
+ section.red{border-left-color:var(--red)}
25
+ h2{font-size:14px;margin:0 0 8px;display:flex;align-items:center;gap:8px}
26
+ h2 a{color:var(--ink);text-decoration:none}h2 a:hover{color:var(--gold)}
27
+ h2 em{font-style:normal;font-size:12px;color:var(--dim);background:var(--panel2);border-radius:8px;padding:1px 8px}
28
+ .line{font:12.5px/1.7 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap}
29
+ .line a{color:var(--dim);text-decoration:underline dotted}.line a:hover{color:var(--gold)}
30
+ section.red .line{color:#e8a0a0}
31
+ .sub{margin-top:10px;font-size:12px;color:var(--dim)}
32
+ .sub a{color:var(--dim)}
33
+
34
+ .bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
35
+ button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
36
+ button:disabled{opacity:.45;cursor:default}
37
+ .note{font-size:12px;color:var(--dim)}
38
+ pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
39
+ font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
40
+ .ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
41
+ .err{margin-top:26px;color:var(--red)}
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>shipkit hub</title>
7
+ <link rel="stylesheet" href="./hub.css" />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./hub-app.tsx"></script>
12
+ </body>
13
+ </html>
@@ -1,84 +1,45 @@
1
- // The hub's two no-build pages (served by serve-hub.ts): the landing page
2
- // whose links ARE the toolbox, and the /status page (STATUS.md + a
3
- // "run check" button streaming wire lines). Same style family as the
4
- // studio: one template string, vanilla JS, no build step, no CDN.
1
+ // Shared hub-page plumbing. The hub UI itself is the Bun-native React app
2
+ // in hub-app/ (HTML import, served by serve-hub.ts — build/webapp
3
+ // doctrine). This module keeps what the LEGACY pages (graph pages, studio
4
+ // SPA) still need until they migrate: the server-injected rail, and the
5
+ // attention section types the collector + React app share.
5
6
  import type { HubTool } from './serve-hub.ts'
6
7
 
7
- const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
8
+ const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;')
8
9
 
9
- const SHELL = `
10
- :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
11
- --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
12
- *{box-sizing:border-box}
13
- body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
14
- .wrap{max-width:860px;margin:0 auto;padding:40px 24px}
15
- h1{font-size:20px;margin:0}
16
- h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
17
- .crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
18
- .crumb a{color:var(--accent);text-decoration:none}.crumb a:hover{color:var(--gold)}
19
- `
10
+ // ── the rail: thin fixed tab bar, injected into legacy hub-served pages ───
11
+ const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
20
12
 
21
- export function landingPage(o: { name: string; port: number; tools: HubTool[] }): string {
22
- const cards = o.tools
23
- .map((t) => `<a class=card href="${esc(t.path)}"><b>${esc(t.label)}</b><span>${esc(t.desc)}</span><code>${esc(t.path)}</code></a>`)
24
- .join('\n')
25
- return `<!doctype html><html><head><meta charset=utf-8>
26
- <meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · shipkit</title>
27
- <style>${SHELL}
28
- .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:26px}
29
- .card{display:flex;flex-direction:column;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
30
- padding:16px;text-decoration:none;color:var(--ink)}
31
- .card:hover{border-color:var(--accent);background:var(--panel2)}
32
- .card b{font-size:15px}.card span{font-size:13px;color:var(--dim)}
33
- .card code{font:11px ui-monospace,Menlo,monospace;color:var(--teal)}
34
- </style></head><body><div class=wrap>
35
- <div class=crumb>shipkit hub</div>
36
- <h1>${esc(o.name)}<small>http://localhost:${o.port}</small></h1>
37
- <div class=cards>
38
- ${cards}
39
- </div>
40
- </div></body></html>`
41
- }
13
+ const RAIL_CSS = `
14
+ .skrail{position:fixed;left:0;top:0;bottom:0;width:46px;z-index:9999;display:flex;flex-direction:column;
15
+ align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid #332b22}
16
+ .skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
17
+ font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
18
+ .skrail a:hover{background:#262019;filter:none;opacity:1}
19
+ .skrail a.on{background:#262019;border-left-color:#e88c28;filter:none;opacity:1}
20
+ .skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05252}
21
+ body{padding-left:46px}`
42
22
 
43
- export function statusPage(o: { name: string; statusMd: string | null }): string {
44
- const body = o.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)'
45
- return `<!doctype html><html><head><meta charset=utf-8>
46
- <meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · status</title>
47
- <style>${SHELL}
48
- .bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
49
- button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
50
- button:disabled{opacity:.45;cursor:default}
51
- #note{font-size:12px;color:var(--dim)}
52
- pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
53
- font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
54
- .ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
55
- </style></head><body><div class=wrap>
56
- <div class=crumb><a href="/">shipkit hub</a> / status</div>
57
- <h1>${esc(o.name)}<small>STATUS.md</small></h1>
58
- <div class=bar><button id=run>run check</button><span id=note>shells <code>shipkit check</code>, wire lines stream in below</span></div>
59
- <pre id=out></pre>
60
- <script>
61
- const out=document.getElementById('out'),btn=document.getElementById('run'),note=document.getElementById('note')
62
- // wire-line coloring: ok/WARN/FAIL/skip prefixes → the house palette
63
- const esc=s=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;')
64
- const cls=l=>{const t=l.trimStart()
65
- if(t.startsWith('FAIL')||t.startsWith('✗'))return 'fail'
66
- if(t.startsWith('WARN')||t.startsWith('⚠'))return 'warn'
67
- if(t.startsWith('ok')||t.startsWith('✓'))return 'ok'
68
- if(t.startsWith('skip')||t.startsWith('fix:')||t.startsWith('#'))return 'dim'
69
- return ''}
70
- const paint=text=>{out.innerHTML=text.split('\\n').map(l=>'<span class="'+cls(l)+'">'+esc(l)+'</span>').join('\\n')}
71
- paint(${JSON.stringify(body).replaceAll('<', '\\u003c')})
72
- btn.onclick=async()=>{
73
- btn.disabled=true;note.textContent='running…';let text=''
74
- try{
75
- const r=await fetch('status/run',{method:'POST'})
76
- const rd=r.body.getReader(),dec=new TextDecoder()
77
- for(;;){const{done,value}=await rd.read();if(done)break;text+=dec.decode(value,{stream:true});paint(text)}
78
- note.textContent='done — STATUS.md refreshed on disk'
79
- }catch(e){text+='\\nFAIL stream broke: '+e;paint(text);note.textContent='stream broke'}
80
- btn.disabled=false
81
- }
82
- </script>
83
- </div></body></html>`
23
+ /** Inject the rail into a legacy hub-served HTML document (post-<body>,
24
+ * fixed position — the page needs no layout knowledge). The React app
25
+ * renders its own identical rail from /hub.json. */
26
+ export function withRail(html: string, o: { tools: HubTool[]; active: string }): string {
27
+ const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...o.tools.filter((t) => t.path !== '/')]
28
+ .map((t) => {
29
+ const on = o.active === t.path || (t.path !== '/' && o.active.startsWith(t.path))
30
+ const dot = t.path === '/' ? '<i id=skdot style="display:none"></i>' : ''
31
+ return `<a class="${on ? 'on' : ''}" href="${esc(t.path)}" title="${esc(t.label)} — ${esc(t.desc)}">${RAIL_ICONS[t.path] ?? '·'}${dot}</a>`
32
+ })
33
+ .join('')
34
+ // the red dot fetches client-side: a passive page nav must never run the
35
+ // attention collector server-side (cost + heal side-effects)
36
+ const dotScript = `<script>fetch('/api/attention.json').then(r=>r.json()).then(j=>{const n=(j.sections||[]).filter(s=>s.tone==='red').reduce((a,s)=>a+s.lines.length,0);if(n>0)document.getElementById('skdot').style.display=''}).catch(()=>{})</script>`
37
+ return html.replace(/<body([^>]*)>/, `<body$1><style>${RAIL_CSS}</style><nav class=skrail>${items}</nav>${dotScript}`)
84
38
  }
39
+
40
+ // ── attention data shapes (collector in run-serve, rendering in hub-app) ──
41
+ export type AttentionLine = { text: string; href?: string }
42
+ export type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
43
+
44
+ export const attentionRedCount = (sections: AttentionSection[]): number =>
45
+ sections.filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
@@ -5,14 +5,20 @@
5
5
  // kit.ts loads. Port: --port > serve.port > a deterministic house-range
6
6
  // default (4800-4979, hashed from the repo name — stable across launches;
7
7
  // pin serve.port when two repos collide).
8
- import { existsSync } from 'fs'
8
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
11
- import { loadKit } from '../assets/kit-config.ts'
11
+ import { loadKit, allAssets, type ResolvedKit } from '../assets/kit-config.ts'
12
+ import { isStale } from '../assets/gen/derived.ts'
13
+ import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
14
+ import { healIdentity } from '../assets/run-assets.ts'
12
15
  import { page as studioPage } from '../assets/server/studio-page.ts'
13
16
  import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
14
- import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
17
+ import { openAsks } from '../asks/asks-queue.ts'
18
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
19
+ import { activeContracts, contractsConfigFrom, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
15
20
  import { scanMdGraph } from '../wiki/scan-md.ts'
21
+ import type { AttentionSection } from './hub-pages.ts'
16
22
  import { serveHub, type HubDeps } from './serve-hub.ts'
17
23
 
18
24
  export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
@@ -32,12 +38,15 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
32
38
  ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
33
39
  : null
34
40
  let studio: StudioRoutes | null = null
41
+ let kit: ResolvedKit | null = null
35
42
  if (existsSync(join(root, '.rv-assets/kit.ts'))) {
36
43
  // a broken kit must not take the graphs and status down with it —
37
44
  // WARN + mount everything else
38
45
  try {
39
- studio = await studioRoutes({ kit: await loadKit(root), root, page: studioPage })
46
+ kit = await loadKit(root)
47
+ studio = await studioRoutes({ kit, root, page: studioPage })
40
48
  } catch (e) {
49
+ kit = null
41
50
  io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
42
51
  }
43
52
  }
@@ -50,6 +59,90 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
50
59
  agentGraph,
51
60
  studio,
52
61
  checkCmd: [process.execPath, cli, 'check'],
62
+ attention: attentionCollector(root, c, kit),
63
+ }
64
+ }
65
+
66
+ // the hub must KNOW when it is outdated (field bug 2026-07-22: a morning
67
+ // process served stale code all day, picks looked broken): version read at
68
+ // boot vs on disk per attention load; mismatch = red line telling him to
69
+ // restart the task
70
+ const libVersion = (): string => {
71
+ try { return (JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
72
+ }
73
+ const BOOT_VERSION = libVersion()
74
+
75
+ // ── the / dashboard's data: everything red, one collector per source, each
76
+ // failure-isolated (a broken source reports itself, never blanks the page)
77
+ export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit | null): () => Promise<AttentionSection[]> {
78
+ const guard = async (title: string, tone: 'red' | 'warn', body: () => Promise<AttentionSection['lines']>): Promise<AttentionSection> => {
79
+ try { return { title, tone, lines: await body() } } catch (e) { return { title, tone: 'red', lines: [{ text: `collector broke: ${(e as Error).message}` }] } }
80
+ }
81
+ return async () => {
82
+ const sections = await Promise.all([
83
+ guard('hub process', 'red', async () => {
84
+ const now = libVersion()
85
+ if (now === BOOT_VERSION) return []
86
+ return [{ text: `hub is running shipkit ${BOOT_VERSION} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` }]
87
+ }),
88
+ guard('failing checks', 'red', async () => {
89
+ const p = join(root, 'STATUS.md')
90
+ if (!existsSync(p)) return []
91
+ // a cred FAIL whose ask already sits in the asks section is
92
+ // ONE action shown twice — the ask (his hands) wins
93
+ const askDir = join(root, c.asks?.dir ?? 'asks')
94
+ const askText = existsSync(askDir) ? openAsks(askDir).map((a) => a.title.toLowerCase()).join(' ') : ''
95
+ return (await Bun.file(p).text()).split('\n')
96
+ .filter((l) => l.trimStart().startsWith('FAIL'))
97
+ .filter((l) => {
98
+ const cred = l.match(/cred rv\/([a-z0-9-]+)\/[a-z0-9-]+ missing/)?.[1]
99
+ return cred == null || !askText.includes(cred)
100
+ })
101
+ .map((text) => ({ text, href: '/status' }))
102
+ }),
103
+ guard('asks — waiting on you', 'red', async () => {
104
+ const dir = join(root, c.asks?.dir ?? 'asks')
105
+ if (!existsSync(dir)) return [] // read path must not mint an asks/ dir
106
+ return openAsks(dir).map((a) => ({ text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}` }))
107
+ }),
108
+ guard('assets', 'red', async () => {
109
+ // self-heal first: identity re-renders itself on brand.ts
110
+ // drift; only pick-blocked (taste) surfaces below
111
+ const idn = await healIdentity(root)
112
+ const lines: AttentionSection['lines'] = []
113
+ if (idn.state === 'blocked') lines.push({ text: `identity — ${idn.detail ?? 'blocked'}`, href: '/assets/#identity-logo' })
114
+ if (kit == null) return lines
115
+ for (const f of allAssets(kit)) {
116
+ const dir = assetDir(join(root, kit.workspace), f.asset.id)
117
+ const rolls = listRolls(dir)
118
+ const manual = f.kind.generate.backend === 'manual'
119
+ if (manual && rolls.length === 0) lines.push({ text: `${f.asset.id} — manual, nothing uploaded`, href: `/assets/#${f.asset.id}` })
120
+ else if (rolls.length > 0 && (await readPick(dir)).pick == null) lines.push({ text: `${f.asset.id} — ${rolls.length} roll(s), none picked`, href: `/assets/#${f.asset.id}` })
121
+ else if (await isStale(kit, join(root, kit.workspace), f)) lines.push({ text: `${f.asset.id} — stale (a parent was re-picked)`, href: `/assets/#${f.asset.id}` })
122
+ }
123
+ return lines
124
+ }),
125
+ guard('contracts', 'red', async () => {
126
+ const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
127
+ return r.lines.filter((l) => l.kind === 'fail').map((l) => ({ text: l.text }))
128
+ }),
129
+ guard('contract warnings', 'warn', async () => {
130
+ const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
131
+ return r.lines.filter((l) => l.kind === 'warn').map((l) => ({ text: l.text }))
132
+ }),
133
+ guard('posts to write', 'warn', async () => {
134
+ const social = join(root, '.rv-social')
135
+ if (!existsSync(social)) return []
136
+ const lines: AttentionSection['lines'] = []
137
+ for (const ch of readdirSync(social)) {
138
+ const d = join(social, ch)
139
+ if (!statSync(d).isDirectory()) continue
140
+ for (const f of readdirSync(d).filter((x) => x.endsWith('.md'))) lines.push({ text: `${ch}/${f}` })
141
+ }
142
+ return lines
143
+ }),
144
+ ])
145
+ return sections
53
146
  }
54
147
  }
55
148