rvlib-shipkit 0.11.0 → 0.14.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 (66) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +17 -3
  3. package/package.json +6 -3
  4. package/src/asks/asks-queue.ts +8 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Reflect.ts +116 -0
  12. package/src/cli/cmd_Rules.ts +32 -0
  13. package/src/cli/cmd_Serve.ts +1 -1
  14. package/src/cli.ts +5 -2
  15. package/src/collections/actions.ts +19 -0
  16. package/src/collections/collection-types.ts +13 -0
  17. package/src/collections/graphs.ts +34 -0
  18. package/src/collections/pings.ts +5 -1
  19. package/src/collections/posts.ts +20 -1
  20. package/src/collections/providers.ts +163 -50
  21. package/src/contracts/apply-contracts.ts +47 -0
  22. package/src/contracts/contract-types.ts +5 -2
  23. package/src/contracts/contracts-engine.ts +13 -3
  24. package/src/contracts/manifests/discord.contract.ts +1 -1
  25. package/src/contracts/manifests/identity.contract.ts +1 -1
  26. package/src/contracts/manifests/itch.contract.ts +19 -2
  27. package/src/contracts/run-contracts.ts +8 -3
  28. package/src/core/canonical-scripts.ts +46 -0
  29. package/src/core/check-suite.ts +6 -3
  30. package/src/core/locked-write.ts +39 -0
  31. package/src/core/motherhome.ts +87 -0
  32. package/src/core/run-check.ts +28 -15
  33. package/src/core/run-ci.ts +4 -0
  34. package/src/core/write-status.ts +7 -26
  35. package/src/init-repo.ts +27 -11
  36. package/src/reflect/prompt-corpus.ts +305 -0
  37. package/src/repo-config.ts +30 -38
  38. package/src/rules/SK001-package-json-shape.ts +156 -0
  39. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  40. package/src/rules/SK003-claude-md-shape.ts +64 -0
  41. package/src/rules/SK004-gitignore-policy.ts +105 -0
  42. package/src/rules/SK005-rvlib-versions.ts +99 -0
  43. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  44. package/src/rules/SK007-github-visibility.ts +63 -0
  45. package/src/rules/SK008-reviewer-agent.ts +98 -0
  46. package/src/rules/rule-registry.ts +34 -0
  47. package/src/rules/rule.ts +62 -0
  48. package/src/rules/run-fix.ts +57 -0
  49. package/src/serve/hub-app/col-card.tsx +192 -0
  50. package/src/serve/hub-app/cols-svc.ts +136 -0
  51. package/src/serve/hub-app/geometry-view.tsx +155 -0
  52. package/src/serve/hub-app/health-svc.ts +49 -0
  53. package/src/serve/hub-app/hub-app.tsx +236 -215
  54. package/src/serve/hub-app/hub-st.ts +169 -0
  55. package/src/serve/hub-app/hub-types.ts +72 -0
  56. package/src/serve/hub-app/hub.css +286 -59
  57. package/src/serve/hub-app/index.html +1 -1
  58. package/src/serve/hub-app/md.ts +24 -0
  59. package/src/serve/hub-app/media-svc.ts +429 -0
  60. package/src/serve/hub-app/media-view.tsx +299 -0
  61. package/src/serve/run-serve.ts +23 -9
  62. package/src/serve/serve-hub.ts +62 -47
  63. package/src/shipkit.ts +9 -5
  64. package/src/assets/server/studio-page.ts +0 -687
  65. package/src/core/package-json-check.ts +0 -88
  66. package/src/serve/hub-pages.ts +0 -38
package/CHANGELOG.md CHANGED
@@ -1,5 +1,205 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 — 2026-07-27
4
+
5
+ New rule SK008 — the mandated reviewer agent type. A repo declares
6
+ `reviewer: { name, brief, tools? }` in `repo.config.ts`; the rule then
7
+ requires `.claude/agents/<name>.md` to exist, carry that type name, point at
8
+ the brief file (which must itself exist), and keep `tools:` inside a
9
+ read-only ALLOWLIST (default `Read, Grep, Glob, Bash`; widen it deliberately
10
+ with `reviewer.tools`). A denylist of today's edit-tool names would wave
11
+ through anything that edits by proxy, and an ABSENT `tools:` line is the
12
+ worst case: the harness inherits every tool. Fixable and create-only —
13
+ `shipkit check --fix SK008` writes a valid definition when the file is
14
+ missing and never overwrites an existing one, which may be hand-tuned.
15
+ Undeclared repos skip, and `shipkit init` scaffolds the key commented out.
16
+ `reviewerAgentIssues` is exported so a repo's own linter can enforce the same
17
+ invariants in its pre-commit hook without a second implementation. Note the
18
+ written agent file embeds the brief PATH: a repo that may go public should
19
+ gitignore it and let the fixer regenerate it per machine.
20
+
21
+ Contract rule `png(path, w, h, { atLeast: true })` — the venue's numbers as a
22
+ MINIMUM at a fixed aspect ratio (relative tolerance), for stores that DISPLAY
23
+ a size but accept larger uploads. Without it an exact-size rule fails a better
24
+ 2x asset and blocks the release it was meant to gate. Applied to the itch
25
+ cover and the identity/discord image rules; Steam capsules stay exact, Valve
26
+ mandates those.
27
+
28
+ Dep: `rvlib-ts-tools` ^0.2.0 → ^0.3.0, so the zero-config `imports:check`
29
+ floor task inherits its honest reporting (a run that scanned zero files now
30
+ fails instead of printing green).
31
+
32
+ MIGRATION: the itch venue contract gains a required `.rv-itch/listing.md`
33
+ (every store-form field answered, paste-ready). A repo already shipping on itch
34
+ will see one new FAIL until the file exists; the docs that own the itch
35
+ contract carry a fill-in template for it.
36
+
37
+ ## 0.13.0 — 2026-07-26
38
+
39
+ Stable rule ids. Every built-in invariant `shipkit check` enforces is now
40
+ an SK rule — one module per rule (`src/rules/SK###-<slug>.ts`), one
41
+ registry (`rule-registry.ts`, throws at import on a duplicate or malformed
42
+ id), ids never reused or renumbered. The two existing built-ins migrated
43
+ whole: SK001 package.json shape (was `core/package-json-check.ts`, deleted),
44
+ SK002 `.rv-*` contracts fulfillness. Line text changed on every consumer:
45
+ rule-emitted `ok:`/`WARN`/`FAIL:`/`skip:` lines now carry a `[SK###]` tag
46
+ after the wire prefix — on `check`, `shipkit contracts`, AND the ci
47
+ `contracts` task (one tagged rendering path,
48
+ `contracts/apply-contracts.ts`; `contractsConfigFrom`/`activeContracts`/
49
+ `applyContractsResult` moved there, still exported from the barrel). A
50
+ failing/warning rule always carries at least one `fix:` pointer (the
51
+ framework appends `docsBase + rule.owner` when the lint attached none).
52
+ New command: `shipkit rules [--json]` — the id catalog (plain lines, not
53
+ wire). Wire prefixes themselves are unchanged; matchers keying on prefixes
54
+ are unaffected.
55
+
56
+ `shipkit check --fix [SK### …]`: rules may carry an idempotent `fix()` —
57
+ apply, re-lint, show the real end state; never cached, never under ci;
58
+ unknown/unfixable ids fail loud. First fixers: SK001 (private:true, license
59
+ removal, canonical scripts from the ONE shared module
60
+ `core/canonical-scripts.ts` that `shipkit init` also scaffolds from —
61
+ bodies the vocabulary page doesn't state are named in the summary, never
62
+ invented; rvlib-* roots refused), SK003 CLAUDE.md include-index (new rule:
63
+ prose/extra lines/inverted order/untracked-CLAUDE.local.md FAIL; fix routes
64
+ a missing `@./agent/index.md` on an EXISTING file, never creates one),
65
+ SK004 `.gitignore` policy block (new rule: marker-keyed managed block of
66
+ the always-gitignored contract surfaces + tmp/; a well-formed block is
67
+ rewritten IN PLACE so below-block `!`-overrides keep winning; CRLF files
68
+ keep their EOL; repo lines outside the block never touched;
69
+ `defineRepo({ gitignore: [...] })` contributes repo lines INSIDE the
70
+ block, for names a published lib may not carry; `shipkit init` — fresh or
71
+ re-run on an existing repo — writes it through the same fixer). A failing
72
+ rule's fix pointers advertise `autofix: shipkit check --fix SK###`; the
73
+ post-fix re-lint strips the advertisement and an unchanged fix reports as
74
+ a note, not a WARN; `--fix` rejects `--json` and closes with a count of
75
+ rules no fixer covers. SK001's fixer refuses field flips on anything
76
+ publish-marked (files/exports/publishConfig/prepublishOnly) and never
77
+ duplicates a script a banned alias already satisfies.
78
+
79
+ SK005 rvlib versions: every `rvlib-*` dep is compared (real semver order,
80
+ so a dist-tag pointing BEHIND the installed version is reported, never a
81
+ silent downgrade) to npm latest on each `shipkit check` (rules may now
82
+ declare `ttl` — SK005 caches clean passes 24h; registry unreachable = loud
83
+ skip, never red; a `link:` dep that does not resolve is a FAIL instead of
84
+ a cryptic import crash). When a newer version is live the rule
85
+ SELF-UPDATES: `bun update --latest`, run by the lint itself, only when
86
+ package.json/lockfile are git-clean (else WARN deferred), never under ci;
87
+ the applied update itself reports as WARN — the repo was mutated and a
88
+ restart+commit is owed, so it lands on the attention surface and is never
89
+ cached. New `shipkit check --frozen` (or standing `SHIPKIT_FROZEN=1`)
90
+ reports drift without touching anything; the flag threads through
91
+ `CheckOpts` so doctor/TUI/hub callers can pass it too. Committing the bump
92
+ stays with the working agent — shipkit never commits. Suite-level fix
93
+ riding along: a TTL'd check that emitted a `skip` line is no longer
94
+ cached — "only clean passes replay" is now literally true (a dead
95
+ registry was being restated as fact for 24h).
96
+
97
+ SK006 npm publish safety: a `private: true` package carrying publishConfig
98
+ / prepublish* scripts / an npm-publishing `publish` script FAILs — the
99
+ machinery is dead weight while private holds and a loaded gun the day it
100
+ flips; rvlib-* roots exempt; lint-only on purpose (a `publish` script may
101
+ deploy a site). SK007 github visibility: new repo.config
102
+ `visibility: 'private' | 'public'` (absent = private, defineRepo validates)
103
+ vs `gh repo view --json isPrivate` — mismatch FAILs in BOTH directions
104
+ (public-while-declared-private is the leak case), 7d clean-pass cache,
105
+ every unverifiable path (no gh, no remote, gh error) a loud skip that is
106
+ never red and never cached.
107
+
108
+ Motherhome reporting: `check`/`ci` (and `init` on an already-configured
109
+ repo) upsert one row for the repo — name, path, version (`+src` suffix
110
+ when consumed by path/link), npm|link|file|path mode, last-seen day —
111
+ into `<docsBase>/build/shipkit/local-projects.md`, ONLY when that page
112
+ already exists on this disk. The lib never creates the page and never
113
+ writes into a relative or URL docsBase — a consumer using docsBase as a
114
+ plain fix-pointer prefix will never find surprise files in its docs tree.
115
+ Whole read-modify-write under a file lock with atomic rename
116
+ (core/locked-write, extracted from write-status — one lock concern),
117
+ dead-path rows pruned, data-change-or->7d churn control, every failure a
118
+ WARN note that never breaks the calling command.
119
+
120
+ ## 0.12.0 — 2026-07-23
121
+
122
+ ONE server, ONE page. `shipkit serve` runs the repo's single `Bun.serve`,
123
+ and `/` is the single surface: a mobx+react app (Bun HTML import, no build
124
+ step) holding the full media atelier (rolls, compare, pick, geometry
125
+ overlay, custom panels, uploads, generate queue) plus foldable sections
126
+ for asks, creds, posts, health (STATUS.md + streaming check runner) and
127
+ graphs (each lint-checked), with a segmented severity filter (all · needs ·
128
+ red) at `/?f=needs` / `/?f=red` (legacy `/?needs=1` reads as needs) and
129
+ hash deep-links (`#<asset-id>` / `#cat-<Category>`).
130
+ Kit-less repos serve the same page with no media section. Graph canvases
131
+ stay as drill-down pages (`/graph/wiki`, `/graph/agent`) with a home link.
132
+
133
+ New:
134
+
135
+ - Navigation on the one page: a thin icon RAIL that scrolls/unfolds to a
136
+ section (red dot when it holds a red item — never a separate page), a
137
+ sidebar SEARCH filtering rows and grid cards, a `cmd+j` palette jumping
138
+ to any item or section.
139
+ - Instances carry `md` (truth-file markdown body, frontmatter stripped,
140
+ 4000-char cap): cards render it via marked (GFM — tables render) behind
141
+ a sanitize-html allowlist — clamped in the grid, full in the item view.
142
+ - Secret cards presented properly: mono name, service line,
143
+ present/missing chip, mint link; copy block only when missing.
144
+ - Roll strips: newest first with a stable tiebreak; the generating
145
+ spinner tile sits at the START of the strip.
146
+ - Every section has BOTH views: title → collection view (one card per
147
+ instance in the stage), row/card → item view with a back link. A graph's
148
+ item view EMBEDS the graph (iframe of /graph/*), never a bare link.
149
+ - Sections show everything, always: asks include the done/ archive
150
+ (quiet, remarkable — consult past answers), the secrets section lists
151
+ every repo-declared cred (present = quiet, missing = red with the mint
152
+ artifact); section headers render even at 0 items. "needs" is THE
153
+ waiting-on-me filter, "red" the hard-blocked cut.
154
+ - Watch mode is the house default for the hub script
155
+ (`bun --watch … serve`, `shipkit init` scaffolds it): lib/config edits
156
+ restart the server live. Accepted tradeoff: a restart drops in-memory
157
+ queue state (`assets fill` recovers).
158
+ - `serve.publicHost` (repo.config): off-machine hostname printed beside
159
+ localhost and appended to ping messages — a phone can't resolve
160
+ localhost.
161
+ - `/api/collections.json`: browse-all — every instance of every
162
+ collection, healthy included (quiet tiles), rendered as foldable
163
+ sections with red/yellow dots.
164
+ - Trust surface: instances carry `file` (the git file the tile IS — shown
165
+ on the card; the file always wins) and `img` (thumbnails via the media
166
+ API).
167
+ - Remarks: cards AND the asset view have a remark box; `/api/act/remark`
168
+ routes the text into the item's own file (post `## Feedback`, the ask
169
+ file, or `.rv-assets/notes.md` for media) and it returns as `remarks`
170
+ on the instance — `shipkit needs --json` briefs agents on them at boot.
171
+ - Posted/rejected post history is VISIBLE (quiet), no longer hidden;
172
+ media collections list ALL kit assets, not only needing ones.
173
+ - `shipkit reflect [repo…] [--short --out <dir> --json]`: your prompts
174
+ across every Claude Code session of a repo, chronological md corpus for a
175
+ retrospective. Corpus fidelity is the whole feature — it unions EVERY
176
+ config root on the machine (`$CLAUDE_CONFIG_DIR` plus `~/.claude` plus
177
+ sibling `~/.claude-<account>`; honoring only the env var hides the account
178
+ you are not currently running as), scans worktree session dirs, includes
179
+ the prompts you QUEUED while the agent was working (`queue-operation`
180
+ entries, marked `origin: 'queued'` — they carry mid-run corrections and
181
+ standing instructions, and when a run ends first they never become user
182
+ messages at all), drops harness-authored user entries (tool results,
183
+ command wrappers, compaction summaries, subagent prompts, SDK-driven
184
+ sessions), rescues the text you typed inside a slash command, strips
185
+ reminder blocks appended inside real prompts, recovers pruned sessions
186
+ from `sessions-index.json`, and removes only MECHANICAL copies — a
187
+ replayed entry, or a queued prompt arriving again as a message — so real
188
+ repetition (saying it twice) survives in the corpus. `--json` emits one
189
+ shape whatever the run looked like: `[{repo, prompts, error?}]`.
190
+
191
+ BREAKING (0.11.0 → 0.12.0):
192
+
193
+ - `shipkit assets serve` DELETED — the studio UI rides the hub page; the
194
+ media API mounts at `/media/*` (was `/assets/*` under the hub).
195
+ - `serveStudio`, `Studio`, `studioPage`, `withRail` exports DELETED
196
+ (`studioRoutes` stays — it IS the media API).
197
+ - `kit.port` DELETED from `defineKit` (nothing standalone listens anymore).
198
+ - `/status` and `/assets/` pages DELETED (404, no redirect); the check
199
+ stream moved `/status/run` → `/api/check/run`; the vanilla
200
+ template-string studio SPA and the injected rail are gone — the react
201
+ app is the only UI code path.
202
+
3
203
  ## 0.11.0 — 2026-07-23
4
204
 
5
205
  The needs feed: the hub's `/` now answers "what needs a human right now,
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.11.0",
3
+ "version": "0.14.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
- "rvlib-ts-tools": "^0.2.0",
36
+ "rvlib-ts-tools": "^0.3.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 })
@@ -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,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
+ }