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
@@ -0,0 +1,63 @@
1
+ // SK007 — github visibility tripwire (meta/contracts.md pre-public gate).
2
+ // `repo.config.ts visibility: 'private' | 'public'` (absent = 'private')
3
+ // states the INTENT; gh states the FACT; a mismatch in either direction is
4
+ // red — public-while-declared-private is the leak case,
5
+ // private-while-declared-public is a stale config lying to agents. The
6
+ // TARGET repo is resolved explicitly from the remote url (preferring
7
+ // `github`, then `origin`, then the first remote) and passed to gh — gh's
8
+ // own base-repo resolution prefers `upstream`, which in a fork-shaped
9
+ // checkout would confidently validate the WRONG repository. Flipping a
10
+ // repo public stays a human act behind the pre-public audit; this rule is
11
+ // the standing tripwire if it ever happens out of band. Clean passes cache
12
+ // 7d, keyed on the declared value (a flip re-probes immediately); every
13
+ // unverifiable path (no git, no remote, non-github remote, gh missing or
14
+ // erroring, unreadable answer) is a loud skip — an UNKNOWN, never red,
15
+ // never cached.
16
+ import type { Rule } from './rule.ts'
17
+
18
+ export const githubVisibility: Rule = {
19
+ id: 'SK007',
20
+ title: 'github visibility',
21
+ owner: 'meta/contracts.md',
22
+ ttl: '7d',
23
+ cacheKey: (env) => env.config.visibility ?? 'private',
24
+ lint: async (t, env) => {
25
+ const declared = env.config.visibility ?? 'private'
26
+ const skip = (why: string): void => void t.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
27
+ const firstLine = (s: string): string => s.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? ''
28
+
29
+ const remotes = await t.exec(['git', 'remote'], { cwd: env.root })
30
+ if (remotes.code !== 0) return skip(`git says: ${firstLine(remotes.out) || `exit ${remotes.code}`}`)
31
+ const names = remotes.out.trim().split('\n').filter((l) => l !== '')
32
+ if (names.length === 0) return skip('no git remote')
33
+ // his repos name it `github`, not `origin` (field catch 2026-07-26)
34
+ const pick = ['github', 'origin'].find((n) => names.includes(n)) ?? names[0]!
35
+ const url = await t.exec(['git', 'remote', 'get-url', pick], { cwd: env.root })
36
+ if (url.code !== 0) return skip(`git says: ${firstLine(url.out)}`)
37
+ const m = url.out.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\s*$/m)
38
+ if (m == null) return skip(`remote '${pick}' is not github (${firstLine(url.out)})`)
39
+ const target = m[1]!
40
+
41
+ const r = await t.exec(['gh', 'repo', 'view', target, '--json', 'isPrivate,nameWithOwner'], { cwd: env.root, timeoutMs: 30_000 })
42
+ if (r.code !== 0) return skip(`gh failed (${firstLine(r.out)})`)
43
+ // gh mixes stderr chatter (release notices, SSO warnings) into the
44
+ // captured output — carve the JSON object out before parsing, and an
45
+ // unreadable answer stays an UNKNOWN, never a FAIL
46
+ const start = r.out.indexOf('{')
47
+ const end = r.out.lastIndexOf('}')
48
+ let parsed: { isPrivate?: boolean; nameWithOwner?: string }
49
+ try {
50
+ if (start === -1 || end < start) throw new Error('no JSON object in output')
51
+ parsed = JSON.parse(r.out.slice(start, end + 1)) as typeof parsed
52
+ } catch {
53
+ return skip(`gh answer unreadable (${firstLine(r.out)})`)
54
+ }
55
+ const actual = parsed.isPrivate === true ? 'private' : 'public'
56
+ const who = parsed.nameWithOwner ?? target
57
+ if (actual === declared) t.ok(`github ${who} is ${actual} — matches repo.config visibility`)
58
+ else if (actual === 'public')
59
+ t.fail(`github ${who} is PUBLIC but repo.config declares '${declared}' — the leak case; make it private again or run the pre-public gate and declare visibility: 'public'`)
60
+ else
61
+ t.fail(`github ${who} is private but repo.config declares 'public' — stale config lying to agents; fix the declaration`)
62
+ },
63
+ }
@@ -0,0 +1,32 @@
1
+ // THE rule registry — single source of every SK rule. `shipkit check` runs
2
+ // them, `shipkit rules` lists them, fix pointers and wiki pages name their
3
+ // ids. A duplicate or malformed id throws at import time: every surface
4
+ // that loads rules dies loud rather than run with an ambiguous registry.
5
+ import type { Rule } from './rule.ts'
6
+ import { packageJsonShape } from './SK001-package-json-shape.ts'
7
+ import { contractsFulfillness } from './SK002-contracts-fulfillness.ts'
8
+ import { claudeMdShape } from './SK003-claude-md-shape.ts'
9
+ import { gitignorePolicy } from './SK004-gitignore-policy.ts'
10
+ import { rvlibVersions } from './SK005-rvlib-versions.ts'
11
+ import { npmPublishSafety } from './SK006-npm-publish-safety.ts'
12
+ import { githubVisibility } from './SK007-github-visibility.ts'
13
+
14
+ export const RULES: readonly Rule[] = [
15
+ packageJsonShape,
16
+ contractsFulfillness,
17
+ claudeMdShape,
18
+ gitignorePolicy,
19
+ rvlibVersions,
20
+ npmPublishSafety,
21
+ githubVisibility,
22
+ ]
23
+
24
+ const seen = new Set<string>()
25
+ for (const r of RULES) {
26
+ if (!/^SK\d{3}$/.test(r.id)) throw new Error(`rule-registry: bad id '${r.id}' — rules are SK### (3 digits, never reused)`)
27
+ if (seen.has(r.id)) throw new Error(`rule-registry: duplicate id ${r.id}`)
28
+ if (r.owner === '') throw new Error(`rule-registry: ${r.id} has no owner page`)
29
+ seen.add(r.id)
30
+ }
31
+
32
+ export const ruleById = (id: string): Rule | undefined => RULES.find((r) => r.id === id)
@@ -0,0 +1,62 @@
1
+ // Rule — one machine-enforced invariant with a stable id. One module per
2
+ // rule (`src/rules/SK###-<slug>.ts`), registered in rule-registry.ts; ids
3
+ // are never reused or renumbered. `shipkit check` runs every rule's lint;
4
+ // a rule may also carry an idempotent fix() (the `check --fix` runner).
5
+ // Every wire line a rule emits is tagged `[SK###]` so output is greppable
6
+ // by rule and a fix pointer names the rule that can repair it.
7
+ import type { CheckCtx } from '../core/check-ctx.ts'
8
+ import type { Line } from '../core/wire.ts'
9
+ import type { RepoConfig } from '../repo-config.ts'
10
+
11
+ export type RuleEnv = { root: string; config: RepoConfig }
12
+
13
+ export type FixResult = { changed: boolean; summary: string }
14
+
15
+ export type Rule = {
16
+ /** stable `SK` + 3 digits — never reused, never renumbered (registry throws) */
17
+ id: string
18
+ /** check-suite display name — stable: the check name in STATUS.md, the TUI row, and the suite cache */
19
+ title: string
20
+ /** wiki page owning the invariant — the run-check framework appends `docsBase + owner`
21
+ * as the fix: pointer whenever a failing/warning lint attached none */
22
+ owner: string
23
+ /** clean-pass cache duration (check-suite TTL, e.g. '24h') — for rules that
24
+ * probe the network; skip/WARN/FAIL runs are never cached */
25
+ ttl?: string
26
+ /** cache-identity contribution for TTL'd rules whose verdict depends on
27
+ * config — a changed key busts the cache instantly instead of replaying
28
+ * a stale assertion for the TTL */
29
+ cacheKey?: (env: RuleEnv) => string
30
+ lint: (t: CheckCtx, env: RuleEnv) => void | Promise<void>
31
+ /** idempotent auto-repair; `check --fix` applies it then re-lints — never interactive */
32
+ fix?: (env: RuleEnv) => FixResult | Promise<FixResult>
33
+ }
34
+
35
+ // THE one way to run a rule's lint (check suite + the --fix runner): a
36
+ // throwing lint still fails with its id, a failing/warning run always
37
+ // carries at least one fix: pointer (the lint's own precise ones win, else
38
+ // docsBase + the rule's owner page), and every line gets tagged.
39
+ export async function runRuleLint(rule: Rule, t: CheckCtx, env: RuleEnv): Promise<void> {
40
+ try {
41
+ await rule.lint(t, env)
42
+ } catch (e) {
43
+ t.fail(`${rule.title} lint errored (${String(e).slice(0, 200)}) — network down, or a bug in this rule`)
44
+ }
45
+ if ((t.failed || t.warned) && !t.lines.some((l) => l.kind === 'fix-fail' || l.kind === 'fix-warn'))
46
+ t.lines.push({ kind: t.failed ? 'fix-fail' : 'fix-warn', text: `${env.config.docsBase ?? ''}${rule.owner}` })
47
+ t.lines = tagRuleLines(rule, t.lines)
48
+ }
49
+
50
+ // tag every severity line with the rule id; fix pointers of a fixable rule
51
+ // advertise the autofix. Idempotent — already-tagged lines pass through
52
+ // (cached suite replays re-enter here).
53
+ export function tagRuleLines(rule: Rule, lines: Line[]): Line[] {
54
+ return lines.map((l) => {
55
+ if (l.kind === 'fix-warn' || l.kind === 'fix-fail')
56
+ return rule.fix == null || l.text.includes('--fix ')
57
+ ? l
58
+ : { ...l, text: `${l.text} · autofix: shipkit check --fix ${rule.id}` }
59
+ if (l.kind === 'note') return l
60
+ return l.text.startsWith(`[${rule.id}]`) ? l : { ...l, text: `[${rule.id}] ${l.text}` }
61
+ })
62
+ }
@@ -0,0 +1,57 @@
1
+ // `shipkit check --fix [SK### …]` — apply rule autofixes, then re-lint and
2
+ // show the real end state. Never runs under ci (the gate stays pure), never
3
+ // caches (a fixer must see reality). No ids = every fixable rule; a
4
+ // requested id that is unknown or unfixable fails loud instead of silently
5
+ // narrowing.
6
+ import { CheckCtx } from '../core/check-ctx.ts'
7
+ import { formatLine } from '../core/wire.ts'
8
+ import { loadRepoConfig } from '../repo-config.ts'
9
+ import { RULES, ruleById } from './rule-registry.ts'
10
+ import { runRuleLint, type RuleEnv } from './rule.ts'
11
+
12
+ export type FixOpts = {
13
+ ids?: string[]
14
+ print?: (line: string) => void
15
+ }
16
+
17
+ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
18
+ const print = o.print ?? ((l: string) => console.log(l))
19
+ const config = await loadRepoConfig(root)
20
+ const env: RuleEnv = { root, config }
21
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
22
+
23
+ let targets = RULES.filter((r) => r.fix != null)
24
+ if (o.ids != null && o.ids.length > 0) {
25
+ for (const id of o.ids) {
26
+ const r = ruleById(id)
27
+ if (r == null) { print(`FAIL: unknown rule '${id}' — see: shipkit rules`); return 1 }
28
+ if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
29
+ }
30
+ const wanted = new Set(o.ids)
31
+ targets = targets.filter((r) => wanted.has(r.id))
32
+ }
33
+
34
+ let exit = 0
35
+ for (const rule of targets) {
36
+ const before = new CheckCtx(new Map())
37
+ await runRuleLint(rule, before, env)
38
+ if (!before.failed && !before.warned) {
39
+ print(`ok: [${rule.id}] already clean — nothing to fix`)
40
+ continue
41
+ }
42
+ const f = await rule.fix!(env)
43
+ // an unchanged fix is the DESIGNED outcome for judgment findings —
44
+ // a note, not a warning
45
+ print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `[${rule.id}] fix changed nothing — ${f.summary}`)
46
+ const after = new CheckCtx(new Map())
47
+ await runRuleLint(rule, after, env)
48
+ // the fix already ran: advertising it again on surviving findings
49
+ // would send an agent in a loop
50
+ for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix SK\d{3}$/, '') }, color))
51
+ if (after.failed) exit = 1
52
+ }
53
+ const unfixable = RULES.filter((r) => r.fix == null).length
54
+ if (unfixable > 0 && (o.ids == null || o.ids.length === 0))
55
+ print(`${unfixable} rule(s) have no autofix — run \`shipkit check\` for the full repo state`)
56
+ return exit
57
+ }
@@ -0,0 +1,192 @@
1
+ // Collection views: the per-collection card grid (ColGrid), the item card
2
+ // (asks · creds · posts), the graph item view (GraphView — embeds the
3
+ // /graph/* page itself), and the health/status card with the streaming
4
+ // check runner. Every verb posts to /api/act/* and lands in a git-tracked
5
+ // truth file; the truth chip names the file the item IS (on disagreement
6
+ // the file wins).
7
+ import { runInAction } from 'mobx'
8
+ import { observer } from 'mobx-react-lite'
9
+ import type { HubSt } from './hub-st.ts'
10
+ import type { ColInstance } from './hub-types.ts'
11
+ import { renderMd } from './md.ts'
12
+
13
+ // creds carry no truth file — name/service/state parse out of the instance
14
+ const credOf = (i: ColInstance): { name: string; service: string | null; missing: boolean } => ({
15
+ name: i.id.replace(/^cred:/, ''),
16
+ service: i.text.includes(' — ') ? i.text.split(' — ').slice(1).join(' — ') : null,
17
+ missing: i.severity != null,
18
+ })
19
+
20
+ const CredHead = ({ cred }: { cred: ReturnType<typeof credOf> }) => (
21
+ <>
22
+ <span className="credname">{cred.name}</span>
23
+ <span className={`stchip ${cred.missing ? 'miss' : 'ok'}`}>{cred.missing ? 'missing' : 'present'}</span>
24
+ </>
25
+ )
26
+
27
+ export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id: string }) => {
28
+ const c = app.cols
29
+ const i = c.find(col, id)
30
+ if (i == null) return <div className="ph">item vanished — the truth file moved on</div>
31
+ const askFile = col === 'asks' ? id.replace(/^ask:/, '') : null
32
+ const postM = col === 'posts' ? id.match(/^post:([^/]+)\/(.+?):(pick|topost|invalid|posted|rejected)$/) : null
33
+ const postFile = (i.file ?? '').split('/').pop() ?? ''
34
+ if (col === 'creds') {
35
+ // no remark box, no generic dump — a cred has no truth file; the
36
+ // copy block + steps only matter while it is MISSING
37
+ const cred = credOf(i)
38
+ return (
39
+ <div className="colcard">
40
+ <div className="back" onClick={() => app.showCol(col)}>← {c.colTitle(col)}</div>
41
+ <h3>
42
+ <span className={`cdot ${i.severity ?? ''}`} /> <CredHead cred={cred} />
43
+ {i.artifact?.url != null ? <a className="mint" href={i.artifact.url} target="_blank" rel="noreferrer">mint ↗</a> : null}
44
+ </h3>
45
+ {cred.service != null ? <div className="creddesc">{cred.service}</div> : null}
46
+ {/* probe FAIL + fix: pointers ride the instance md — for an
47
+ ask-covered cred (no verbs, no artifact) it is the ONLY detail */}
48
+ {i.md != null ? <div className="md" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
49
+ {cred.missing && i.artifact?.copy != null ? (
50
+ <>
51
+ <code>{i.artifact.copy}</code>
52
+ <div className="row"><button onClick={() => void navigator.clipboard.writeText(i.artifact!.copy!)}>copy</button></div>
53
+ </>
54
+ ) : null}
55
+ {cred.missing && i.artifact?.steps != null ? <div className="row"><div>{i.artifact.steps.map((s, n) => <span key={n}>{n + 1}. {s}<br /></span>)}</div></div> : null}
56
+ </div>
57
+ )
58
+ }
59
+ return (
60
+ <div className="colcard">
61
+ <div className="back" onClick={() => app.showCol(col)}>← {c.colTitle(col)}</div>
62
+ <h3>
63
+ <span className={`cdot ${i.severity ?? ''}`} /> {i.text}
64
+ {i.file != null ? <span className="truth">{i.file}</span> : null}
65
+ </h3>
66
+ {i.md != null ? <div className="md" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
67
+ {i.remarks != null && i.remarks.length > 0 ? <div className="rem">{i.remarks.join('\n')}</div> : null}
68
+ {i.artifact?.copy != null ? (
69
+ <>
70
+ <code>{i.artifact.copy}</code>
71
+ <div className="row"><button onClick={() => void navigator.clipboard.writeText(i.artifact!.copy!)}>copy</button></div>
72
+ </>
73
+ ) : null}
74
+ {i.artifact?.url != null ? <div className="row"><a href={i.artifact.url} target="_blank" rel="noreferrer">mint ↗</a></div> : null}
75
+ {i.artifact?.steps != null ? <div className="row"><div>{i.artifact.steps.map((s, n) => <span key={n}>{n + 1}. {s}<br /></span>)}</div></div> : null}
76
+ {postM?.[3] === 'pick' && i.candidates != null ? i.candidates.map((cd) => (
77
+ <div className="cand" key={cd.id}>
78
+ <div className="row"><b>{cd.label}</b><button onClick={() => void c.postPick(postM[1]!, cd.id)}>pick</button></div>
79
+ <div className="candhint">{cd.hint ?? ''}</div>
80
+ </div>
81
+ )) : null}
82
+ {postM?.[3] === 'topost' ? (
83
+ <div className="row">
84
+ <input placeholder="posted URL = proof" value={c.proofDraft} onChange={(e) => runInAction(() => { c.proofDraft = e.target.value })} />
85
+ <button onClick={() => void c.postPosted(postM[1]!, postFile)}>posted ✓</button>
86
+ </div>
87
+ ) : null}
88
+ {askFile != null && i.severity != null ? (
89
+ <div className="row">
90
+ <input placeholder="proof URL (optional)" value={c.proofDraft} onChange={(e) => runInAction(() => { c.proofDraft = e.target.value })} />
91
+ <button onClick={() => void c.askDone(askFile)}>done ✓</button>
92
+ </div>
93
+ ) : null}
94
+ {askFile != null || postM != null ? (
95
+ <div className="row">
96
+ <input placeholder="remark → lands in the file, agents read it" value={c.remarkDraft}
97
+ onChange={(e) => runInAction(() => { c.remarkDraft = e.target.value })} />
98
+ <button className="ghost" onClick={() => void c.remark(askFile != null ? { kind: 'ask', file: askFile } : { kind: 'post', channel: postM![1], file: postFile })}>remark</button>
99
+ </div>
100
+ ) : null}
101
+ {c.actErr !== '' ? <div className="err">✗ {c.actErr}</div> : null}
102
+ </div>
103
+ )
104
+ })
105
+
106
+ // collection view — one card per visible instance, the stage-side twin of a
107
+ // sidebar section (his rule 2026-07-23: every section gets both views)
108
+ export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
109
+ const c = app.cols
110
+ const col = c.sidebarCols.find((x) => x.id === id)
111
+ if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
112
+ const items = c.visibleInstances(col)
113
+ return (
114
+ <div className="colgrid">
115
+ <div className="cghead">{col.title}<span className="cnt">{items.length}</span></div>
116
+ {items.length === 0 ? <div className="ph">{app.filter === 'red' ? 'no reds here' : app.filter === 'needs' ? 'nothing here needs you' : 'nothing here yet'}</div> : (
117
+ <div className="cgcards">
118
+ {items.map((i) => {
119
+ if (id === 'creds') {
120
+ const cred = credOf(i)
121
+ return (
122
+ <div key={i.id} className="cgcard" onClick={() => c.select(col.id, i.id)}>
123
+ <div className="cgtop"><span className={`cdot ${i.severity ?? ''}`} /><CredHead cred={cred} /></div>
124
+ {cred.service != null ? <div className="creddesc">{cred.service}</div> : null}
125
+ {i.md != null ? <div className="md clamp" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
126
+ {i.artifact?.url != null ? (
127
+ <div className="credrow"><a className="mint" href={i.artifact.url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>mint ↗</a></div>
128
+ ) : null}
129
+ </div>
130
+ )
131
+ }
132
+ return (
133
+ <div key={i.id} className="cgcard" onClick={() => c.select(col.id, i.id)}>
134
+ <div className="cgtop"><span className={`cdot ${i.severity ?? ''}`} /><b>{i.text}</b></div>
135
+ {/* a link click opens its tab — it must not ALSO yank into the item view */}
136
+ {i.md != null ? <div className="md clamp" onClick={(e) => { if ((e.target as HTMLElement).tagName === 'A') e.stopPropagation() }} dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
137
+ {i.file != null ? <span className="truth">{i.file}</span> : null}
138
+ {i.remarks != null && i.remarks.length > 0 ? <div className="cgrem">{i.remarks[i.remarks.length - 1]}</div> : null}
139
+ </div>
140
+ )
141
+ })}
142
+ </div>
143
+ )}
144
+ </div>
145
+ )
146
+ })
147
+
148
+ // graph item view — the graph ITSELF fills the stage (his order 2026-07-23:
149
+ // "if I select a graph, I want to see it"), lint line as a slim header
150
+ export const GraphView = observer(({ app, id }: { app: HubSt; id: string }) => {
151
+ const c = app.cols
152
+ const i = c.find('graphs', id)
153
+ if (i == null) return <div className="stagewrap"><div className="ph">graph vanished — the truth moved on</div></div>
154
+ if (i.href == null) return <div className="stagewrap"><div className="ph">this graph carries no page to embed</div></div>
155
+ return (
156
+ <div className="graphview">
157
+ <div className="gvhead">
158
+ <span className="back" onClick={() => app.showCol('graphs')}>← {c.colTitle('graphs')}</span>
159
+ <span className={`cdot ${i.severity ?? ''}`} />
160
+ <span className="gvtext">{i.text}</span>
161
+ </div>
162
+ <iframe src={i.href} title={i.text} />
163
+ </div>
164
+ )
165
+ })
166
+
167
+ const wireClass = (l: string): string => {
168
+ const t = l.trimStart()
169
+ if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
170
+ if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
171
+ if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
172
+ if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
173
+ return ''
174
+ }
175
+
176
+ export const StatusCard = observer(({ app }: { app: HubSt }) => {
177
+ const h = app.health
178
+ return (
179
+ <div className="wrap">
180
+ <h1>{app.name}<small>STATUS.md</small></h1>
181
+ <div className="hbar">
182
+ <button disabled={h.running} onClick={() => void h.runCheck()}>run check</button>
183
+ <span className="note">{h.note !== '' ? h.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
184
+ </div>
185
+ <pre>
186
+ {(h.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
187
+ .split('\n')
188
+ .map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
189
+ </pre>
190
+ </div>
191
+ )
192
+ })
@@ -0,0 +1,136 @@
1
+ // Collections service — asks · creds · posts · health · graphs sections +
2
+ // the act-in-place verbs. Every act hits a git-tracked truth file through
3
+ // /api/act/*; errors come back as 400 text shown verbatim on the card.
4
+ import { makeAutoObservable, runInAction } from 'mobx'
5
+ import type { HubSt } from './hub-st.ts'
6
+ import type { Col, ColInstance } from './hub-types.ts'
7
+
8
+ export class ColsSvc {
9
+ cols: Col[] = []
10
+ actErr = ''
11
+ remarkDraft = ''
12
+ proofDraft = ''
13
+ /** user fold choices win; default = open when the section holds needs */
14
+ foldOverride = new Map<string, boolean>()
15
+ private timer: ReturnType<typeof setInterval> | null = null
16
+
17
+ constructor(private st: HubSt) {
18
+ makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false })
19
+ }
20
+
21
+ start(): void {
22
+ void this.poll()
23
+ this.timer = setInterval(() => void this.poll(), 10_000)
24
+ }
25
+ dispose(): void {
26
+ if (this.timer != null) clearInterval(this.timer)
27
+ }
28
+
29
+ async poll(): Promise<void> {
30
+ try {
31
+ const r = await fetch('/api/collections.json')
32
+ if (!r.ok) throw new Error(await r.text())
33
+ const j = (await r.json()) as { collections: Col[] }
34
+ runInAction(() => { this.cols = j.collections })
35
+ } catch (e) {
36
+ runInAction(() => {
37
+ this.cols = [{ id: 'hub', title: 'hub', media: 'health', instances: [{ id: 'hub:collections-broken', text: `collections fetch broke: ${(e as Error).message}`, severity: 'red' }] }]
38
+ })
39
+ }
40
+ }
41
+
42
+ /** media instances render richer through the media sidebar — the
43
+ * collection twin would be a duplicate surface (kept in `cols` so the
44
+ * asset card can show its remarks) */
45
+ get sidebarCols(): Col[] {
46
+ return this.cols.filter((c) => c.id !== 'media')
47
+ }
48
+ mediaRemarks(assetId: string): string[] {
49
+ return this.cols.find((c) => c.id === 'media')?.instances.find((i) => i.id === `media:${assetId}`)?.remarks ?? []
50
+ }
51
+
52
+ needsItem = (i: ColInstance): boolean => i.severity != null
53
+ isOpen(c: Col): boolean {
54
+ // a live search must SHOW its matches — folds yield while it runs
55
+ if (this.st.query.trim() !== '') return true
56
+ return this.foldOverride.get(c.id) ?? c.instances.some(this.needsItem)
57
+ }
58
+ toggleFold(c: Col): void {
59
+ // folds yield to a live search — toggling then would do nothing
60
+ // visibly and snap shut when the query clears
61
+ if (this.st.query.trim() !== '') return
62
+ this.foldOverride.set(c.id, !this.isOpen(c))
63
+ }
64
+ openSection(c: Col): void {
65
+ this.foldOverride.set(c.id, true)
66
+ }
67
+ /** rail red dot — the section holds at least one red item */
68
+ hasRed(c: Col): boolean {
69
+ return c.instances.some((i) => i.severity === 'red')
70
+ }
71
+ get mediaHasRed(): boolean {
72
+ return this.cols.find((c) => c.id === 'media')?.instances.some((i) => i.severity === 'red') === true
73
+ }
74
+ visibleInstances(c: Col): ColInstance[] {
75
+ const f = this.st.filter
76
+ const base = f === 'all' ? c.instances
77
+ : c.instances.filter((i) => (f === 'red' ? i.severity === 'red' : this.needsItem(i)))
78
+ const q = this.st.query.trim().toLowerCase()
79
+ if (q === '') return base
80
+ return base.filter((i) => i.text.toLowerCase().includes(q) || (i.file ?? '').toLowerCase().includes(q))
81
+ }
82
+ get redCount(): number {
83
+ return this.cols.flatMap((c) => c.instances).filter((i) => i.severity === 'red').length
84
+ }
85
+ find(colId: string, id: string): ColInstance | null {
86
+ return this.cols.find((c) => c.id === colId)?.instances.find((i) => i.id === id) ?? null
87
+ }
88
+ colTitle(colId: string): string {
89
+ return this.cols.find((c) => c.id === colId)?.title ?? colId
90
+ }
91
+
92
+ select(colId: string, id: string): void {
93
+ this.st.sel = { t: 'item', col: colId, id }
94
+ this.actErr = ''
95
+ this.remarkDraft = ''
96
+ this.proofDraft = ''
97
+ }
98
+
99
+ private async act(path: string, body: Record<string, unknown>): Promise<boolean> {
100
+ this.actErr = ''
101
+ try {
102
+ const r = await fetch(`/api/act/${path}`, { method: 'POST', body: JSON.stringify(body) })
103
+ if (!r.ok) throw new Error(await r.text())
104
+ await this.poll()
105
+ return true
106
+ } catch (e) {
107
+ runInAction(() => { this.actErr = (e as Error).message })
108
+ return false
109
+ }
110
+ }
111
+
112
+ askDone(file: string): Promise<boolean> {
113
+ const url = this.proofDraft.trim()
114
+ return url !== '' ? this.act('ask-proof', { file, url }) : this.act('ask-done', { file })
115
+ }
116
+ postPick(channel: string, file: string): Promise<boolean> {
117
+ return this.act('post-pick', { channel, file })
118
+ }
119
+ async postPosted(channel: string, file: string): Promise<boolean> {
120
+ const url = this.proofDraft.trim()
121
+ if (url === '') {
122
+ this.actErr = 'paste the posted URL first — the proof is the point'
123
+ return false
124
+ }
125
+ const ok = await this.act('post-posted', { channel, file, url })
126
+ if (ok) runInAction(() => { this.proofDraft = '' })
127
+ return ok
128
+ }
129
+ async remark(body: Record<string, unknown>): Promise<void> {
130
+ const text = this.remarkDraft.trim()
131
+ if (text === '') return
132
+ // draft survives a failed send — his words are never erased
133
+ const ok = await this.act('remark', { ...body, text })
134
+ if (ok) runInAction(() => { this.remarkDraft = '' })
135
+ }
136
+ }