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 +53 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +34 -0
- package/src/cli/cmd_Needs.ts +47 -0
- package/src/cli/cmd_Start.ts +3 -3
- package/src/cli.ts +2 -0
- package/src/collections/actions.ts +66 -0
- package/src/collections/collection-types.ts +52 -0
- package/src/collections/pings.ts +68 -0
- package/src/collections/posts.ts +106 -0
- package/src/collections/providers.ts +162 -0
- package/src/repo-config.ts +7 -1
- package/src/serve/hub-app/hub-app.tsx +114 -22
- package/src/serve/hub-app/hub.css +24 -0
- package/src/serve/hub-pages.ts +2 -9
- package/src/serve/run-serve.ts +24 -83
- package/src/serve/serve-hub.ts +43 -8
- package/src/shipkit.ts +5 -2
- package/src/start-repo.ts +29 -19
- package/src/wiki/scan-md.ts +7 -2
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.
|
|
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",
|
package/src/asks/asks-queue.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -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
|
|
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.
|
|
22
|
-
alsoOk = Option.Array('--also-ok', { description: 'alternative lines
|
|
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
|
+
}
|
package/src/repo-config.ts
CHANGED
|
@@ -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
|
|
13
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
99
|
-
|
|
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=
|
|
102
|
-
<div className="
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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}
|
package/src/serve/hub-pages.ts
CHANGED
|
@@ -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
|
-
//
|
|
36
|
-
const dotScript = `<script>fetch('/api/
|
|
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)
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -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,
|
|
8
|
+
import { existsSync, readFileSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { fileURLToPath } from 'url'
|
|
11
|
-
import { loadKit,
|
|
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 {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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
|
-
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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)
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -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 {
|
|
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 /
|
|
33
|
-
*
|
|
34
|
-
|
|
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/
|
|
66
|
-
const
|
|
67
|
-
return Response.json({
|
|
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
|
-
|
|
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
|
|
10
|
-
export
|
|
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
|
|
2
|
-
// given, that CLAUDE.md carries
|
|
3
|
-
// cmd_Start. Status lines go to STDERR — the ONLY stdout
|
|
4
|
-
// resolved dir, so a sourced shell alias can
|
|
5
|
-
// (the one thing a child process can't do
|
|
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
|
-
/**
|
|
14
|
-
include?: string
|
|
15
|
-
/** alternative lines that
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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, `${
|
|
39
|
-
console.error(
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|
package/src/wiki/scan-md.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|