rvlib-shipkit 0.10.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +42 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Needs.ts +47 -0
  12. package/src/cli/cmd_Reflect.ts +116 -0
  13. package/src/cli/cmd_Rules.ts +32 -0
  14. package/src/cli/cmd_Serve.ts +1 -1
  15. package/src/cli.ts +7 -2
  16. package/src/collections/actions.ts +85 -0
  17. package/src/collections/collection-types.ts +65 -0
  18. package/src/collections/graphs.ts +34 -0
  19. package/src/collections/pings.ts +72 -0
  20. package/src/collections/posts.ts +125 -0
  21. package/src/collections/providers.ts +275 -0
  22. package/src/contracts/apply-contracts.ts +47 -0
  23. package/src/contracts/run-contracts.ts +8 -3
  24. package/src/core/canonical-scripts.ts +46 -0
  25. package/src/core/check-suite.ts +6 -3
  26. package/src/core/locked-write.ts +39 -0
  27. package/src/core/motherhome.ts +87 -0
  28. package/src/core/run-check.ts +28 -15
  29. package/src/core/run-ci.ts +4 -0
  30. package/src/core/write-status.ts +7 -26
  31. package/src/init-repo.ts +24 -11
  32. package/src/reflect/prompt-corpus.ts +305 -0
  33. package/src/repo-config.ts +26 -38
  34. package/src/rules/SK001-package-json-shape.ts +156 -0
  35. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  36. package/src/rules/SK003-claude-md-shape.ts +64 -0
  37. package/src/rules/SK004-gitignore-policy.ts +105 -0
  38. package/src/rules/SK005-rvlib-versions.ts +99 -0
  39. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  40. package/src/rules/SK007-github-visibility.ts +63 -0
  41. package/src/rules/rule-registry.ts +32 -0
  42. package/src/rules/rule.ts +62 -0
  43. package/src/rules/run-fix.ts +57 -0
  44. package/src/serve/hub-app/col-card.tsx +192 -0
  45. package/src/serve/hub-app/cols-svc.ts +136 -0
  46. package/src/serve/hub-app/geometry-view.tsx +155 -0
  47. package/src/serve/hub-app/health-svc.ts +49 -0
  48. package/src/serve/hub-app/hub-app.tsx +240 -127
  49. package/src/serve/hub-app/hub-st.ts +169 -0
  50. package/src/serve/hub-app/hub-types.ts +72 -0
  51. package/src/serve/hub-app/hub.css +286 -35
  52. package/src/serve/hub-app/index.html +1 -1
  53. package/src/serve/hub-app/md.ts +24 -0
  54. package/src/serve/hub-app/media-svc.ts +429 -0
  55. package/src/serve/hub-app/media-view.tsx +299 -0
  56. package/src/serve/run-serve.ts +46 -91
  57. package/src/serve/serve-hub.ts +99 -49
  58. package/src/shipkit.ts +13 -6
  59. package/src/assets/server/studio-page.ts +0 -687
  60. package/src/core/package-json-check.ts +0 -88
  61. package/src/serve/hub-pages.ts +0 -45
@@ -0,0 +1,116 @@
1
+ // shipkit reflect — the user's typed prompts across every Claude Code session
2
+ // of a repo, as a chronological corpus for retrospective extraction passes.
3
+ // REPORT-ONLY: exit 0 with prompts, 1 when a repo has no transcripts (loud,
4
+ // naming the config roots that were searched).
5
+ import { mkdirSync, writeFileSync } from 'fs'
6
+ import { basename, join, resolve } from 'path'
7
+ import { Command, Option } from 'clipanion'
8
+ import { type PromptEntry, collectRepoPrompts, corpusMarkdown, projectSlug } from '../reflect/prompt-corpus.ts'
9
+
10
+ const SHORT_MAX_LINES = 20
11
+
12
+ /** the --json unit: one per requested repo, failures carried in-band */
13
+ export type RepoDoc = { repo: string; prompts: PromptEntry[]; error?: string }
14
+
15
+ export class ReflectCommand extends Command {
16
+ static override paths = [['reflect']]
17
+
18
+ static override usage = Command.Usage({
19
+ category: 'Health',
20
+ description:
21
+ "the user's typed prompts across every Claude Code session of a repo — chronological md corpus for retrospectives",
22
+ examples: [
23
+ ['corpus for the current repo', 'shipkit reflect'],
24
+ ['corpus for other repos', 'shipkit reflect ~/dev/one-repo ~/dev/another-repo'],
25
+ ['drop the long pastes', 'shipkit reflect --short'],
26
+ ['batch to files instead of stdout', 'shipkit reflect ~/dev/a ~/dev/b --out stats'],
27
+ ['machine-readable', 'shipkit reflect --json'],
28
+ ],
29
+ })
30
+
31
+ repoPaths = Option.Rest()
32
+ json = Option.Boolean('--json', false, { description: 'emit [{repo, prompts, error?}] JSON — same shape for 1 or N repos' })
33
+ short = Option.Boolean('--short', false, { description: `only prompts of ${SHORT_MAX_LINES} lines or fewer` })
34
+ out = Option.String('--out', { description: 'write <repo>[.short].md (or .json) per repo into this dir instead of stdout' })
35
+
36
+ async execute(): Promise<number> {
37
+ const repos = this.repoPaths.length > 0 ? this.repoPaths : [process.cwd()]
38
+ if (this.out != null) mkdirSync(this.out, { recursive: true })
39
+ let failed = false
40
+ const jsonDocs: RepoDoc[] = []
41
+ for (const repo of repos) {
42
+ const { prompts, dirs, roots } = collectRepoPrompts(repo)
43
+ const kept = this.short ? prompts.filter((p) => p.text.split('\n').length <= SHORT_MAX_LINES) : prompts
44
+ if (kept.length === 0) {
45
+ const error = this.fail(repo, prompts.length, roots)
46
+ failed = true
47
+ if (this.json) jsonDocs.push({ repo: resolve(repo), prompts: [], error })
48
+ continue
49
+ }
50
+ if (this.json) jsonDocs.push({ repo: resolve(repo), prompts: kept })
51
+ else this.emitMarkdown(repo, kept, dirs)
52
+ }
53
+ if (this.json) this.emitJson(jsonDocs)
54
+ return failed ? 1 : 0
55
+ }
56
+
57
+ /** one FAIL line per repo, and a fix that is not "do what you just did".
58
+ * Returns the same reason for the --json payload: a consumer must not have
59
+ * to diff stderr against stdout to learn a repo produced nothing. */
60
+ private fail(repo: string, total: number, roots: string[]): string {
61
+ const e = this.context.stderr
62
+ if (this.short && total > 0) {
63
+ const reason = `${total} prompts, none of ${SHORT_MAX_LINES} lines or fewer`
64
+ e.write(`FAIL: ${resolve(repo)}: ${reason}\n`)
65
+ e.write(` fix: drop --short\n`)
66
+ return reason
67
+ }
68
+ e.write(`FAIL: no typed prompts for ${resolve(repo)}\n`)
69
+ e.write(` looked for projects/${projectSlug(repo)}* under: ${roots.join(' · ')}\n`)
70
+ e.write(
71
+ this.repoPaths.length === 0
72
+ ? ` fix: this repo has no Claude Code sessions — name another, shipkit reflect ~/dev/<repo>\n`
73
+ : ` fix: check the path (it is slugged verbatim) — a session dir appears only after a session ran there\n`,
74
+ )
75
+ return 'no typed prompts'
76
+ }
77
+
78
+ /** ONE shape whatever the run looked like: RepoDoc[], failures included with
79
+ * their reason. A data-dependent schema breaks every consumer eventually. */
80
+ private emitJson(docs: RepoDoc[]): void {
81
+ if (this.out == null) {
82
+ this.context.stdout.write(`${JSON.stringify(docs, null, 2)}\n`)
83
+ return
84
+ }
85
+ for (const d of docs) {
86
+ if (d.error != null) continue
87
+ const file = this.outFile(d.repo, '.json')
88
+ writeFileSync(file, `${JSON.stringify(d, null, 2)}\n`)
89
+ this.context.stdout.write(`ok: ${this.counts(d.prompts)} → ${file}\n`)
90
+ }
91
+ }
92
+
93
+ /** queued prompts are the ones typed mid-run: say how many, so a future hole
94
+ * in that path is visible instead of silent */
95
+ private counts(prompts: PromptEntry[]): string {
96
+ const queued = prompts.filter((p) => p.origin === 'queued').length
97
+ const base = `${String(prompts.length).padStart(4)} prompts`
98
+ return queued === 0 ? base : `${base} (${queued} queued mid-run)`
99
+ }
100
+
101
+ private emitMarkdown(repo: string, prompts: PromptEntry[], dirs: string[]): void {
102
+ const md = corpusMarkdown(repo, prompts)
103
+ if (this.out == null) {
104
+ this.context.stdout.write(md)
105
+ return
106
+ }
107
+ const file = this.outFile(repo, '.md')
108
+ writeFileSync(file, md)
109
+ this.context.stdout.write(`ok: ${this.counts(prompts)} → ${file} (${dirs.length} session dir(s))\n`)
110
+ }
111
+
112
+ /** --short writes its OWN file: the filtered corpus must never overwrite the full one */
113
+ private outFile(repo: string, ext: string): string {
114
+ return join(this.out ?? '.', `${basename(resolve(repo))}${this.short ? '.short' : ''}${ext}`)
115
+ }
116
+ }
@@ -0,0 +1,32 @@
1
+ // shipkit rules — render the SK rule registry. Agents read ids HERE (or
2
+ // --json), never guess or mint one: the registry is the single source.
3
+ import { Command, Option } from 'clipanion'
4
+ import { RULES } from '../rules/rule-registry.ts'
5
+
6
+ export class RulesCommand extends Command {
7
+ static override paths = [['rules']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Health',
11
+ description: 'list every built-in SK rule: id, what it lints, owning doc, whether check --fix can repair it',
12
+ examples: [
13
+ ['list rules', 'shipkit rules'],
14
+ ['machine-readable', 'shipkit rules --json'],
15
+ ],
16
+ })
17
+
18
+ json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, fixable}]' })
19
+
20
+ async execute(): Promise<number> {
21
+ const rows = RULES.map((r) => ({ id: r.id, title: r.title, owner: r.owner, fixable: r.fix != null }))
22
+ if (this.json) {
23
+ this.context.stdout.write(`${JSON.stringify(rows)}\n`)
24
+ return 0
25
+ }
26
+ // a catalog, not a health run — no wire prefixes (`ok:` would assert
27
+ // a passing check into anything that parses wire output)
28
+ for (const r of rows)
29
+ this.context.stdout.write(`${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
30
+ return 0
31
+ }
32
+ }
@@ -8,7 +8,7 @@ export class ServeCommand extends Command {
8
8
 
9
9
  static override usage = Command.Usage({
10
10
  category: 'Serve',
11
- description: 'ONE hub server per repo: landing page linking the wiki/agent graphs, the assets studio, and live status',
11
+ description: 'ONE server, ONE page: media studio + asks + creds + posts + health + graphs, with drill-down graph pages',
12
12
  examples: [
13
13
  ['serve the repo toolbox', 'shipkit serve'],
14
14
  ['pin the port for this launch', 'shipkit serve --port 4804'],
package/src/cli.ts CHANGED
@@ -5,12 +5,15 @@
5
5
  import { readFileSync } from 'fs'
6
6
  import { Builtins, Cli } from 'clipanion'
7
7
  import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
- import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand, AssetsServeCommand } from './cli/cmd_Assets.ts'
8
+ import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
9
9
  import { CheckCommand } from './cli/cmd_Check.ts'
10
10
  import { CiCommand } from './cli/cmd_Ci.ts'
11
11
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
12
12
  import { InitCommand } from './cli/cmd_Init.ts'
13
13
  import { MvCommand } from './cli/cmd_Mv.ts'
14
+ import { NeedsCommand } from './cli/cmd_Needs.ts'
15
+ import { ReflectCommand } from './cli/cmd_Reflect.ts'
16
+ import { RulesCommand } from './cli/cmd_Rules.ts'
14
17
  import { ServeCommand } from './cli/cmd_Serve.ts'
15
18
  import { StartCommand } from './cli/cmd_Start.ts'
16
19
  import { TuiCommand } from './cli/cmd_Tui.ts'
@@ -39,7 +42,6 @@ cli.register(AsksCheckCommand)
39
42
  cli.register(AskNewCommand)
40
43
  cli.register(WikiServeCommand)
41
44
  cli.register(WikiLintCommand)
42
- cli.register(AssetsServeCommand)
43
45
  cli.register(AssetsFillCommand)
44
46
  cli.register(AssetsAddCommand)
45
47
  cli.register(AssetsGenCommand)
@@ -47,5 +49,8 @@ cli.register(AssetsIdentityCommand)
47
49
  cli.register(AssetsCheckCommand)
48
50
  cli.register(InitCommand)
49
51
  cli.register(StartCommand)
52
+ cli.register(NeedsCommand)
53
+ cli.register(ReflectCommand)
54
+ cli.register(RulesCommand)
50
55
 
51
56
  cli.runExit(process.argv.slice(2))
@@ -0,0 +1,85 @@
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
+ /** his asset remark → `.rv-assets/notes.md` (git-tracked; the studio
59
+ * workspace is gitignored, so notes live beside kit.ts) */
60
+ export function appendAssetNote(root: string, assetId: string, text: string): void {
61
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(assetId)) throw new Error('bad asset id')
62
+ const p = join(root, '.rv-assets', 'notes.md')
63
+ const line = `- ${today()} [${assetId}]: ${text.replace(/\n+/g, ' ').trim()}`
64
+ writeFileSync(p, existsSync(p) ? `${readFileSync(p, 'utf8').trimEnd()}\n${line}\n` : `# asset notes — his remarks, read by agents at boot\n\n${line}\n`)
65
+ }
66
+
67
+ /** his remark on an ask → appended to the ask file, dated, non-destructive.
68
+ * Accepts exactly one optional `done/` prefix (archived asks stay
69
+ * annotatable); any other slash or traversal is a bad path. */
70
+ export function appendAskRemark(root: string, asksDir: string, file: string, text: string): void {
71
+ if (!/^(done\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(file) || file.includes('..')) throw new Error('bad path')
72
+ const p = join(root, asksDir, file)
73
+ if (!existsSync(p)) throw new Error(`no such ask: ${file}`)
74
+ writeFileSync(p, `${readFileSync(p, 'utf8').trimEnd()}\n- ${today()}: ${text.replace(/\n+/g, ' ').trim()}\n`)
75
+ }
76
+
77
+ export function askDone(root: string, asksDir: string, file: string): void {
78
+ if (file.includes('/') || file.includes('..')) throw new Error('bad path')
79
+ archiveAsk(join(root, asksDir), file)
80
+ }
81
+
82
+ export function askProof(root: string, asksDir: string, file: string, url: string): void {
83
+ if (file.includes('/') || file.includes('..')) throw new Error('bad path')
84
+ proofAsk(join(root, asksDir), file, url)
85
+ }
@@ -0,0 +1,65 @@
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
+ /** TRUST: the git file this instance IS — shown on the tile; the file
27
+ * always wins over the page */
28
+ file?: string
29
+ /** thumbnail URL for media tiles */
30
+ img?: string
31
+ /** his latest remarks from the item's own file — agents boot on these */
32
+ remarks?: string[]
33
+ /** the truth file's markdown BODY (frontmatter stripped) — clamped card
34
+ * in the grid, full in the item view; capped server-side, never a dump */
35
+ md?: string
36
+ /** an open ask owns the action (dedup = acted-on once, never hidden):
37
+ * the instance stays red HERE but verbs and pings live with the ask */
38
+ askOwned?: boolean
39
+ }
40
+
41
+ export type Collection = {
42
+ id: string
43
+ title: string
44
+ media: 'image' | 'audio' | 'text' | 'data' | 'pointer' | 'health'
45
+ /** wire tone when any needing instance is only yellow */
46
+ list: () => Promise<Instance[]>
47
+ }
48
+
49
+ export type NeedItem = Instance & { collection: string }
50
+
51
+ /** the flat needs projection — failure-isolated per collection: a broken
52
+ * source reports itself as ONE red item, never blanks the feed */
53
+ export async function projectNeeds(collections: Collection[]): Promise<NeedItem[]> {
54
+ const out: NeedItem[] = []
55
+ for (const c of collections) {
56
+ try {
57
+ for (const i of await c.list()) if (i.severity != null) out.push({ ...i, collection: c.id })
58
+ } catch (e) {
59
+ out.push({ collection: c.id, id: `${c.id}:broken`, text: `collector broke: ${(e as Error).message}`, severity: 'red' })
60
+ }
61
+ }
62
+ return out
63
+ }
64
+
65
+ export const redCount = (needs: NeedItem[]): number => needs.filter((n) => n.severity === 'red').length
@@ -0,0 +1,34 @@
1
+ // graphs collection — the mounted md graphs as CHECKED section entries:
2
+ // each graph instance carries its lint state (orphans + dangling refs =
3
+ // yellow with counts; clean = quiet). Custom wiki-graph providers (repos
4
+ // with their own loader + their own lint) get a link-only instance — their
5
+ // repo lint owns correctness, never double-judged here.
6
+ import { existsSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { lintMd } from '../wiki/lint-md.ts'
9
+ import type { Collection, Instance } from './collection-types.ts'
10
+
11
+ export function graphsCollection(root: string, o: { customWiki: boolean; agentIndex?: string }): Collection {
12
+ return {
13
+ id: 'graphs',
14
+ title: 'graphs',
15
+ media: 'data',
16
+ list: async () => {
17
+ const out: Instance[] = []
18
+ const checked = (id: string, title: string, href: string, dir: string, index?: string): Instance => {
19
+ const r = lintMd({ root: dir, index })
20
+ const issues = r.orphans.length + r.dangling.length
21
+ return {
22
+ id,
23
+ text: issues === 0 ? title : `${title} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
24
+ severity: issues === 0 ? undefined : 'yellow',
25
+ href,
26
+ }
27
+ }
28
+ if (o.customWiki) out.push({ id: 'graph:wiki', text: 'wiki graph (repo-owned lint)', href: '/graph/wiki' })
29
+ else out.push(checked('graph:wiki', 'wiki graph', '/graph/wiki', root))
30
+ if (existsSync(join(root, 'agent'))) out.push(checked('graph:agent', 'agent graph', '/graph/agent', join(root, 'agent'), o.agentIndex))
31
+ return out
32
+ },
33
+ }
34
+ }
@@ -0,0 +1,72 @@
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
+ /** hub URL appended to the message — the OFF-MACHINE form
21
+ * (serve.publicHost); a phone can't resolve localhost */
22
+ link?: string
23
+ }
24
+
25
+ const stateDir = (root: string): string | null => (existsSync(join(root, 'node_modules')) ? join(root, 'node_modules', '.cache', 'shipkit') : null)
26
+
27
+ const loadPinged = (root: string): Set<string> | null => {
28
+ const dir = stateDir(root)
29
+ if (dir == null) return null
30
+ try {
31
+ return new Set(JSON.parse(readFileSync(join(dir, 'pinged.json'), 'utf8')) as string[])
32
+ } catch {
33
+ return new Set()
34
+ }
35
+ }
36
+
37
+ const savePinged = (root: string, ids: Set<string>): void => {
38
+ const dir = stateDir(root)
39
+ if (dir == null) return
40
+ mkdirSync(dir, { recursive: true })
41
+ writeFileSync(join(dir, 'pinged.json'), JSON.stringify([...ids].sort()))
42
+ }
43
+
44
+ /** one tick: ping ONCE for the batch of never-pinged red items, mark them.
45
+ * Returns the pinged ids (empty = silence). */
46
+ export function pingTick(root: string, asksDir: string, needs: NeedItem[], io: PingIo): string[] {
47
+ const pinged = loadPinged(root)
48
+ if (pinged == null) return [] // no persistence, no pings — never re-fire a backlog
49
+ const fresh: NeedItem[] = []
50
+ for (const n of needs) {
51
+ if (n.severity !== 'red') continue
52
+ if (n.askOwned === true) continue // the covering ask's pinged: field already fired the once-ever ping
53
+ if (n.collection === 'asks') {
54
+ // ask file owns the once-ever marker (shared with agent pings)
55
+ const file = n.id.replace(/^ask:/, '')
56
+ if (markPinged(join(root, asksDir), file)) fresh.push(n)
57
+ } else if (!pinged.has(n.id)) {
58
+ pinged.add(n.id)
59
+ fresh.push(n)
60
+ }
61
+ }
62
+ savePinged(root, pinged)
63
+ if (fresh.length === 0) return []
64
+ const head = fresh.slice(0, 3).map((n) => n.text.slice(0, 80)).join(' · ')
65
+ const message = `${fresh.length} new item(s) need you: ${head}${fresh.length > 3 ? ' · …' : ''}${io.link != null ? ` → ${io.link}` : ''}`
66
+ // item texts come from repo data — strip shell-active chars before the template substitution
67
+ const safe = message.replace(/[`$\\'"]/g, '')
68
+ if (io.notifyCommand != null) io.run(['sh', '-c', io.notifyCommand.replaceAll('{message}', safe)])
69
+ if (io.osascript !== false && process.platform === 'darwin')
70
+ io.run(['osascript', '-e', `display notification ${JSON.stringify(message)} with title "shipkit needs"`])
71
+ return fresh.map((n) => n.id)
72
+ }
@@ -0,0 +1,125 @@
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
+ const feedback = (v: PostFile): string[] | undefined => {
70
+ const lines = v.body.split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2}: /.test(l))
71
+ return lines.length > 0 ? lines.slice(-3) : undefined
72
+ }
73
+ for (const [key, variants] of bySlug) {
74
+ const fileOf = (v: PostFile): string => `.rv-social/${v.channel}/${v.file}`
75
+ const missing = variants.filter((v) => v.status == null)
76
+ if (missing.length > 0) {
77
+ out.push({
78
+ id: `post:${key}:invalid`,
79
+ text: `${key} — missing/invalid status: frontmatter (${missing.map((v) => v.file).join(', ')})`,
80
+ severity: 'red',
81
+ file: fileOf(missing[0]!),
82
+ })
83
+ continue
84
+ }
85
+ const picked = variants.find((v) => v.status === 'picked')
86
+ if (picked != null) {
87
+ out.push({
88
+ id: `post:${key}:topost`,
89
+ text: `${key} — picked, waiting to be POSTED (copy → post → paste the URL)`,
90
+ severity: 'red',
91
+ artifact: { copy: postSection(picked.body) },
92
+ verbs: ['paste-proof', 'rate'],
93
+ candidates: [{ id: picked.file, label: picked.variant ?? 'post' }],
94
+ file: fileOf(picked),
95
+ remarks: feedback(picked),
96
+ })
97
+ continue
98
+ }
99
+ const drafts = variants.filter((v) => v.status === 'draft')
100
+ if (drafts.length > 0) {
101
+ out.push({
102
+ id: `post:${key}:pick`,
103
+ text: `${key} — ${drafts.length} draft(s), pick one`,
104
+ severity: 'yellow',
105
+ verbs: ['pick', 'rate'],
106
+ candidates: drafts.map((v) => ({ id: v.file, label: v.variant ?? 'draft', hint: postSection(v.body).slice(0, 200) })),
107
+ file: fileOf(drafts[0]!),
108
+ remarks: feedback(drafts[0]!),
109
+ })
110
+ continue
111
+ }
112
+ // posted / rejected history: VISIBLE (browse-all), never in needs
113
+ const posted = variants.find((v) => v.status === 'posted')
114
+ const shown = posted ?? variants[0]!
115
+ out.push({
116
+ id: `post:${key}:${posted != null ? 'posted' : 'rejected'}`,
117
+ text: `${key} — ${posted != null ? `posted${posted.posted != null ? ` (${posted.posted})` : ''}` : 'rejected'}`,
118
+ file: fileOf(shown),
119
+ remarks: feedback(shown),
120
+ })
121
+ }
122
+ return out
123
+ },
124
+ }
125
+ }