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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,209 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 — 2026-07-26
4
+
5
+ Stable rule ids. Every built-in invariant `shipkit check` enforces is now
6
+ an SK rule — one module per rule (`src/rules/SK###-<slug>.ts`), one
7
+ registry (`rule-registry.ts`, throws at import on a duplicate or malformed
8
+ id), ids never reused or renumbered. The two existing built-ins migrated
9
+ whole: SK001 package.json shape (was `core/package-json-check.ts`, deleted),
10
+ SK002 `.rv-*` contracts fulfillness. Line text changed on every consumer:
11
+ rule-emitted `ok:`/`WARN`/`FAIL:`/`skip:` lines now carry a `[SK###]` tag
12
+ after the wire prefix — on `check`, `shipkit contracts`, AND the ci
13
+ `contracts` task (one tagged rendering path,
14
+ `contracts/apply-contracts.ts`; `contractsConfigFrom`/`activeContracts`/
15
+ `applyContractsResult` moved there, still exported from the barrel). A
16
+ failing/warning rule always carries at least one `fix:` pointer (the
17
+ framework appends `docsBase + rule.owner` when the lint attached none).
18
+ New command: `shipkit rules [--json]` — the id catalog (plain lines, not
19
+ wire). Wire prefixes themselves are unchanged; matchers keying on prefixes
20
+ are unaffected.
21
+
22
+ `shipkit check --fix [SK### …]`: rules may carry an idempotent `fix()` —
23
+ apply, re-lint, show the real end state; never cached, never under ci;
24
+ unknown/unfixable ids fail loud. First fixers: SK001 (private:true, license
25
+ removal, canonical scripts from the ONE shared module
26
+ `core/canonical-scripts.ts` that `shipkit init` also scaffolds from —
27
+ bodies the vocabulary page doesn't state are named in the summary, never
28
+ invented; rvlib-* roots refused), SK003 CLAUDE.md include-index (new rule:
29
+ prose/extra lines/inverted order/untracked-CLAUDE.local.md FAIL; fix routes
30
+ a missing `@./agent/index.md` on an EXISTING file, never creates one),
31
+ SK004 `.gitignore` policy block (new rule: marker-keyed managed block of
32
+ the always-gitignored contract surfaces + tmp/; a well-formed block is
33
+ rewritten IN PLACE so below-block `!`-overrides keep winning; CRLF files
34
+ keep their EOL; repo lines outside the block never touched;
35
+ `defineRepo({ gitignore: [...] })` contributes repo lines INSIDE the
36
+ block, for names a published lib may not carry; `shipkit init` — fresh or
37
+ re-run on an existing repo — writes it through the same fixer). A failing
38
+ rule's fix pointers advertise `autofix: shipkit check --fix SK###`; the
39
+ post-fix re-lint strips the advertisement and an unchanged fix reports as
40
+ a note, not a WARN; `--fix` rejects `--json` and closes with a count of
41
+ rules no fixer covers. SK001's fixer refuses field flips on anything
42
+ publish-marked (files/exports/publishConfig/prepublishOnly) and never
43
+ duplicates a script a banned alias already satisfies.
44
+
45
+ SK005 rvlib versions: every `rvlib-*` dep is compared (real semver order,
46
+ so a dist-tag pointing BEHIND the installed version is reported, never a
47
+ silent downgrade) to npm latest on each `shipkit check` (rules may now
48
+ declare `ttl` — SK005 caches clean passes 24h; registry unreachable = loud
49
+ skip, never red; a `link:` dep that does not resolve is a FAIL instead of
50
+ a cryptic import crash). When a newer version is live the rule
51
+ SELF-UPDATES: `bun update --latest`, run by the lint itself, only when
52
+ package.json/lockfile are git-clean (else WARN deferred), never under ci;
53
+ the applied update itself reports as WARN — the repo was mutated and a
54
+ restart+commit is owed, so it lands on the attention surface and is never
55
+ cached. New `shipkit check --frozen` (or standing `SHIPKIT_FROZEN=1`)
56
+ reports drift without touching anything; the flag threads through
57
+ `CheckOpts` so doctor/TUI/hub callers can pass it too. Committing the bump
58
+ stays with the working agent — shipkit never commits. Suite-level fix
59
+ riding along: a TTL'd check that emitted a `skip` line is no longer
60
+ cached — "only clean passes replay" is now literally true (a dead
61
+ registry was being restated as fact for 24h).
62
+
63
+ SK006 npm publish safety: a `private: true` package carrying publishConfig
64
+ / prepublish* scripts / an npm-publishing `publish` script FAILs — the
65
+ machinery is dead weight while private holds and a loaded gun the day it
66
+ flips; rvlib-* roots exempt; lint-only on purpose (a `publish` script may
67
+ deploy a site). SK007 github visibility: new repo.config
68
+ `visibility: 'private' | 'public'` (absent = private, defineRepo validates)
69
+ vs `gh repo view --json isPrivate` — mismatch FAILs in BOTH directions
70
+ (public-while-declared-private is the leak case), 7d clean-pass cache,
71
+ every unverifiable path (no gh, no remote, gh error) a loud skip that is
72
+ never red and never cached.
73
+
74
+ Motherhome reporting: `check`/`ci` (and `init` on an already-configured
75
+ repo) upsert one row for the repo — name, path, version (`+src` suffix
76
+ when consumed by path/link), npm|link|file|path mode, last-seen day —
77
+ into `<docsBase>/build/shipkit/local-projects.md`, ONLY when that page
78
+ already exists on this disk. The lib never creates the page and never
79
+ writes into a relative or URL docsBase — a consumer using docsBase as a
80
+ plain fix-pointer prefix will never find surprise files in its docs tree.
81
+ Whole read-modify-write under a file lock with atomic rename
82
+ (core/locked-write, extracted from write-status — one lock concern),
83
+ dead-path rows pruned, data-change-or->7d churn control, every failure a
84
+ WARN note that never breaks the calling command.
85
+
86
+ ## 0.12.0 — 2026-07-23
87
+
88
+ ONE server, ONE page. `shipkit serve` runs the repo's single `Bun.serve`,
89
+ and `/` is the single surface: a mobx+react app (Bun HTML import, no build
90
+ step) holding the full media atelier (rolls, compare, pick, geometry
91
+ overlay, custom panels, uploads, generate queue) plus foldable sections
92
+ for asks, creds, posts, health (STATUS.md + streaming check runner) and
93
+ graphs (each lint-checked), with a segmented severity filter (all · needs ·
94
+ red) at `/?f=needs` / `/?f=red` (legacy `/?needs=1` reads as needs) and
95
+ hash deep-links (`#<asset-id>` / `#cat-<Category>`).
96
+ Kit-less repos serve the same page with no media section. Graph canvases
97
+ stay as drill-down pages (`/graph/wiki`, `/graph/agent`) with a home link.
98
+
99
+ New:
100
+
101
+ - Navigation on the one page: a thin icon RAIL that scrolls/unfolds to a
102
+ section (red dot when it holds a red item — never a separate page), a
103
+ sidebar SEARCH filtering rows and grid cards, a `cmd+j` palette jumping
104
+ to any item or section.
105
+ - Instances carry `md` (truth-file markdown body, frontmatter stripped,
106
+ 4000-char cap): cards render it via marked (GFM — tables render) behind
107
+ a sanitize-html allowlist — clamped in the grid, full in the item view.
108
+ - Secret cards presented properly: mono name, service line,
109
+ present/missing chip, mint link; copy block only when missing.
110
+ - Roll strips: newest first with a stable tiebreak; the generating
111
+ spinner tile sits at the START of the strip.
112
+ - Every section has BOTH views: title → collection view (one card per
113
+ instance in the stage), row/card → item view with a back link. A graph's
114
+ item view EMBEDS the graph (iframe of /graph/*), never a bare link.
115
+ - Sections show everything, always: asks include the done/ archive
116
+ (quiet, remarkable — consult past answers), the secrets section lists
117
+ every repo-declared cred (present = quiet, missing = red with the mint
118
+ artifact); section headers render even at 0 items. "needs" is THE
119
+ waiting-on-me filter, "red" the hard-blocked cut.
120
+ - Watch mode is the house default for the hub script
121
+ (`bun --watch … serve`, `shipkit init` scaffolds it): lib/config edits
122
+ restart the server live. Accepted tradeoff: a restart drops in-memory
123
+ queue state (`assets fill` recovers).
124
+ - `serve.publicHost` (repo.config): off-machine hostname printed beside
125
+ localhost and appended to ping messages — a phone can't resolve
126
+ localhost.
127
+ - `/api/collections.json`: browse-all — every instance of every
128
+ collection, healthy included (quiet tiles), rendered as foldable
129
+ sections with red/yellow dots.
130
+ - Trust surface: instances carry `file` (the git file the tile IS — shown
131
+ on the card; the file always wins) and `img` (thumbnails via the media
132
+ API).
133
+ - Remarks: cards AND the asset view have a remark box; `/api/act/remark`
134
+ routes the text into the item's own file (post `## Feedback`, the ask
135
+ file, or `.rv-assets/notes.md` for media) and it returns as `remarks`
136
+ on the instance — `shipkit needs --json` briefs agents on them at boot.
137
+ - Posted/rejected post history is VISIBLE (quiet), no longer hidden;
138
+ media collections list ALL kit assets, not only needing ones.
139
+ - `shipkit reflect [repo…] [--short --out <dir> --json]`: your prompts
140
+ across every Claude Code session of a repo, chronological md corpus for a
141
+ retrospective. Corpus fidelity is the whole feature — it unions EVERY
142
+ config root on the machine (`$CLAUDE_CONFIG_DIR` plus `~/.claude` plus
143
+ sibling `~/.claude-<account>`; honoring only the env var hides the account
144
+ you are not currently running as), scans worktree session dirs, includes
145
+ the prompts you QUEUED while the agent was working (`queue-operation`
146
+ entries, marked `origin: 'queued'` — they carry mid-run corrections and
147
+ standing instructions, and when a run ends first they never become user
148
+ messages at all), drops harness-authored user entries (tool results,
149
+ command wrappers, compaction summaries, subagent prompts, SDK-driven
150
+ sessions), rescues the text you typed inside a slash command, strips
151
+ reminder blocks appended inside real prompts, recovers pruned sessions
152
+ from `sessions-index.json`, and removes only MECHANICAL copies — a
153
+ replayed entry, or a queued prompt arriving again as a message — so real
154
+ repetition (saying it twice) survives in the corpus. `--json` emits one
155
+ shape whatever the run looked like: `[{repo, prompts, error?}]`.
156
+
157
+ BREAKING (0.11.0 → 0.12.0):
158
+
159
+ - `shipkit assets serve` DELETED — the studio UI rides the hub page; the
160
+ media API mounts at `/media/*` (was `/assets/*` under the hub).
161
+ - `serveStudio`, `Studio`, `studioPage`, `withRail` exports DELETED
162
+ (`studioRoutes` stays — it IS the media API).
163
+ - `kit.port` DELETED from `defineKit` (nothing standalone listens anymore).
164
+ - `/status` and `/assets/` pages DELETED (404, no redirect); the check
165
+ stream moved `/status/run` → `/api/check/run`; the vanilla
166
+ template-string studio SPA and the injected rail are gone — the react
167
+ app is the only UI code path.
168
+
169
+ ## 0.11.0 — 2026-07-23
170
+
171
+ The needs feed: the hub's `/` now answers "what needs a human right now,
172
+ artifact in hand".
173
+
174
+ New:
175
+
176
+ - Collection model (`src/collections/`): every curatable surface projects
177
+ through one interface — media kinds, asks, missing declared secrets
178
+ (mint artifact + `credsCommand` store template as config data), repo
179
+ health, social posts. `projectNeeds` is failure-isolated per collection.
180
+ - `shipkit needs [--json]`: the feed as a CLI — red/yellow by collection,
181
+ REPORT-ONLY (exit 0 always; `check`/`ci` stay the gates).
182
+ - Hub `/` = the needs feed: act-in-place tiles (copy buttons for briefs/
183
+ commands/post text, mint links, done + proof-URL inputs, draft pick
184
+ buttons), reds-only toggle, `/api/needs.json` + `/api/act/*` (every
185
+ mutation hits a git-tracked truth file).
186
+ - RED-only pings while the hub serves: one batched notification per tick
187
+ of NEW red items (`notifyCommand` template + macOS notification).
188
+ Once-ever per item: cache file for non-ask items, the ask file's own
189
+ `pinged:` field for asks. Yellow never pings.
190
+ - Social posts contract v2: slug-only files, variants (`<slug>.a.md`) as
191
+ drafts-as-rolls (pick flips siblings to rejected, files kept),
192
+ frontmatter kind/tone/status/posted/rating, `## Angle` / `## Post` /
193
+ `## Feedback` sections — the copy button pastes ONLY `## Post`;
194
+ feedback appends are non-destructive.
195
+ - Missing manual media tiles carry their brief as the copy artifact.
196
+
197
+ Breaking (0.10.x consumers):
198
+
199
+ - `/api/attention.json` → `/api/needs.json` (NeedItem[] — flat, severity
200
+ red|yellow); `attentionRedCount`/`AttentionSection`/`AttentionLine`
201
+ exports removed (use `redCount`/`NeedItem`/`Instance`). Unpicked-roll
202
+ and stale assets are now YELLOW (taste pending — a default ships), no
203
+ longer red.
204
+ - `.rv-social` post files: `status:` frontmatter is required (missing =
205
+ a red feed item); v1 vocab maps idea→draft, ready→picked, link→posted.
206
+
3
207
  ## 0.10.0 — 2026-07-22
4
208
 
5
209
  New:
package/README.md CHANGED
@@ -34,16 +34,30 @@ shipkit ci commit gate: floor (lint/typecheck/test/imports/cycles —
34
34
  + your tasks, parallel DAG; refreshes STATUS.md
35
35
  [--bail --skip g --only id --json]
36
36
  shipkit contracts release-contract fulfillness alone [--venue v --json]
37
- shipkit check periodic health: every *.check.ts in the repo,
38
- auto-discovered + TTL-cached [--json --fresh]
37
+ shipkit check periodic health: built-in SK rules + every *.check.ts in
38
+ the repo, auto-discovered + TTL-cached
39
+ [--json --fresh --frozen --fix [SK### …]]
40
+ --fix applies rule autofixes then re-lints; --frozen =
41
+ self-maintaining rules (rvlib self-update) report only
42
+ shipkit rules the SK rule catalog: id, what it lints, owning doc,
43
+ fixable? [--json]
39
44
  shipkit tui live dashboard: ci tasks + discovered checks + panels
40
45
  the repo injects via repo.config tui.panels
41
46
  shipkit asks human action queue: list · ask "<title>" · asks check (auto-close)
47
+ shipkit needs everything blocked on a human, red/yellow by collection
48
+ report-only, exit 0 always [--json]
42
49
  shipkit wiki serve|lint — live md link-graph + orphan/dangling-ref lint
50
+ shipkit mv rename an md page + its sibling dir, sweeping every ref
43
51
  shipkit assets serve|fill|add|gen|identity|check — the asset atelier
44
52
  (declare kinds in .rv-assets/kit.ts, roll, curate, ship)
53
+ shipkit serve the repo's ONE server: hub page, graphs, studio, status
54
+ shipkit reflect your prompts across every Claude Code session of a repo:
55
+ all config roots, queued-mid-run ones included, harness
56
+ noise out, chronological md corpus for a retrospective
57
+ [repo…] [--short --out <dir> --json]
45
58
  shipkit init scaffold config + scripts, print the resolved floor
46
59
  [--bin <cli.ts path> for in-repo-source consumers]
60
+ shipkit start ensure <root>/<name> + its CLAUDE.md include; prints the dir
47
61
  ```
48
62
 
49
63
  Output is a stable line protocol — `ok:` / `WARN` / `FAIL:` / `skip:` with
@@ -67,7 +81,7 @@ Ship your own contracts via `contracts: { extra: [defineContract({...})] }`
67
81
  Everything the CLI does is importable: `runTasks`, `CheckSuite`,
68
82
  `evaluateContracts`, the asks queue, `scanMdGraph`/`lintMd`/`serveMdGraph`,
69
83
  and the assets surface (`defineKit`/`defineKind`, `identityKinds`,
70
- `serveStudio`, `saveEndpoint` via `rvlib-shipkit/vite`).
84
+ `studioRoutes`, `saveEndpoint` via `rvlib-shipkit/vite`).
71
85
  Ships TypeScript source; Bun ≥ 1.0.
72
86
 
73
87
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.10.0",
3
+ "version": "0.13.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",
@@ -21,17 +21,20 @@
21
21
  "devDependencies": {
22
22
  "@types/bun": "^1.3.14",
23
23
  "@types/react": "^19.2.17",
24
- "@types/react-dom": "^19.2.3"
24
+ "@types/react-dom": "^19.2.3",
25
+ "@types/sanitize-html": "^2.16.1"
25
26
  },
26
27
  "dependencies": {
27
28
  "@napi-rs/canvas": "^1.0.2",
28
29
  "clipanion": "^4.0.0-rc.4",
29
30
  "ink": "^7.1.1",
31
+ "marked": "^18.0.7",
30
32
  "mobx": "^6.16.1",
31
33
  "mobx-react-lite": "^4.1.1",
32
34
  "react": "^19.2.7",
33
35
  "react-dom": "^19.2.7",
34
36
  "rvlib-ts-tools": "^0.2.0",
37
+ "sanitize-html": "^2.17.6",
35
38
  "sharp": "^0.35.3"
36
39
  }
37
40
  }
@@ -45,6 +45,14 @@ export function openAsks(dir: string): Ask[] {
45
45
  return listAsks(dir).filter((a) => a.status !== 'done')
46
46
  }
47
47
 
48
+ /** the <dir>/done archive, newest-first — READ-ONLY: never mints the dir
49
+ * (numeric filename prefixes are creation order, so reverse-sort = newest) */
50
+ export function doneAsks(dir: string): Ask[] {
51
+ const done = join(dir, 'done')
52
+ if (!existsSync(done)) return []
53
+ return readdirSync(done).filter((f) => f.endsWith('.md')).sort().reverse().map((f) => parse(done, f))
54
+ }
55
+
48
56
  export function checkAsks(dir: string, print: (line: string) => void = console.log): { closed: number } {
49
57
  const done = join(dir, 'done')
50
58
  mkdirSync(done, { recursive: true })
@@ -99,6 +107,40 @@ ${whyBlock}**Answer**: _(fill in / check off, or just do it — the check probe
99
107
  return file
100
108
  }
101
109
 
110
+ /** flip an ask to done and archive it (the hub's "done" verb) */
111
+ export function archiveAsk(dir: string, file: string): void {
112
+ const p = join(dir, file)
113
+ if (!existsSync(p)) throw new Error(`no such ask: ${file}`)
114
+ const md = readFileSync(p, 'utf8').replace(/^status:.*$/m, `status: done\nclosed: ${new Date().toISOString().slice(0, 10)}`)
115
+ writeFileSync(p, md)
116
+ const done = join(dir, 'done')
117
+ mkdirSync(done, { recursive: true })
118
+ renameSync(p, join(done, file))
119
+ }
120
+
121
+ /** record the completion proof (human-loop: URL back = done), then archive */
122
+ export function proofAsk(dir: string, file: string, url: string): void {
123
+ const p = join(dir, file)
124
+ if (!existsSync(p)) throw new Error(`no such ask: ${file}`)
125
+ writeFileSync(p, `${readFileSync(p, 'utf8').trimEnd()}\n\n**Proof**: ${url}\n`)
126
+ archiveAsk(dir, file)
127
+ }
128
+
129
+ /** once-ever ping marker, shared with every ping path (notify doctrine):
130
+ * returns false when the ask was already pinged — never double-fire */
131
+ export function markPinged(dir: string, file: string): boolean {
132
+ const p = join(dir, file)
133
+ if (!existsSync(p)) return false
134
+ const md = readFileSync(p, 'utf8')
135
+ if (/^pinged:/m.test(md)) return false
136
+ const line = `pinged: ${new Date().toISOString().slice(0, 10)}`
137
+ const withMark = md.replace(/^status:(.*)$/m, `status:$1\n${line}`)
138
+ // a hand-edited ask without status: still gets a durable marker — a
139
+ // no-op write here would re-fire the ping every tick forever
140
+ writeFileSync(p, withMark !== md ? withMark : `${line}\n${md}`)
141
+ return true
142
+ }
143
+
102
144
  export function renderAsksTable(dir: string, print: (line: string) => void = console.log, opts?: { emptyText?: string }): number {
103
145
  const open = openAsks(dir)
104
146
  if (open.length === 0) { print(opts?.emptyText ?? 'no open asks — all caught up 🎉'); return 0 }
@@ -112,14 +112,13 @@ export type Kind = {
112
112
  }
113
113
 
114
114
  export type KitConfig = {
115
- port?: number
116
115
  /** roll workspace root, repo-root-relative; gitignored */
117
116
  workspace?: string
118
117
  comfy?: { host: string; workflows: string; heal?: string }
119
118
  kinds: Kind[]
120
119
  }
121
120
 
122
- export type ResolvedKit = KitConfig & { port: number; workspace: string }
121
+ export type ResolvedKit = KitConfig & { workspace: string }
123
122
 
124
123
  export function validateKit(k: KitConfig): string[] {
125
124
  const errs: string[] = []
@@ -190,7 +189,7 @@ export function missingPanelScripts(kit: KitConfig, root: string): string[] {
190
189
  export function defineKit(k: KitConfig): ResolvedKit {
191
190
  const errs = validateKit(k)
192
191
  if (errs.length > 0) throw new Error(`invalid kit:\n ${errs.join('\n ')}`)
193
- return { ...k, port: k.port ?? 4800, workspace: k.workspace ?? '.rv-assets/out' }
192
+ return { ...k, workspace: k.workspace ?? '.rv-assets/out' }
194
193
  }
195
194
 
196
195
  export const defineKind = (k: Kind): Kind => k
@@ -25,7 +25,8 @@ export function listRolls(dir: string): string[] {
25
25
  if (!existsSync(dir)) return []
26
26
  return readdirSync(dir)
27
27
  .filter(isRollFile)
28
- .sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs) // newest first
28
+ // newest first; filename tiebreak — same-second batches must not shuffle between polls
29
+ .sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs || a.localeCompare(b))
29
30
  }
30
31
 
31
32
  export const pickPath = (dir: string): string => join(dir, 'pick.json')
@@ -10,8 +10,6 @@ import { loadWorkflows } from './gen/workflow-loader.ts'
10
10
  import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
11
11
  import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
12
12
  import { assetDir, listRolls, readPick, readPromptOverlay } from './roll-workspace.ts'
13
- import { page } from './server/studio-page.ts'
14
- import { serveStudio } from './server/studio-server.ts'
15
13
 
16
14
  export type Print = (line: string) => void
17
15
  export type AssetsIo = { print: Print; printErr: Print }
@@ -61,15 +59,6 @@ async function guarded(io: AssetsIo, body: () => Promise<number>): Promise<numbe
61
59
  }
62
60
  }
63
61
 
64
- export const runAssetsServe = (root: string, io: AssetsIo): Promise<number> =>
65
- guarded(io, async () => {
66
- const kit = await loadKit(root)
67
- const studio = await serveStudio({ kit, root, page })
68
- io.print(`ok: studio → http://127.0.0.1:${studio.server.port} (${allAssets(kit).length} assets)`)
69
- await new Promise(() => {}) // Bun.serve owns the process — never resolve
70
- return 0
71
- })
72
-
73
62
  export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | 'add'; n: number; categories: string[] }): Promise<number> =>
74
63
  guarded(io, async () => {
75
64
  const kit = await loadKit(root)
@@ -1,6 +1,7 @@
1
1
  // The studio HTTP server — roll variants, compare, pick, geometry metadata,
2
- // category grid data. 127.0.0.1 ONLY, forever. Never run with --watch: the
3
- // queue is in-memory (fill is the recovery path).
2
+ // category grid data. 127.0.0.1 ONLY, forever. Watch-mode restarts drop the
3
+ // in-memory queue — accepted tradeoff (his order 2026-07-23); `shipkit
4
+ // assets fill` is the recovery path.
4
5
  //
5
6
  // ids/names are validated against the kit + the roll-file regex — no path
6
7
  // from the client ever reaches the filesystem unchecked.
@@ -20,14 +21,11 @@ import { StudioQueue } from './studio-queue.ts'
20
21
  export type StudioDeps = {
21
22
  kit: ResolvedKit
22
23
  root: string
23
- page: () => string
24
24
  /** test seam — defaults to the real engines. `context` = a derived
25
25
  * asset's resolved parent picks (queue resolves it at run time) */
26
26
  generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress, context?: ParentContext) => Promise<void>
27
27
  }
28
28
 
29
- export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
30
-
31
29
  // what the studio detail view shows about an asset's plumbing (W2-2)
32
30
  export type AssetInfo = {
33
31
  backend: 'canvas' | 'comfy' | 'manual' | 'gemini'
@@ -42,9 +40,9 @@ export type AssetInfo = {
42
40
  kitSource: string
43
41
  }
44
42
 
45
- // the studio's route table without a server: `handle` answers the studio's
46
- // own path space ('/', '/api/…', '/img', '/panel/…') so serveStudio can own
47
- // a port AND the shipkit hub can mount the same routes under /assets/.
43
+ // the studio's route table without a server: `handle` answers the media
44
+ // path space ('/api/…', '/img', '/panel/…'); the hub — the repo's ONE
45
+ // server — mounts it under /media/*. The page itself is the hub's react app.
48
46
  export type StudioRoutes = { handle: (req: Request, pathname: string) => Promise<Response>; queue: StudioQueue }
49
47
 
50
48
  export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
@@ -220,8 +218,6 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
220
218
  const q = (k: string): string => url.searchParams.get(k) ?? ''
221
219
  const body = async (): Promise<Record<string, unknown>> => (await req.json().catch(() => ({}))) as Record<string, unknown>
222
220
 
223
- if (pathname === '/') return new Response(deps.page(), { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
224
-
225
221
  if (pathname === '/api/state') {
226
222
  const assets = await Promise.all(allAssets(kit).map(async (f) => {
227
223
  const dir = dirOf(f.asset.id)
@@ -242,7 +238,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
242
238
  const dir = dirOf(f.asset.id)
243
239
  const pick = await readPick(dir)
244
240
  const rolls = listRolls(dir).map((name) => ({ name, picked: name === pick.pick, alt: pick.alts.includes(name), hasMeta: existsSync(metaPath(dir, name)) }))
245
- const custom = customPanelsOf(f).map((p) => ({ id: p.id, kind: f.kind.id, url: `./panel/${encodeURIComponent(f.kind.id)}/${encodeURIComponent(p.id)}.js` }))
241
+ const custom = customPanelsOf(f).map((p) => ({ id: p.id, kind: f.kind.id, url: `panel/${encodeURIComponent(f.kind.id)}/${encodeURIComponent(p.id)}.js` }))
246
242
  return json({ asset: { ...f.asset, prompt: await promptOf(f), cat: f.kind.category, geometry: geometryOf(f) != null, custom }, info: infoOf(f), rolls, pick })
247
243
  }
248
244
 
@@ -472,16 +468,3 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
472
468
 
473
469
  return { handle, queue }
474
470
  }
475
-
476
- // standalone studio server (`shipkit assets serve`): the same routes on
477
- // their own port. The hub (`shipkit serve`) mounts studioRoutes under
478
- // /assets/ instead — one route table, two front doors.
479
- export async function serveStudio(deps: StudioDeps): Promise<Studio> {
480
- const routes = await studioRoutes(deps)
481
- const server = Bun.serve({
482
- hostname: '127.0.0.1',
483
- port: deps.kit.port,
484
- fetch: (req) => routes.handle(req, new URL(req.url).pathname),
485
- })
486
- return { server, queue: routes.queue, stop: () => server.stop(true) }
487
- }
@@ -2,26 +2,15 @@
2
2
  // (ComfyUI or procedural canvas), curate in the studio, ship with metadata
3
3
  // write-through. Thin clipanion surfaces; logic in assets/run-assets.ts.
4
4
  import { Command, Option } from 'clipanion'
5
- import { runAssetsCheck, runAssetsGen, runAssetsIdentity, runAssetsServe, runAssetsTopUp, type AssetsIo } from '../assets/run-assets.ts'
5
+ import { runAssetsCheck, runAssetsGen, runAssetsIdentity, runAssetsTopUp, type AssetsIo } from '../assets/run-assets.ts'
6
6
 
7
7
  const ioOf = (cmd: Command): AssetsIo => ({
8
8
  print: (l) => cmd.context.stdout.write(`${l}\n`),
9
9
  printErr: (l) => cmd.context.stderr.write(`${l}\n`),
10
10
  })
11
11
 
12
- export class AssetsServeCommand extends Command {
13
- static override paths = [['assets', 'serve']]
14
-
15
- static override usage = Command.Usage({
16
- category: 'Assets',
17
- description: 'the studio — roll, curate, pick, ship (127.0.0.1, kit port)',
18
- examples: [['launch the studio for the cwd kit', 'shipkit assets serve']],
19
- })
20
-
21
- async execute(): Promise<number> {
22
- return runAssetsServe(process.cwd(), ioOf(this))
23
- }
24
- }
12
+ // the studio UI has no standalone server command — it rides the hub page
13
+ // (`shipkit serve`, the repo's ONE Bun.serve)
25
14
 
26
15
  export class AssetsFillCommand extends Command {
27
16
  static override paths = [['assets', 'fill']]
@@ -1,27 +1,45 @@
1
- // shipkit check — periodic health, TTL-cached. Thin clipanion surface;
2
- // logic in core/run-check.ts.
1
+ // shipkit check — periodic health, TTL-cached; `--fix` applies rule
2
+ // autofixes then re-lints. Thin clipanion surface; logic in
3
+ // core/run-check.ts and rules/run-fix.ts. ci NEVER fixes.
3
4
  import { Command, Option } from 'clipanion'
4
5
  import { runCheck } from '../core/run-check.ts'
6
+ import { runFix } from '../rules/run-fix.ts'
5
7
 
6
8
  export class CheckCommand extends Command {
7
9
  static override paths = [['check']]
8
10
 
9
11
  static override usage = Command.Usage({
10
12
  category: 'Health',
11
- description: 'periodic health: contracts + every *.check.ts in the repo (auto-discovered, run as subprocesses), clean passes replay from a TTL cache',
13
+ description: 'periodic health: SK rules + every *.check.ts in the repo (auto-discovered, run as subprocesses), clean passes replay from a TTL cache; --fix applies rule autofixes',
12
14
  examples: [
13
15
  ['run (cached where valid)', 'shipkit check'],
14
16
  ['bust every cache layer', 'shipkit check --fresh'],
17
+ ['apply every rule autofix, re-lint', 'shipkit check --fix'],
18
+ ['fix one rule', 'shipkit check --fix SK004'],
19
+ ['report-only: self-maintaining rules never write', 'shipkit check --frozen'],
15
20
  ['machine-readable', 'shipkit check --json'],
16
21
  ],
17
22
  })
18
23
 
19
24
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
20
25
  fresh = Option.Boolean('--fresh', false, { description: 'bust every TTL cache layer (travels to child checks)' })
26
+ fix = Option.Boolean('--fix', false, { description: 'apply rule autofixes (all fixable rules, or only the listed SK ids), then re-lint' })
27
+ frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules (SK005 never touches package.json)' })
28
+ fixIds = Option.Rest()
21
29
 
22
30
  async execute(): Promise<number> {
23
31
  try {
24
- return await runCheck(process.cwd(), { json: this.json, fresh: this.fresh, print: (l) => this.context.stdout.write(`${l}\n`) })
32
+ if (this.fixIds.length > 0 && !this.fix) {
33
+ this.context.stderr.write(`FAIL: rule ids need --fix (shipkit check --fix ${this.fixIds.join(' ')})\n`)
34
+ return 2
35
+ }
36
+ if (this.fix && this.json) {
37
+ this.context.stderr.write('FAIL: --fix has no --json shape — run the fix, then `shipkit check --json` for state\n')
38
+ return 2
39
+ }
40
+ if (this.fix)
41
+ return await runFix(process.cwd(), { ids: this.fixIds, print: (l) => this.context.stdout.write(`${l}\n`) })
42
+ return await runCheck(process.cwd(), { json: this.json, fresh: this.fresh, frozen: this.frozen, print: (l) => this.context.stdout.write(`${l}\n`) })
25
43
  } catch (e) {
26
44
  this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
27
45
  return 1
@@ -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
+ }