rvlib-shipkit 0.9.0 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 — 2026-07-23
4
+
5
+ The needs feed: the hub's `/` now answers "what needs a human right now,
6
+ artifact in hand".
7
+
8
+ New:
9
+
10
+ - Collection model (`src/collections/`): every curatable surface projects
11
+ through one interface — media kinds, asks, missing declared secrets
12
+ (mint artifact + `credsCommand` store template as config data), repo
13
+ health, social posts. `projectNeeds` is failure-isolated per collection.
14
+ - `shipkit needs [--json]`: the feed as a CLI — red/yellow by collection,
15
+ REPORT-ONLY (exit 0 always; `check`/`ci` stay the gates).
16
+ - Hub `/` = the needs feed: act-in-place tiles (copy buttons for briefs/
17
+ commands/post text, mint links, done + proof-URL inputs, draft pick
18
+ buttons), reds-only toggle, `/api/needs.json` + `/api/act/*` (every
19
+ mutation hits a git-tracked truth file).
20
+ - RED-only pings while the hub serves: one batched notification per tick
21
+ of NEW red items (`notifyCommand` template + macOS notification).
22
+ Once-ever per item: cache file for non-ask items, the ask file's own
23
+ `pinged:` field for asks. Yellow never pings.
24
+ - Social posts contract v2: slug-only files, variants (`<slug>.a.md`) as
25
+ drafts-as-rolls (pick flips siblings to rejected, files kept),
26
+ frontmatter kind/tone/status/posted/rating, `## Angle` / `## Post` /
27
+ `## Feedback` sections — the copy button pastes ONLY `## Post`;
28
+ feedback appends are non-destructive.
29
+ - Missing manual media tiles carry their brief as the copy artifact.
30
+
31
+ Breaking (0.10.x consumers):
32
+
33
+ - `/api/attention.json` → `/api/needs.json` (NeedItem[] — flat, severity
34
+ red|yellow); `attentionRedCount`/`AttentionSection`/`AttentionLine`
35
+ exports removed (use `redCount`/`NeedItem`/`Instance`). Unpicked-roll
36
+ and stale assets are now YELLOW (taste pending — a default ships), no
37
+ longer red.
38
+ - `.rv-social` post files: `status:` frontmatter is required (missing =
39
+ a red feed item); v1 vocab maps idea→draft, ready→picked, link→posted.
40
+
41
+ ## 0.10.0 — 2026-07-22
42
+
43
+ New:
44
+
45
+ - `shipkit start`: `--include` is now repeatable — every given line is
46
+ ensured in CLAUDE.md, in order, inserted after any leading include lines
47
+ already present (never double-added; `--also-ok` satisfies the first
48
+ include).
49
+
50
+ Fixed:
51
+
52
+ - md graph scanner: a backticked/arrow token carrying the scan root's own
53
+ dir name (`agent/architecture.md` while scanning `agent/`) now resolves —
54
+ routing tables written repo-root-relative finally produce edges.
55
+
3
56
  ## 0.9.0 — 2026-07-22
4
57
 
5
58
  New:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -99,6 +99,40 @@ ${whyBlock}**Answer**: _(fill in / check off, or just do it — the check probe
99
99
  return file
100
100
  }
101
101
 
102
+ /** flip an ask to done and archive it (the hub's "done" verb) */
103
+ export function archiveAsk(dir: string, file: string): void {
104
+ const p = join(dir, file)
105
+ if (!existsSync(p)) throw new Error(`no such ask: ${file}`)
106
+ const md = readFileSync(p, 'utf8').replace(/^status:.*$/m, `status: done\nclosed: ${new Date().toISOString().slice(0, 10)}`)
107
+ writeFileSync(p, md)
108
+ const done = join(dir, 'done')
109
+ mkdirSync(done, { recursive: true })
110
+ renameSync(p, join(done, file))
111
+ }
112
+
113
+ /** record the completion proof (human-loop: URL back = done), then archive */
114
+ export function proofAsk(dir: string, file: string, url: string): void {
115
+ const p = join(dir, file)
116
+ if (!existsSync(p)) throw new Error(`no such ask: ${file}`)
117
+ writeFileSync(p, `${readFileSync(p, 'utf8').trimEnd()}\n\n**Proof**: ${url}\n`)
118
+ archiveAsk(dir, file)
119
+ }
120
+
121
+ /** once-ever ping marker, shared with every ping path (notify doctrine):
122
+ * returns false when the ask was already pinged — never double-fire */
123
+ export function markPinged(dir: string, file: string): boolean {
124
+ const p = join(dir, file)
125
+ if (!existsSync(p)) return false
126
+ const md = readFileSync(p, 'utf8')
127
+ if (/^pinged:/m.test(md)) return false
128
+ const line = `pinged: ${new Date().toISOString().slice(0, 10)}`
129
+ const withMark = md.replace(/^status:(.*)$/m, `status:$1\n${line}`)
130
+ // a hand-edited ask without status: still gets a durable marker — a
131
+ // no-op write here would re-fire the ping every tick forever
132
+ writeFileSync(p, withMark !== md ? withMark : `${line}\n${md}`)
133
+ return true
134
+ }
135
+
102
136
  export function renderAsksTable(dir: string, print: (line: string) => void = console.log, opts?: { emptyText?: string }): number {
103
137
  const open = openAsks(dir)
104
138
  if (open.length === 0) { print(opts?.emptyText ?? 'no open asks — all caught up 🎉'); return 0 }
@@ -0,0 +1,47 @@
1
+ // shipkit needs — the needs feed as a CLI: everything blocked on a human,
2
+ // red/yellow, grouped by collection. REPORT-ONLY: exit 0 always — `check`
3
+ // and `ci` stay the gates. Thin clipanion surface; logic in collections/.
4
+ import { Command, Option } from 'clipanion'
5
+ import { loadKit } from '../assets/kit-config.ts'
6
+ import { projectNeeds } from '../collections/collection-types.ts'
7
+ import { buildCollections } from '../collections/providers.ts'
8
+ import { loadRepoConfig } from '../repo-config.ts'
9
+
10
+ export class NeedsCommand extends Command {
11
+ static override paths = [['needs']]
12
+
13
+ static override usage = Command.Usage({
14
+ category: 'Health',
15
+ description: 'everything blocked on a human, red/yellow by collection — report-only, exit 0 always (check/ci are the gates)',
16
+ examples: [
17
+ ['what needs me', 'shipkit needs'],
18
+ ['machine-readable (agents, drills, cross-repo rollups)', 'shipkit needs --json'],
19
+ ],
20
+ })
21
+
22
+ json = Option.Boolean('--json', false, { description: 'emit NeedItem[] JSON' })
23
+
24
+ async execute(): Promise<number> {
25
+ const root = process.cwd()
26
+ const c = await loadRepoConfig(root)
27
+ const kit = await loadKit(root).catch(() => null)
28
+ const needs = await projectNeeds(buildCollections(root, c, kit))
29
+ if (this.json) {
30
+ this.context.stdout.write(`${JSON.stringify(needs, null, 2)}\n`)
31
+ return 0
32
+ }
33
+ if (needs.length === 0) {
34
+ this.context.stdout.write('ok: nothing needs you\n')
35
+ return 0
36
+ }
37
+ for (const n of needs) {
38
+ const line = `${n.severity === 'red' ? 'red: ' : 'yellow:'} [${n.collection}] ${n.text}`
39
+ this.context.stdout.write(`${line}\n`)
40
+ if (n.artifact?.copy != null) this.context.stdout.write(` paste: ${n.artifact.copy}\n`)
41
+ if (n.artifact?.url != null) this.context.stdout.write(` mint: ${n.artifact.url}\n`)
42
+ }
43
+ const reds = needs.filter((n) => n.severity === 'red').length
44
+ this.context.stdout.write(`${reds} red · ${needs.length - reds} yellow\n`)
45
+ return 0
46
+ }
47
+ }
@@ -12,14 +12,14 @@ export class StartCommand extends Command {
12
12
  description: 'ensure <root>/<name> exists, optionally with a CLAUDE.md include line; prints the dir on stdout so a shell alias can cd there',
13
13
  examples: [
14
14
  ['start (or enter) a repo', 'shipkit start pong'],
15
- ['seed CLAUDE.md line 1', "shipkit start pong --include '@~/wiki/index.md'"],
15
+ ['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
16
16
  ],
17
17
  })
18
18
 
19
19
  name = Option.String()
20
20
  root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
21
- include = Option.String('--include', { description: 'line CLAUDE.md must start with; omitted = only ensure the dir' })
22
- alsoOk = Option.Array('--also-ok', { description: 'alternative lines that already count as wired — never double-add' })
21
+ include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept; omitted = only ensure the dir' })
22
+ alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
23
23
  skip = Option.Array('--skip', { description: 'repo names that never get the include' })
24
24
 
25
25
  async execute(): Promise<number> {
package/src/cli.ts CHANGED
@@ -11,6 +11,7 @@ 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
+ import { NeedsCommand } from './cli/cmd_Needs.ts'
14
15
  import { ServeCommand } from './cli/cmd_Serve.ts'
15
16
  import { StartCommand } from './cli/cmd_Start.ts'
16
17
  import { TuiCommand } from './cli/cmd_Tui.ts'
@@ -47,5 +48,6 @@ cli.register(AssetsIdentityCommand)
47
48
  cli.register(AssetsCheckCommand)
48
49
  cli.register(InitCommand)
49
50
  cli.register(StartCommand)
51
+ cli.register(NeedsCommand)
50
52
 
51
53
  cli.runExit(process.argv.slice(2))
@@ -0,0 +1,66 @@
1
+ // Act-in-place verbs behind the hub feed — every mutation targets a
2
+ // git-tracked truth file (studio pick-flow precedent: the hub writes, the
3
+ // repo's diff shows it; Feedback appends are append-only, never destructive).
4
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
5
+ import { join } from 'path'
6
+ import { archiveAsk, proofAsk } from '../asks/asks-queue.ts'
7
+ import { parsePostFile } from './posts.ts'
8
+
9
+ const today = (): string => new Date().toISOString().slice(0, 10)
10
+
11
+ const postPath = (root: string, channel: string, file: string): string => {
12
+ if (channel.includes('/') || channel.includes('..') || file.includes('/') || file.includes('..')) throw new Error('bad path')
13
+ const p = join(root, '.rv-social', channel, file)
14
+ if (!existsSync(p)) throw new Error(`no such post: ${channel}/${file}`)
15
+ return p
16
+ }
17
+
18
+ const setStatus = (p: string, status: string): void => {
19
+ const md = readFileSync(p, 'utf8')
20
+ if (!/^status:/m.test(md)) throw new Error('post has no status: frontmatter')
21
+ writeFileSync(p, md.replace(/^status:.*$/m, `status: ${status}`))
22
+ }
23
+
24
+ /** pick one draft variant: it flips to picked, sibling drafts to rejected
25
+ * (files KEPT — rejections are training data for the next generation) */
26
+ export function pickPostVariant(root: string, channel: string, file: string): void {
27
+ const p = postPath(root, channel, file)
28
+ const dir = join(root, '.rv-social', channel)
29
+ const slug = parsePostFile(dir, channel, file).slug
30
+ setStatus(p, 'picked')
31
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.md') && x !== file)) {
32
+ const sib = parsePostFile(dir, channel, f)
33
+ if (sib.slug === slug && sib.status === 'draft') setStatus(join(dir, f), 'rejected')
34
+ }
35
+ }
36
+
37
+ /** the posted-URL proof: status → posted, `posted:` frontmatter carries the URL */
38
+ export function markPosted(root: string, channel: string, file: string, url: string, rating?: number): void {
39
+ const p = postPath(root, channel, file)
40
+ setStatus(p, 'posted')
41
+ const md = readFileSync(p, 'utf8')
42
+ const withUrl = /^posted:/m.test(md) ? md.replace(/^posted:.*$/m, `posted: ${url}`) : md.replace(/^status:.*$/m, (s) => `${s}\nposted: ${url}`)
43
+ const withRating = rating != null
44
+ ? (/^rating:/m.test(withUrl) ? withUrl.replace(/^rating:.*$/m, `rating: ${rating}`) : withUrl.replace(/^posted:.*$/m, (s) => `${s}\nrating: ${rating}`))
45
+ : withUrl
46
+ writeFileSync(p, withRating)
47
+ }
48
+
49
+ /** append a dated remark under ## Feedback (created if absent) — NEVER
50
+ * destructive; future generations re-read these before drafting */
51
+ export function appendPostFeedback(root: string, channel: string, file: string, text: string): void {
52
+ const p = postPath(root, channel, file)
53
+ const md = readFileSync(p, 'utf8')
54
+ const line = `- ${today()}: ${text.replace(/\n+/g, ' ').trim()}`
55
+ writeFileSync(p, /^## Feedback\s*$/m.test(md) ? `${md.trimEnd()}\n${line}\n` : `${md.trimEnd()}\n\n## Feedback\n${line}\n`)
56
+ }
57
+
58
+ export function askDone(root: string, asksDir: string, file: string): void {
59
+ if (file.includes('/') || file.includes('..')) throw new Error('bad path')
60
+ archiveAsk(join(root, asksDir), file)
61
+ }
62
+
63
+ export function askProof(root: string, asksDir: string, file: string, url: string): void {
64
+ if (file.includes('/') || file.includes('..')) throw new Error('bad path')
65
+ proofAsk(join(root, asksDir), file, url)
66
+ }
@@ -0,0 +1,52 @@
1
+ // The collection model: every curatable surface (media kinds, asks, creds,
2
+ // repo health, posts) projects through ONE interface into the hub's needs
3
+ // feed, the studio browse, and `shipkit needs`. Truth files never move to
4
+ // fit the UI — a Collection only READS its source. Severity: yellow = a
5
+ // default is in use, taste pending; red = missing with no inventable
6
+ // default, or a paste-from-elsewhere. Doctrine page: build/collections in
7
+ // the docs wiki this lib ships beside.
8
+
9
+ export type NeedSeverity = 'red' | 'yellow'
10
+
11
+ export type CurationVerb = 'pick' | 'approve' | 'rate' | 'provide' | 'paste-proof' | 'set' | 'done' | 'archive'
12
+
13
+ export type Instance = {
14
+ id: string
15
+ /** one-line human text — the feed line */
16
+ text: string
17
+ /** absent = healthy, nothing needed */
18
+ severity?: NeedSeverity
19
+ /** deep link (studio anchor, /status, external editor) */
20
+ href?: string
21
+ /** paste-ready artifact: exact command/text to copy + optional steps */
22
+ artifact?: { copy?: string; url?: string; steps?: string[] }
23
+ verbs?: CurationVerb[]
24
+ /** roll-strip analog: pickable variants (post drafts, rolls) */
25
+ candidates?: { id: string; label: string; hint?: string }[]
26
+ }
27
+
28
+ export type Collection = {
29
+ id: string
30
+ title: string
31
+ media: 'image' | 'audio' | 'text' | 'data' | 'pointer' | 'health'
32
+ /** wire tone when any needing instance is only yellow */
33
+ list: () => Promise<Instance[]>
34
+ }
35
+
36
+ export type NeedItem = Instance & { collection: string }
37
+
38
+ /** the flat needs projection — failure-isolated per collection: a broken
39
+ * source reports itself as ONE red item, never blanks the feed */
40
+ export async function projectNeeds(collections: Collection[]): Promise<NeedItem[]> {
41
+ const out: NeedItem[] = []
42
+ for (const c of collections) {
43
+ try {
44
+ for (const i of await c.list()) if (i.severity != null) out.push({ ...i, collection: c.id })
45
+ } catch (e) {
46
+ out.push({ collection: c.id, id: `${c.id}:broken`, text: `collector broke: ${(e as Error).message}`, severity: 'red' })
47
+ }
48
+ }
49
+ return out
50
+ }
51
+
52
+ export const redCount = (needs: NeedItem[]): number => needs.filter((n) => n.severity === 'red').length
@@ -0,0 +1,68 @@
1
+ // RED-item pings — one batched notification per burst of NEW red needs.
2
+ // Yellow (taste debt) NEVER pings: that would be progress spam. Stateful by
3
+ // design: last-pinged ids persist in node_modules/.cache/shipkit (always
4
+ // gitignored territory); a repo without node_modules gets NO pings rather
5
+ // than re-fired backlogs (no persistence = no dedup = stay silent). For
6
+ // ask-backed items the marker is the ask file's own `pinged:` field — the
7
+ // SAME once-ever guarantee every other ping path uses.
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { markPinged } from '../asks/asks-queue.ts'
11
+ import type { NeedItem } from './collection-types.ts'
12
+
13
+ export type PingIo = {
14
+ /** run a notify command — injectable for tests */
15
+ run: (cmd: string[]) => void
16
+ /** repo.config notifyCommand template, `{message}` substituted */
17
+ notifyCommand?: string
18
+ /** additive desktop notification on macOS */
19
+ osascript?: boolean
20
+ }
21
+
22
+ const stateDir = (root: string): string | null => (existsSync(join(root, 'node_modules')) ? join(root, 'node_modules', '.cache', 'shipkit') : null)
23
+
24
+ const loadPinged = (root: string): Set<string> | null => {
25
+ const dir = stateDir(root)
26
+ if (dir == null) return null
27
+ try {
28
+ return new Set(JSON.parse(readFileSync(join(dir, 'pinged.json'), 'utf8')) as string[])
29
+ } catch {
30
+ return new Set()
31
+ }
32
+ }
33
+
34
+ const savePinged = (root: string, ids: Set<string>): void => {
35
+ const dir = stateDir(root)
36
+ if (dir == null) return
37
+ mkdirSync(dir, { recursive: true })
38
+ writeFileSync(join(dir, 'pinged.json'), JSON.stringify([...ids].sort()))
39
+ }
40
+
41
+ /** one tick: ping ONCE for the batch of never-pinged red items, mark them.
42
+ * Returns the pinged ids (empty = silence). */
43
+ export function pingTick(root: string, asksDir: string, needs: NeedItem[], io: PingIo): string[] {
44
+ const pinged = loadPinged(root)
45
+ if (pinged == null) return [] // no persistence, no pings — never re-fire a backlog
46
+ const fresh: NeedItem[] = []
47
+ for (const n of needs) {
48
+ if (n.severity !== 'red') continue
49
+ if (n.collection === 'asks') {
50
+ // ask file owns the once-ever marker (shared with agent pings)
51
+ const file = n.id.replace(/^ask:/, '')
52
+ if (markPinged(join(root, asksDir), file)) fresh.push(n)
53
+ } else if (!pinged.has(n.id)) {
54
+ pinged.add(n.id)
55
+ fresh.push(n)
56
+ }
57
+ }
58
+ savePinged(root, pinged)
59
+ if (fresh.length === 0) return []
60
+ 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 ? ' · …' : ''}`
62
+ // item texts come from repo data — strip shell-active chars before the template substitution
63
+ const safe = message.replace(/[`$\\'"]/g, '')
64
+ if (io.notifyCommand != null) io.run(['sh', '-c', io.notifyCommand.replaceAll('{message}', safe)])
65
+ if (io.osascript !== false && process.platform === 'darwin')
66
+ io.run(['osascript', '-e', `display notification ${JSON.stringify(message)} with title "shipkit needs"`])
67
+ return fresh.map((n) => n.id)
68
+ }
@@ -0,0 +1,106 @@
1
+ // social-posts collection — projects `.rv-social/<channel>/` post files
2
+ // (contract v2) into the needs feed. Status-aware: rejected/posted history
3
+ // stays on disk as training data and never floods the feed. Drafts-as-rolls:
4
+ // variants are sibling files `<slug>.a.md`, `<slug>.b.md`; picking one flips
5
+ // it to picked and the others to rejected (files KEPT). Severities:
6
+ // any file missing `status:` → red (metadata with no inventable default)
7
+ // drafts awaiting a pick → yellow (his taste)
8
+ // picked but not posted → red (paste-from-elsewhere: post + URL proof)
9
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
10
+ import { join } from 'path'
11
+ import type { Collection, Instance } from './collection-types.ts'
12
+
13
+ export type PostFile = {
14
+ channel: string
15
+ file: string
16
+ slug: string
17
+ variant: string | null
18
+ status: 'draft' | 'picked' | 'posted' | 'rejected' | null
19
+ kind: string | null
20
+ posted: string | null
21
+ body: string
22
+ }
23
+
24
+ const fm = (md: string, key: string): string | null => md.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null
25
+
26
+ /** the paste-ready text: the `## Post` section body (Angle/Feedback never leak into the paste) */
27
+ export const postSection = (md: string): string => md.match(/^## Post\s*\n([\s\S]*?)(?=^## |\s*$(?![\s\S]))/m)?.[1]?.trim() ?? ''
28
+
29
+ export function parsePostFile(dir: string, channel: string, file: string): PostFile {
30
+ const md = readFileSync(join(dir, file), 'utf8')
31
+ const m = file.match(/^(.*?)(?:\.([a-z]))?\.md$/)
32
+ const status = fm(md, 'status')
33
+ return {
34
+ channel,
35
+ file,
36
+ slug: m?.[1] ?? file,
37
+ variant: m?.[2] ?? null,
38
+ status: status === 'draft' || status === 'picked' || status === 'posted' || status === 'rejected' ? status : null,
39
+ kind: fm(md, 'kind'),
40
+ posted: fm(md, 'posted'),
41
+ body: md,
42
+ }
43
+ }
44
+
45
+ export function listPosts(root: string): PostFile[] {
46
+ const social = join(root, '.rv-social')
47
+ if (!existsSync(social)) return []
48
+ const out: PostFile[] = []
49
+ for (const ch of readdirSync(social)) {
50
+ const d = join(social, ch)
51
+ if (!statSync(d).isDirectory()) continue
52
+ for (const f of readdirSync(d).filter((x) => x.endsWith('.md')).sort()) out.push(parsePostFile(d, ch, f))
53
+ }
54
+ return out
55
+ }
56
+
57
+ export function socialPostsCollection(root: string): Collection {
58
+ return {
59
+ id: 'posts',
60
+ title: 'posts',
61
+ media: 'text',
62
+ list: async () => {
63
+ const bySlug = new Map<string, PostFile[]>()
64
+ for (const p of listPosts(root)) {
65
+ const key = `${p.channel}/${p.slug}`
66
+ bySlug.set(key, [...(bySlug.get(key) ?? []), p])
67
+ }
68
+ const out: Instance[] = []
69
+ for (const [key, variants] of bySlug) {
70
+ const missing = variants.filter((v) => v.status == null)
71
+ if (missing.length > 0) {
72
+ out.push({
73
+ id: `post:${key}:invalid`,
74
+ text: `${key} — missing/invalid status: frontmatter (${missing.map((v) => v.file).join(', ')})`,
75
+ severity: 'red',
76
+ })
77
+ continue
78
+ }
79
+ const picked = variants.find((v) => v.status === 'picked')
80
+ if (picked != null) {
81
+ out.push({
82
+ id: `post:${key}:topost`,
83
+ text: `${key} — picked, waiting to be POSTED (copy → post → paste the URL)`,
84
+ severity: 'red',
85
+ artifact: { copy: postSection(picked.body) },
86
+ verbs: ['paste-proof', 'rate'],
87
+ candidates: [{ id: picked.file, label: picked.variant ?? 'post' }],
88
+ })
89
+ continue
90
+ }
91
+ const drafts = variants.filter((v) => v.status === 'draft')
92
+ if (drafts.length > 0) {
93
+ out.push({
94
+ id: `post:${key}:pick`,
95
+ text: `${key} — ${drafts.length} draft(s), pick one`,
96
+ severity: 'yellow',
97
+ verbs: ['pick', 'rate'],
98
+ candidates: drafts.map((v) => ({ id: v.file, label: v.variant ?? 'draft', hint: postSection(v.body).slice(0, 200) })),
99
+ })
100
+ }
101
+ // posted / rejected-only: quiet — history is training data, not attention
102
+ }
103
+ return out
104
+ },
105
+ }
106
+ }
@@ -0,0 +1,162 @@
1
+ // The built-in collection providers — each one REPLACED an attentionCollector
2
+ // guard (the collector is gone; never reintroduce a parallel projection).
3
+ // Behaviors preserved by name: per-source failure isolation (projectNeeds
4
+ // guards per collection), 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.
8
+ import { existsSync, readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { isStale } from '../assets/gen/derived.ts'
11
+ import { allAssets, type ResolvedKit } from '../assets/kit-config.ts'
12
+ import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
13
+ import { healIdentity } from '../assets/run-assets.ts'
14
+ import { openAsks } from '../asks/asks-queue.ts'
15
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
16
+ import { activeContracts, contractsConfigFrom, type RepoConfig } from '../repo-config.ts'
17
+ import type { Collection, Instance } from './collection-types.ts'
18
+ import { socialPostsCollection } from './posts.ts'
19
+
20
+ const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? 'asks')
21
+ // read path must not mint an asks/ dir (listAsks mkdirs on read)
22
+ const openAsksSafe = (root: string, c: RepoConfig): ReturnType<typeof openAsks> => {
23
+ const dir = askDirOf(root, c)
24
+ return existsSync(dir) ? openAsks(dir) : []
25
+ }
26
+
27
+ const CRED_LINE = /cred ([a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+) missing/
28
+
29
+ export function asksCollection(root: string, c: RepoConfig): Collection {
30
+ return {
31
+ id: 'asks',
32
+ title: 'asks — waiting on you',
33
+ media: 'text',
34
+ list: async () =>
35
+ openAsksSafe(root, c).map((a) => ({
36
+ id: `ask:${a.file}`,
37
+ text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}`,
38
+ severity: 'red' as const,
39
+ verbs: ['done' as const, 'paste-proof' as const],
40
+ })),
41
+ }
42
+ }
43
+
44
+ /** 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). */
47
+ export function credsCollection(root: string, c: RepoConfig): Collection {
48
+ return {
49
+ id: 'creds',
50
+ title: 'missing secrets',
51
+ media: 'pointer',
52
+ 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)
55
+ 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)
68
+ const decl = (c.creds ?? []).find((d) => d.name === name)
69
+ out.push(credInstance(name, c.credsCommand, decl))
70
+ }
71
+ // declared-but-not-probed creds stay quiet: only a STATUS.md
72
+ // probe line proves "missing" — declaration alone never reds
73
+ return out
74
+ },
75
+ }
76
+ }
77
+
78
+ // the store command is repo config DATA (`credsCommand` template) — the lib
79
+ // never names any particular secret manager
80
+ const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { service?: string; mintUrl?: string; steps?: string[] }): Instance => ({
81
+ id: `cred:${name}`,
82
+ text: `${name} missing${decl?.service != null ? ` — ${decl.service}` : ''}`,
83
+ severity: 'red',
84
+ artifact: { copy: cmdTemplate?.replaceAll('{name}', name), url: decl?.mintUrl, steps: decl?.steps },
85
+ verbs: ['set'],
86
+ })
87
+
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. */
91
+ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal: typeof healIdentity = healIdentity): Collection {
92
+ return {
93
+ id: 'media',
94
+ title: 'assets',
95
+ media: 'image',
96
+ list: async () => {
97
+ const idn = await heal(root)
98
+ const out: Instance[] = []
99
+ if (idn.state === 'blocked')
100
+ out.push({ id: 'media:identity', text: `identity — ${idn.detail ?? 'blocked'}`, severity: 'red', href: '/assets/#identity-logo', verbs: ['pick'] })
101
+ if (kit == null) return out
102
+ for (const f of allAssets(kit)) {
103
+ const dir = assetDir(join(root, kit.workspace), f.asset.id)
104
+ const rolls = listRolls(dir)
105
+ const manual = f.kind.generate.backend === 'manual'
106
+ const href = `/assets/#${f.asset.id}`
107
+ if (manual && rolls.length === 0)
108
+ // the brief IS the paste-ready artifact ("I don't even
109
+ // 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 })
111
+ 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'] })
113
+ 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'] })
115
+ }
116
+ return out
117
+ },
118
+ }
119
+ }
120
+
121
+ /** repo health — NON-curatable feed items: failing checks (minus cred lines
122
+ * the creds provider owns), contract FAIL/WARN, hub process staleness. */
123
+ export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVersion?: string; libVersion?: () => string }): Collection {
124
+ return {
125
+ id: 'health',
126
+ title: 'repo health',
127
+ media: 'health',
128
+ list: async () => {
129
+ const out: Instance[] = []
130
+ if (o?.bootVersion != null && o.libVersion != null) {
131
+ const now = o.libVersion()
132
+ if (now !== o.bootVersion)
133
+ 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
+ }
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
+ }
144
+ const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
145
+ for (const l of r.lines) {
146
+ if (l.kind === 'fail') out.push({ id: `health:contract:${l.text}`, severity: 'red', text: l.text })
147
+ else if (l.kind === 'warn') out.push({ id: `health:contractwarn:${l.text}`, severity: 'yellow', text: l.text })
148
+ }
149
+ return out
150
+ },
151
+ }
152
+ }
153
+
154
+ export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit | null, o?: { bootVersion?: string; libVersion?: () => string }): Collection[] {
155
+ return [
156
+ repoHealthCollection(root, c, o),
157
+ asksCollection(root, c),
158
+ credsCollection(root, c),
159
+ mediaKitCollection(root, kit),
160
+ socialPostsCollection(root),
161
+ ]
162
+ }
@@ -45,6 +45,12 @@ export type RepoConfig = {
45
45
  tasks?: TaskDef[]
46
46
  /** emptyText: the `shipkit asks` all-clear line, in the repo's own voice */
47
47
  asks?: { dir?: string; emptyText?: string }
48
+ /** secrets this repo needs; mint artifact rides as data (needs feed tile) */
49
+ creds?: { name: string; service?: string; mintUrl?: string; steps?: string[] }[]
50
+ /** store-command template for a missing secret tile, `{name}` substituted (e.g. your secret manager's set command) */
51
+ credsCommand?: string
52
+ /** notify command for new RED needs while the hub serves, `{message}` substituted (e.g. a push-notification CLI) */
53
+ notifyCommand?: string
48
54
  contracts?: { exempt?: Record<string, string>; extra?: ContractManifest[] }
49
55
  /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
50
56
  tui?: { panels?: TuiPanelDef[] }
@@ -53,7 +59,7 @@ export type RepoConfig = {
53
59
  logDir?: string
54
60
  }
55
61
 
56
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'contracts', 'tui', 'serve', 'logDir'])
62
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'tui', 'serve', 'logDir'])
57
63
 
58
64
  export function defineRepo(c: RepoConfig): RepoConfig {
59
65
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -9,15 +9,24 @@ import { StrictMode } from 'react'
9
9
  import { createRoot } from 'react-dom/client'
10
10
 
11
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 }
12
+ type NeedItem = {
13
+ collection: string
14
+ id: string
15
+ text: string
16
+ severity?: 'red' | 'yellow'
17
+ href?: string
18
+ artifact?: { copy?: string; url?: string; steps?: string[] }
19
+ verbs?: string[]
20
+ candidates?: { id: string; label: string; hint?: string }[]
21
+ }
14
22
 
15
23
  const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
16
24
 
17
25
  class HubStore {
18
26
  name = ''
19
27
  tools: HubTool[] = []
20
- sections: AttentionSection[] | null = null
28
+ needs: NeedItem[] | null = null
29
+ redsOnly = false
21
30
  statusMd: string | null = null
22
31
  running = false
23
32
  note = ''
@@ -28,17 +37,23 @@ class HubStore {
28
37
  }
29
38
 
30
39
  get redCount(): number {
31
- return (this.sections ?? []).filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
40
+ return (this.needs ?? []).filter((n) => n.severity === 'red').length
41
+ }
42
+
43
+ get visible(): NeedItem[] {
44
+ const all = this.needs ?? []
45
+ const sorted = [...all].sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'red' ? -1 : 1))
46
+ return this.redsOnly ? sorted.filter((n) => n.severity === 'red') : sorted
32
47
  }
33
48
 
34
49
  async load(): Promise<void> {
35
50
  try {
36
51
  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[] }
52
+ const nd = (await (await fetch('/api/needs.json')).json()) as { needs: NeedItem[] }
38
53
  runInAction(() => {
39
54
  this.name = hub.name
40
55
  this.tools = hub.tools
41
- this.sections = att.sections
56
+ this.needs = nd.needs
42
57
  })
43
58
  if (location.pathname === '/status') {
44
59
  const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
@@ -49,6 +64,29 @@ class HubStore {
49
64
  }
50
65
  }
51
66
 
67
+ /** per-tile act errors — feedback lands WHERE he clicked, never only in
68
+ * a scrolled-away bar */
69
+ actErr: Record<string, string> = {}
70
+ busy = false
71
+
72
+ /** POST an act-in-place verb, then refetch */
73
+ async act(id: string, path: string, body: Record<string, string>): Promise<void> {
74
+ runInAction(() => { this.busy = true; delete this.actErr[id] })
75
+ try {
76
+ const r = await fetch(`/api/act/${path}`, { method: 'POST', body: JSON.stringify(body) })
77
+ if (!r.ok) throw new Error(await r.text())
78
+ runInAction(() => { this.note = 'done' })
79
+ await this.load()
80
+ } catch (e) {
81
+ runInAction(() => { this.actErr[id] = (e as Error).message })
82
+ }
83
+ runInAction(() => { this.busy = false })
84
+ }
85
+
86
+ tileErr(id: string, msg: string): void {
87
+ runInAction(() => { this.actErr[id] = msg })
88
+ }
89
+
52
90
  async runCheck(): Promise<void> {
53
91
  this.running = true
54
92
  this.note = 'running…'
@@ -95,27 +133,81 @@ const Rail = observer(() => {
95
133
  )
96
134
  })
97
135
 
98
- const Attention = observer(() => {
99
- const live = (store.sections ?? []).filter((s) => s.lines.length > 0)
136
+ const copy = (s: string): void =>
137
+ void navigator.clipboard.writeText(s).then(
138
+ () => runInAction(() => { store.note = 'copied' }),
139
+ () => runInAction(() => { store.note = 'copy failed (clipboard needs a secure context)' }),
140
+ )
141
+
142
+ /** one act-in-place tile per need — the artifact is IN HAND (copy buttons,
143
+ * mint links, proof inputs), never a pointer to a flow */
144
+ const NeedTile = observer(({ n }: { n: NeedItem }) => {
145
+ const askFile = n.collection === 'asks' ? n.id.replace(/^ask:/, '') : null
146
+ const post = n.collection === 'posts' ? n.id.match(/^post:([^/]+)\/(.+?):(pick|topost|invalid)$/) : null
147
+ const pickedFile = n.collection === 'posts' ? n.candidates?.[0]?.id : null
100
148
  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>
149
+ <div className={`tile ${n.severity ?? ''}`}>
150
+ <div className="tline">
151
+ <span className="chip">{n.collection}</span>
152
+ {n.href != null ? <a href={n.href}>{n.text}</a> : <span>{n.text}</span>}
153
+ </div>
154
+ {n.artifact?.copy != null ? (
155
+ <div className="tact">
156
+ <button onClick={() => copy(n.artifact!.copy!)}>copy {n.collection === 'creds' ? 'command' : n.collection === 'posts' ? 'post' : 'brief'}</button>
157
+ <code>{n.artifact.copy.length > 120 ? `${n.artifact.copy.slice(0, 120)}…` : n.artifact.copy}</code>
158
+ </div>
159
+ ) : null}
160
+ {n.artifact?.url != null ? <div className="tact"><a href={n.artifact.url} target="_blank" rel="noreferrer">mint ↗</a></div> : null}
161
+ {n.artifact?.steps != null ? <details className="tact"><summary>steps</summary>{n.artifact.steps.map((s, i) => <div key={i}>{i + 1}. {s}</div>)}</details> : null}
162
+ {post?.[3] === 'pick' ? (
163
+ // the CORE taste interaction: candidates comparable at a
164
+ // glance, never a blind collapse
165
+ <div className="tact picks">
166
+ {n.candidates?.map((c) => (
167
+ <div key={c.id} className="cand">
168
+ <div className="candhead">
169
+ <span className="chip">{c.label}</span>
170
+ <button disabled={store.busy} onClick={() => void store.act(n.id, 'post-pick', { channel: post[1]!, file: c.id })}>pick</button>
171
+ </div>
172
+ <div className="hint">{c.hint}</div>
173
+ </div>
174
+ ))}
175
+ </div>
176
+ ) : null}
177
+ {post?.[3] === 'topost' && pickedFile != null ? (
178
+ <form className="tact" onSubmit={(e) => { e.preventDefault(); const url = new FormData(e.currentTarget).get('url') as string; if (url === '') store.tileErr(n.id, 'paste the posted URL first — the proof is the point'); else void store.act(n.id, 'post-posted', { channel: post[1]!, file: pickedFile, url }) }}>
179
+ <input name="url" placeholder="posted URL = proof" />
180
+ <button type="submit" disabled={store.busy}>posted ✓</button>
181
+ </form>
182
+ ) : null}
183
+ {askFile != null ? (
184
+ <form className="tact" onSubmit={(e) => { e.preventDefault(); const url = new FormData(e.currentTarget).get('url') as string; void (url !== '' ? store.act(n.id, 'ask-proof', { file: askFile, url }) : store.act(n.id, 'ask-done', { file: askFile })) }}>
185
+ <input name="url" placeholder="proof URL (optional)" />
186
+ <button type="submit" disabled={store.busy}>done ✓</button>
187
+ </form>
188
+ ) : null}
189
+ {store.actErr[n.id] != null ? <div className="tileerr">✗ {store.actErr[n.id]}</div> : null}
115
190
  </div>
116
191
  )
117
192
  })
118
193
 
194
+ const Attention = observer(() => (
195
+ <div className="wrap">
196
+ <div className="crumb">shipkit hub · needs</div>
197
+ <h1>{store.name}<small>everything that needs you, artifact in hand</small></h1>
198
+ {store.needs == null ? <div className="sub">loading…</div>
199
+ : store.needs.length === 0 ? <div className="clear">✅ all clear — nothing needs you</div>
200
+ : <>
201
+ <div className="bar">
202
+ <label><input type="checkbox" checked={store.redsOnly} onChange={(e) => runInAction(() => { store.redsOnly = e.target.checked })} /> reds only</label>
203
+ <span className="note">{store.redCount} red · {(store.needs.length - store.redCount)} yellow{store.note !== '' ? ` · ${store.note}` : ''}</span>
204
+ </div>
205
+ {store.visible.map((n) => <NeedTile key={n.id} n={n} />)}
206
+ </>}
207
+ <div className="sub">refreshes on reload · checks refresh via <a href="/status">status → run check</a></div>
208
+ </div>
209
+ ))
210
+
119
211
  const wireClass = (l: string): string => {
120
212
  const t = l.trimStart()
121
213
  if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
@@ -39,3 +39,27 @@ pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padd
39
39
  font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
40
40
  .ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
41
41
  .err{margin-top:26px;color:var(--red)}
42
+
43
+ /* needs feed tiles — red blocks, yellow = taste pending */
44
+ .tile{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--dim);border-radius:12px;
45
+ padding:12px 16px;margin-top:10px}
46
+ .tile.red{border-left-color:var(--red)}
47
+ .tile.yellow{border-left-color:var(--gold)}
48
+ .tline{display:flex;align-items:baseline;gap:10px;font-size:14px}
49
+ .tline a{color:var(--ink);text-decoration:underline dotted}.tline a:hover{color:var(--gold)}
50
+ .chip{flex:none;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);
51
+ background:var(--panel2);border-radius:8px;padding:1px 8px}
52
+ .tile.red .chip{color:#e8a0a0}
53
+ .tact{display:flex;align-items:center;gap:10px;margin-top:8px;font-size:12.5px;flex-wrap:wrap}
54
+ .tact code{color:var(--dim);font:12px ui-monospace,Menlo,monospace;overflow:hidden;text-overflow:ellipsis;max-width:560px;white-space:nowrap}
55
+ .tact button{padding:4px 10px;font-size:12px}
56
+ .tact input{background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--ink);padding:6px 10px;font-size:12.5px;min-width:240px}
57
+ .tact a{color:var(--accent)}
58
+ .tact details{width:100%}
59
+ .tact summary{cursor:pointer;color:var(--dim)}
60
+ .hint{color:var(--dim);font-size:12.5px;white-space:pre-wrap;border-left:2px solid var(--line);padding-left:10px;margin:6px 0 4px}
61
+ .picks{flex-direction:column;align-items:stretch}
62
+ .cand{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:8px 12px}
63
+ .candhead{display:flex;align-items:center;gap:10px;margin-bottom:4px}
64
+ .tileerr{margin-top:8px;font-size:12.5px;color:var(--red)}
65
+ label{font-size:12.5px;color:var(--dim);display:flex;gap:6px;align-items:center;cursor:pointer}
@@ -32,14 +32,7 @@ export function withRail(html: string, o: { tools: HubTool[]; active: string }):
32
32
  })
33
33
  .join('')
34
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>`
35
+ // needs projection server-side (cost + heal side-effects)
36
+ const dotScript = `<script>fetch('/api/needs.json').then(r=>r.json()).then(j=>{const n=(j.needs||[]).filter(x=>x.severity==='red').length;if(n>0)document.getElementById('skdot').style.display=''}).catch(()=>{})</script>`
37
37
  return html.replace(/<body([^>]*)>/, `<body$1><style>${RAIL_CSS}</style><nav class=skrail>${items}</nav>${dotScript}`)
38
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,20 +5,17 @@
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, readdirSync, readFileSync, statSync } from 'fs'
8
+ import { existsSync, readFileSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
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'
11
+ import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
15
12
  import { page as studioPage } from '../assets/server/studio-page.ts'
16
13
  import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.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'
14
+ import { projectNeeds } from '../collections/collection-types.ts'
15
+ import { pingTick } from '../collections/pings.ts'
16
+ import { buildCollections } from '../collections/providers.ts'
17
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
20
18
  import { scanMdGraph } from '../wiki/scan-md.ts'
21
- import type { AttentionSection } from './hub-pages.ts'
22
19
  import { serveHub, type HubDeps } from './serve-hub.ts'
23
20
 
24
21
  export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
@@ -59,7 +56,21 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
59
56
  agentGraph,
60
57
  studio,
61
58
  checkCmd: [process.execPath, cli, 'check'],
62
- attention: attentionCollector(root, c, kit),
59
+ needs: () => projectNeeds(buildCollections(root, c, kit, { bootVersion: BOOT_VERSION, libVersion })),
60
+ asksDir: c.asks?.dir ?? 'asks',
61
+ // RED-only pings while serving; notify command is repo config data.
62
+ // Delivery failure must be LOUD — a silently broken notify path means
63
+ // he assumes no blockers exist
64
+ pingTick: (needs) =>
65
+ pingTick(root, c.asks?.dir ?? 'asks', needs, {
66
+ run: (cmd) => {
67
+ const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
68
+ void p.exited.then(async (code) => {
69
+ if (code !== 0) io.printErr(`WARN ping delivery failed (exit ${code}): ${await new Response(p.stderr).text().then((s) => s.trim().slice(0, 160))}`)
70
+ })
71
+ },
72
+ notifyCommand: c.notifyCommand,
73
+ }),
63
74
  }
64
75
  }
65
76
 
@@ -72,79 +83,9 @@ const libVersion = (): string => {
72
83
  }
73
84
  const BOOT_VERSION = libVersion()
74
85
 
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
146
- }
147
- }
86
+ // the / dashboard's data now comes from the collection providers
87
+ // (src/collections/providers.ts) — the old attentionCollector was DELETED
88
+ // when they landed; never reintroduce a parallel projection.
148
89
 
149
90
  export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
150
91
  const c = await loadRepoConfig(root)
@@ -9,7 +9,9 @@ import { join } from 'path'
9
9
  import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
10
10
  import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
11
11
  import type { StudioRoutes } from '../assets/server/studio-server.ts'
12
- import { withRail, type AttentionSection } from './hub-pages.ts'
12
+ import { appendPostFeedback, askDone, askProof, markPosted, pickPostVariant } from '../collections/actions.ts'
13
+ import type { NeedItem } from '../collections/collection-types.ts'
14
+ import { withRail } from './hub-pages.ts'
13
15
  // the hub UI is a Bun-native React app (HTML import, no vite, no build) —
14
16
  // build/webapp doctrine; legacy pages (graphs, studio SPA) keep the
15
17
  // server-injected rail until they migrate
@@ -29,9 +31,13 @@ export type HubDeps = {
29
31
  studio: StudioRoutes | null
30
32
  /** argv shelling the check run streamed by /status/run (test seam) */
31
33
  checkCmd: string[]
32
- /** the / dashboard's data — everything red, gathered per request
33
- * (checks · asks · assets · contracts · posts; run-serve builds it) */
34
- attention: () => Promise<AttentionSection[]>
34
+ /** the / needs feed — every human-action item, red/yellow, gathered per
35
+ * request from the collection providers (run-serve builds it) */
36
+ needs: () => Promise<NeedItem[]>
37
+ /** asks dir (repo-root-relative) for the act-in-place ask verbs */
38
+ asksDir: string
39
+ /** RED-only ping tick, called on an interval while serving (null = off) */
40
+ pingTick?: ((needs: NeedItem[]) => void) | null
35
41
  }
36
42
  export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop: () => void }
37
43
 
@@ -62,9 +68,29 @@ export function serveHub(d: HubDeps): Hub {
62
68
  // identity probe: lets a second launch tell "same repo, another
63
69
  // window" from "a DIFFERENT repo hashed onto my port"
64
70
  if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, tools })
65
- if (path === '/api/attention.json') {
66
- const sections = await d.attention().catch((e: Error) => [{ title: 'attention collector broke', tone: 'red' as const, lines: [{ text: e.message }] }])
67
- return Response.json({ sections })
71
+ if (path === '/api/needs.json') {
72
+ const needs = await d.needs().catch((e: Error) => [{ collection: 'hub', id: 'hub:broken', text: `needs projection broke: ${e.message}`, severity: 'red' as const }])
73
+ return Response.json({ needs })
74
+ }
75
+ if (path.startsWith('/api/act/') && req.method === 'POST') {
76
+ // act-in-place verbs — every mutation hits a git-tracked truth
77
+ // file; errors surface as 400 text the tile shows verbatim
78
+ try {
79
+ const b = (await req.json()) as Record<string, string>
80
+ if (path === '/api/act/ask-done') askDone(d.root, d.asksDir, b.file ?? '')
81
+ else if (path === '/api/act/ask-proof') askProof(d.root, d.asksDir, b.file ?? '', b.url ?? '')
82
+ else if (path === '/api/act/post-pick') pickPostVariant(d.root, b.channel ?? '', b.file ?? '')
83
+ else if (path === '/api/act/post-posted') {
84
+ const rating = b.rating != null ? Number(b.rating) : undefined
85
+ if (rating != null && (!Number.isFinite(rating) || rating < 1 || rating > 5)) return new Response('rating must be 1-5', { status: 400 })
86
+ markPosted(d.root, b.channel ?? '', b.file ?? '', b.url ?? '', rating)
87
+ }
88
+ else if (path === '/api/act/post-feedback') appendPostFeedback(d.root, b.channel ?? '', b.file ?? '', b.text ?? '')
89
+ else return new Response('unknown act', { status: 404 })
90
+ return Response.json({ ok: true })
91
+ } catch (e) {
92
+ return new Response((e as Error).message, { status: 400 })
93
+ }
68
94
  }
69
95
  if (path === '/api/status.json') {
70
96
  const p = join(d.root, 'STATUS.md')
@@ -94,5 +120,14 @@ export function serveHub(d: HubDeps): Hub {
94
120
  return new Response('not found', { status: 404 })
95
121
  },
96
122
  })
97
- return { server, tools, stop: () => server.stop(true) }
123
+ // RED-only pings ride the serving process: one tick now (state file
124
+ // guarantees a restart never re-fires), then every minute
125
+ let pingTimer: ReturnType<typeof setInterval> | null = null
126
+ if (d.pingTick != null) {
127
+ // tick failures are LOUD (console = the serve terminal), never swallowed
128
+ const tick = (): void => void d.needs().then((n) => d.pingTick?.(n)).catch((e: Error) => console.error(`WARN ping tick broke: ${e.message}`))
129
+ tick()
130
+ pingTimer = setInterval(tick, 60_000)
131
+ }
132
+ return { server, tools, stop: () => { if (pingTimer != null) clearInterval(pingTimer); server.stop(true) } }
98
133
  }
package/src/shipkit.ts CHANGED
@@ -6,8 +6,11 @@ export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './re
6
6
  export { serveHub, hubTools } from './serve/serve-hub.ts'
7
7
  export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
8
8
  export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
9
- export { withRail, attentionRedCount } from './serve/hub-pages.ts'
10
- export type { AttentionSection, AttentionLine } from './serve/hub-pages.ts'
9
+ export { withRail } from './serve/hub-pages.ts'
10
+ export { projectNeeds, redCount } from './collections/collection-types.ts'
11
+ export type { Collection, Instance, NeedItem, NeedSeverity, CurationVerb } from './collections/collection-types.ts'
12
+ export { buildCollections } from './collections/providers.ts'
13
+ export { socialPostsCollection, listPosts } from './collections/posts.ts'
11
14
  export { discoverChecks } from './core/discover-checks.ts'
12
15
  export type { DiscoveredCheck } from './core/discover-checks.ts'
13
16
  export { runTasks, selectTasks } from './core/task-runner.ts'
package/src/start-repo.ts CHANGED
@@ -1,8 +1,9 @@
1
- // `shipkit start` — ensure <root>/<name> exists and, when --include is
2
- // given, that CLAUDE.md carries that include line. Plain module behind
3
- // cmd_Start. Status lines go to STDERR — the ONLY stdout output is the
4
- // resolved dir, so a sourced shell alias can `cd "$(shipkit start <name>)"`
5
- // (the one thing a child process can't do for its parent shell).
1
+ // `shipkit start` — ensure <root>/<name> exists and, when --include lines
2
+ // are given, that CLAUDE.md carries them (in order, at the top). Plain
3
+ // module behind cmd_Start. Status lines go to STDERR — the ONLY stdout
4
+ // output is the resolved dir, so a sourced shell alias can
5
+ // `cd "$(shipkit start <name>)"` (the one thing a child process can't do
6
+ // for its parent shell).
6
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
7
8
  import { homedir } from 'os'
8
9
  import { join } from 'path'
@@ -10,16 +11,14 @@ import { join } from 'path'
10
11
  export type StartOpts = {
11
12
  /** parent dir for repos — default ~/dev */
12
13
  root?: string
13
- /** line CLAUDE.md must start with; omitted = only ensure the dir */
14
- include?: string
15
- /** alternative lines that already count as wired — never double-add */
14
+ /** lines CLAUDE.md must carry, in order; omitted = only ensure the dir */
15
+ include?: string[]
16
+ /** alternative lines that satisfy the FIRST include — never double-add */
16
17
  alsoOk?: string[]
17
- /** repo names that never get the include */
18
+ /** repo names that never get includes */
18
19
  skip?: string[]
19
20
  }
20
21
 
21
- const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
22
-
23
22
  export function startRepo(name: string, opts: StartOpts = {}): number {
24
23
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
25
24
  console.error(`FAIL: bad repo name '${name}' — letters/digits/._- only, no leading dot, no slashes`)
@@ -30,18 +29,29 @@ export function startRepo(name: string, opts: StartOpts = {}): number {
30
29
  mkdirSync(dir, { recursive: true })
31
30
  console.error(`ok: created ${dir}`)
32
31
  }
33
- if (opts.include == null || (opts.skip ?? []).includes(name)) {
34
- if (opts.include != null) console.error(`skip: ${name} is include-exempt — CLAUDE.md untouched`)
32
+ const want = opts.include ?? []
33
+ if (want.length === 0 || (opts.skip ?? []).includes(name)) {
34
+ if (want.length > 0) console.error(`skip: ${name} is include-exempt — CLAUDE.md untouched`)
35
35
  } else {
36
36
  const claudeMd = join(dir, 'CLAUDE.md')
37
37
  if (!existsSync(claudeMd)) {
38
- writeFileSync(claudeMd, `${opts.include}\n`)
39
- console.error('ok: CLAUDE.md created with the include')
38
+ writeFileSync(claudeMd, `${want.join('\n')}\n`)
39
+ console.error(`ok: CLAUDE.md created with ${want.length} include line(s)`)
40
40
  } else {
41
- const wiredRe = new RegExp([opts.include, ...(opts.alsoOk ?? [])].map((l) => `^${escapeRe(l)}$`).join('|'), 'm')
42
- if (!wiredRe.test(readFileSync(claudeMd, 'utf8'))) {
43
- writeFileSync(claudeMd, `${opts.include}\n\n${readFileSync(claudeMd, 'utf8')}`)
44
- console.error('ok: include prepended to CLAUDE.md')
41
+ const cur = readFileSync(claudeMd, 'utf8')
42
+ const lineSet = new Set(cur.split('\n'))
43
+ const alt = opts.alsoOk ?? []
44
+ const satisfied = (w: string): boolean => lineSet.has(w) || (w === want[0] && alt.some((a) => lineSet.has(a)))
45
+ const missing = want.filter((w) => !satisfied(w))
46
+ if (missing.length > 0) {
47
+ // insert after any leading include/alt lines so order survives
48
+ const lines = cur.split('\n')
49
+ const known = new Set([...want, ...alt])
50
+ let at = 0
51
+ while (at < lines.length && known.has(lines[at]!)) at++
52
+ lines.splice(at, 0, ...missing)
53
+ writeFileSync(claudeMd, lines.join('\n'))
54
+ console.error(`ok: ${missing.length} missing include line(s) inserted into CLAUDE.md`)
45
55
  }
46
56
  }
47
57
  }
@@ -8,7 +8,7 @@
8
8
  // unreachable files stay in the graph with no depth (lint-md.ts turns those,
9
9
  // plus dangling refs, into failures — the graph itself is a viewer).
10
10
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
11
- import { dirname, join, relative, resolve } from 'path'
11
+ import { basename, dirname, join, relative, resolve } from 'path'
12
12
  import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
13
13
 
14
14
  export type ScanMdConfig = {
@@ -43,8 +43,13 @@ export function scanMd(cfg: ScanMdConfig): MdScan {
43
43
  files.sort()
44
44
  const inGraph = new Set(files)
45
45
 
46
+ // writers inside a scanned dir often name paths from the REPO root
47
+ // ("agent/architecture.md" while scanning agent/) — accept that form too
48
+ const rootBase = `${basename(cfg.root)}/`
46
49
  const resolveRef = (fromRel: string, token: string): string | null => {
47
- for (const cand of [resolve(join(cfg.root, dirname(fromRel)), token), resolve(cfg.root, token)]) {
50
+ const cands = [resolve(join(cfg.root, dirname(fromRel)), token), resolve(cfg.root, token)]
51
+ if (token.startsWith(rootBase)) cands.push(resolve(cfg.root, token.slice(rootBase.length)))
52
+ for (const cand of cands) {
48
53
  const rel = relative(cfg.root, cand)
49
54
  if (!rel.startsWith('..') && inGraph.has(rel)) return rel
50
55
  }