rvlib-shipkit 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-07-22
4
+
5
+ New:
6
+
7
+ - `shipkit start <name>`: ensure `<root>/<name>` exists (`--root`, default
8
+ `~/dev`) and, with `--include <line>`, that CLAUDE.md starts with that
9
+ include (`--also-ok <line>` variants count as already wired and are never
10
+ double-added; `--skip <name>` repos never get the include). Stdout is
11
+ ONLY the resolved dir: a sourced shell alias can
12
+ `cd "$(shipkit start <name> …)"`, the one thing a child process can't do
13
+ for its parent shell.
14
+
15
+ ## 0.8.0 — 2026-07-22
16
+
17
+ New:
18
+
19
+ - `shipkit mv <old.md> <new.md>` in every repo: rename an md page + its
20
+ sibling asset dir + `<name>.check.ts`, sweeping every tracked file for
21
+ refs (full path, docs-root id, check filename, boundary-guarded so
22
+ lookalikes survive). Dry by default, `--write` applies, `--commit` adds
23
+ one conventional commit behind the repo's pre-commit gate. A repo
24
+ `mv:after` script (NOT `postmv`, bun npm-compat auto-runs post* names)
25
+ runs after the moves for repo-specific re-stamps. `movePage` exported.
26
+ `init` scaffolds the `mv` script; the package.json probe requires it.
27
+ Upgraders: re-run `shipkit init` (idempotent, only adds the gap).
28
+ - the hub detects its own staleness: lib version read at boot vs on disk
29
+ per attention load. A mismatched running process shows a red line telling
30
+ you to restart the task (field bug: a morning process served old studio
31
+ code all day and a pick looked broken).
32
+
3
33
  ## 0.7.0 — 2026-07-22
4
34
 
5
35
  New (the hub becomes THE dashboard; autonomy over nagging):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -184,7 +184,7 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
184
184
  else if (idn.state === 'rendered') io.print(`ok: identity re-rendered (brand.ts changed) — ${idn.detail}`)
185
185
  else if (idn.state === 'blocked') { io.print(`FAIL: identity blocked — ${idn.detail}`); bad++ }
186
186
  // manual assets: no rolls = nothing to ship = RED (the missing list
187
- // is the shopping list — build/audio doctrine); rolls-but-no-pick = WARN
187
+ // is the shopping list — build/game-audio doctrine); rolls-but-no-pick = WARN
188
188
  const manuals = allAssets(kit).filter((f) => f.kind.generate.backend === 'manual')
189
189
  let manualBad = 0
190
190
  for (const f of manuals) {
@@ -0,0 +1,34 @@
1
+ // shipkit mv — md-page rename with repo-wide ref sweep. Thin clipanion
2
+ // surface; logic in mv/mv-md.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { movePage } from '../mv/mv-md.ts'
5
+
6
+ export class MvCommand extends Command {
7
+ static override paths = [['mv']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Wiki',
11
+ description: 'rename an md page + sibling dir + check.ts, sweeping every tracked ref; renames must be CHEAP',
12
+ examples: [
13
+ ['dry run (default): show what would change', 'shipkit mv docs/old-name.md docs/new-name.md'],
14
+ ['apply + one conventional commit', 'shipkit mv docs/old-name.md docs/new-name.md --commit'],
15
+ ],
16
+ })
17
+
18
+ oldPath = Option.String()
19
+ newPath = Option.String()
20
+ write = Option.Boolean('--write', false, { description: 'apply (default is a dry run)' })
21
+ commit = Option.Boolean('--commit', false, { description: 'apply + git commit (pre-commit hook is the gate)' })
22
+
23
+ async execute(): Promise<number> {
24
+ try {
25
+ return movePage(process.cwd(), this.oldPath, this.newPath, {
26
+ print: (l) => this.context.stdout.write(`${l}\n`),
27
+ printErr: (l) => this.context.stderr.write(`${l}\n`),
28
+ }, { write: this.write, commit: this.commit })
29
+ } catch (e) {
30
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
31
+ return 1
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,28 @@
1
+ // shipkit start — ensure <root>/<name> exists (+ a CLAUDE.md include),
2
+ // printing the dir on stdout so a shell alias can cd there (a child process
3
+ // can't cd its parent). Thin clipanion surface; logic in start-repo.ts.
4
+ import { Command, Option } from 'clipanion'
5
+ import { startRepo } from '../start-repo.ts'
6
+
7
+ export class StartCommand extends Command {
8
+ static override paths = [['start']]
9
+
10
+ static override usage = Command.Usage({
11
+ category: 'Setup',
12
+ description: 'ensure <root>/<name> exists, optionally with a CLAUDE.md include line; prints the dir on stdout so a shell alias can cd there',
13
+ examples: [
14
+ ['start (or enter) a repo', 'shipkit start pong'],
15
+ ['seed CLAUDE.md line 1', "shipkit start pong --include '@~/wiki/index.md'"],
16
+ ],
17
+ })
18
+
19
+ name = Option.String()
20
+ root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
21
+ include = Option.String('--include', { description: 'line CLAUDE.md must start with; omitted = only ensure the dir' })
22
+ alsoOk = Option.Array('--also-ok', { description: 'alternative lines that already count as wired — never double-add' })
23
+ skip = Option.Array('--skip', { description: 'repo names that never get the include' })
24
+
25
+ async execute(): Promise<number> {
26
+ return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip })
27
+ }
28
+ }
package/src/cli.ts CHANGED
@@ -10,7 +10,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
+ import { MvCommand } from './cli/cmd_Mv.ts'
13
14
  import { ServeCommand } from './cli/cmd_Serve.ts'
15
+ import { StartCommand } from './cli/cmd_Start.ts'
14
16
  import { TuiCommand } from './cli/cmd_Tui.ts'
15
17
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
16
18
 
@@ -27,6 +29,7 @@ const cli = new Cli({
27
29
  cli.register(Builtins.HelpCommand)
28
30
  cli.register(Builtins.VersionCommand)
29
31
  cli.register(CiCommand)
32
+ cli.register(MvCommand)
30
33
  cli.register(CheckCommand)
31
34
  cli.register(TuiCommand)
32
35
  cli.register(ServeCommand)
@@ -43,5 +46,6 @@ cli.register(AssetsGenCommand)
43
46
  cli.register(AssetsIdentityCommand)
44
47
  cli.register(AssetsCheckCommand)
45
48
  cli.register(InitCommand)
49
+ cli.register(StartCommand)
46
50
 
47
51
  cli.runExit(process.argv.slice(2))
@@ -5,7 +5,7 @@ import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/
5
5
  export default defineContract({
6
6
  venue: 'discord',
7
7
  dir: '.rv-discord',
8
- owner: 'distribute/social.md',
8
+ owner: 'distribute/post-publicly.md',
9
9
  appliesTo: 'declared',
10
10
  detect: [dirExists('.rv-discord')],
11
11
  artifacts: [
@@ -6,7 +6,7 @@ import { defineContract, file, glob, png } from 'rvlib-shipkit/src/contracts/con
6
6
  export default defineContract({
7
7
  venue: 'identity',
8
8
  dir: '.rv-identity',
9
- owner: 'build/assets.md',
9
+ owner: 'build/generate-assets.md',
10
10
  appliesTo: 'always',
11
11
  gate: 'public-surface',
12
12
  detect: [],
@@ -5,7 +5,7 @@ import { custom, defineContract, dirExists } from 'rvlib-shipkit/src/contracts/c
5
5
  export default defineContract({
6
6
  venue: 'social',
7
7
  dir: '.rv-social',
8
- owner: 'distribute/social.md',
8
+ owner: 'distribute/post-publicly.md',
9
9
  appliesTo: 'declared',
10
10
  detect: [dirExists('.rv-social')],
11
11
  artifacts: [
@@ -68,6 +68,7 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
68
68
  { name: 'contracts', why: 'shipkit repo' },
69
69
  { name: 'tui', why: 'shipkit repo' },
70
70
  { name: 'hub', why: 'shipkit repo' },
71
+ { name: 'mv', why: 'shipkit repo' },
71
72
  ]
72
73
  if (existsSync(join(root, '.rv-assets/kit.ts'))) required.push({ name: 'assets', why: '.rv-assets/kit.ts exists' })
73
74
  if (existsSync(join(root, '.rv-identity/brand.ts'))) required.push({ name: 'assets:identity', why: '.rv-identity/brand.ts exists' })
package/src/init-repo.ts CHANGED
@@ -42,7 +42,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
42
42
  let added = 0
43
43
  // canonical script NAME → shipkit command (hub runs `shipkit serve`) —
44
44
  // vocabulary owned by meta/rv-conventions/package-json.md
45
- const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve' } as const
45
+ const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve', mv: 'mv' } as const
46
46
  for (const [k, cmd] of Object.entries(CANONICAL)) {
47
47
  const v = run(cmd)
48
48
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
@@ -0,0 +1,90 @@
1
+ // shipkit mv — rename an md page the AGILE way: one command moves the .md,
2
+ // its sibling asset dir and <name>.check.ts, then sweeps EVERY tracked text
3
+ // file for refs (full path, docs-root-relative id, check filename). Dry by
4
+ // default; --write applies; --commit adds one conventional commit (the
5
+ // repo's pre-commit hook is the gate). If package.json has a `mv:after`
6
+ // script it runs after the moves (repo-specific re-stamps). TS module moves
7
+ // are a different concern (a TS-aware renamer owns import graphs).
8
+ import { execSync } from 'child_process'
9
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
10
+ import { basename, dirname, join } from 'path'
11
+
12
+ export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
13
+ export type MvOpts = { write?: boolean; commit?: boolean }
14
+
15
+ const sh = (cmd: string, cwd: string): string => execSync(cmd, { cwd }).toString()
16
+
17
+ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo, o: MvOpts = {}): number {
18
+ const die = (m: string): number => { io.printErr(`FAIL: ${m}`); return 1 }
19
+ if (!oldRel.endsWith('.md') || !newRel.endsWith('.md')) return die('both paths must be .md (TS moves are not this tool\'s job)')
20
+ if (!existsSync(join(root, oldRel))) return die(`${oldRel} does not exist`)
21
+ if (existsSync(join(root, newRel))) return die(`${newRel} already exists`)
22
+ if (dirname(oldRel) !== dirname(newRel)) return die('cross-directory moves not supported yet, same dir only')
23
+
24
+ const write = o.write === true || o.commit === true
25
+ const oldName = basename(oldRel, '.md')
26
+ const newName = basename(newRel, '.md')
27
+ const oldBase = oldRel.slice(0, -3) // full path sans .md — the sibling dir
28
+ const newBase = newRel.slice(0, -3)
29
+ // docs-root-relative id: refs inside a wiki usually omit the top dir
30
+ // (`build/state`, not `wiki/build/state`) — sweep both forms
31
+ const tail = (p: string): string | null => { const seg = p.split('/'); return seg.length >= 3 ? seg.slice(1).join('/') : null }
32
+ const oldId = tail(oldBase)
33
+ const newId = tail(newBase)
34
+
35
+ // longest-first so `wiki/x/state.md` beats `x/state` beats bare tokens
36
+ const pairs: [string, string][] = [
37
+ [`${oldBase}.md`, `${newBase}.md`],
38
+ ...(oldId != null ? [[`${oldId}.md`, `${newId}.md`] as [string, string]] : []),
39
+ // probe file is named after its page; DIR-qualified so another
40
+ // page's same-named check.ts in a different dir never gets rewritten
41
+ [`${oldBase}/${oldName}.check.ts`, `${newBase}/${newName}.check.ts`],
42
+ ...(oldId != null ? [[`${oldId}/${oldName}.check.ts`, `${newId}/${newName}.check.ts`] as [string, string]] : []),
43
+ [`${oldBase}/`, `${newBase}/`],
44
+ ...(oldId != null ? [[`${oldId}/`, `${newId}/`] as [string, string]] : []),
45
+ [oldBase, newBase],
46
+ ...(oldId != null ? [[oldId, newId!] as [string, string]] : []),
47
+ ]
48
+ const bound = (t: string): RegExp =>
49
+ new RegExp('(?<![\\w-])' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + (/[/.]$/.test(t) || t.endsWith('.ts') ? '' : '(?![\\w-])'), 'g')
50
+
51
+ const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
52
+ .filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
53
+ let touched = 0
54
+ for (const f of tracked) {
55
+ const p = join(root, f)
56
+ if (!existsSync(p)) continue
57
+ const before = readFileSync(p, 'utf8')
58
+ let after = before
59
+ for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
60
+ if (after !== before) {
61
+ io.print(` ${f}`)
62
+ if (write) writeFileSync(p, after)
63
+ touched++
64
+ }
65
+ }
66
+ io.print(`${write ? 'renamed' : 'DRY RUN'}: ${oldBase} → ${newBase}, refs in ${touched} file(s)`)
67
+ if (!write) { io.print('re-run with --write (or --commit) to apply'); return 0 }
68
+
69
+ // moves AFTER the sweep (the sweep rewrote files at their old paths)
70
+ sh(`git mv ${JSON.stringify(oldRel)} ${JSON.stringify(newRel)}`, root)
71
+ if (existsSync(join(root, oldBase))) {
72
+ sh(`git mv ${JSON.stringify(oldBase)} ${JSON.stringify(newBase)}`, root)
73
+ const oldCheck = join(newBase, `${oldName}.check.ts`)
74
+ if (existsSync(join(root, oldCheck))) sh(`git mv ${JSON.stringify(oldCheck)} ${JSON.stringify(join(newBase, `${newName}.check.ts`))}`, root)
75
+ }
76
+
77
+ // repo-specific re-stamps (e.g. wiki depth frontmatter) — loud on failure
78
+ const pkg = existsSync(join(root, 'package.json')) ? (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }) : {}
79
+ if (pkg.scripts?.['mv:after'] != null) {
80
+ try { execSync('bun run mv:after', { cwd: root, stdio: 'inherit' }) } catch { return die('mv:after failed, fix before committing') }
81
+ }
82
+ if (o.commit === true) {
83
+ sh('git add -A', root)
84
+ try {
85
+ execSync(`git commit -m ${JSON.stringify(`docs: rename ${oldBase} → ${newBase}`)}`, { cwd: root, stdio: 'inherit' })
86
+ } catch { return die('commit failed (pre-commit gate?). The rename is applied, fix and commit yourself') }
87
+ }
88
+ io.print(`ok: ${oldBase} → ${newBase}${o.commit === true ? ' (committed)' : ''}`)
89
+ return 0
90
+ }
@@ -5,7 +5,7 @@
5
5
  // kit.ts loads. Port: --port > serve.port > a deterministic house-range
6
6
  // default (4800-4979, hashed from the repo name — stable across launches;
7
7
  // pin serve.port when two repos collide).
8
- import { existsSync, readdirSync, statSync } from 'fs'
8
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
11
11
  import { loadKit, allAssets, type ResolvedKit } from '../assets/kit-config.ts'
@@ -63,6 +63,15 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
63
63
  }
64
64
  }
65
65
 
66
+ // the hub must KNOW when it is outdated (field bug 2026-07-22: a morning
67
+ // process served stale code all day, picks looked broken): version read at
68
+ // boot vs on disk per attention load; mismatch = red line telling him to
69
+ // restart the task
70
+ const libVersion = (): string => {
71
+ try { return (JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
72
+ }
73
+ const BOOT_VERSION = libVersion()
74
+
66
75
  // ── the / dashboard's data: everything red, one collector per source, each
67
76
  // failure-isolated (a broken source reports itself, never blanks the page)
68
77
  export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit | null): () => Promise<AttentionSection[]> {
@@ -71,6 +80,11 @@ export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit
71
80
  }
72
81
  return async () => {
73
82
  const sections = await Promise.all([
83
+ guard('hub process', 'red', async () => {
84
+ const now = libVersion()
85
+ if (now === BOOT_VERSION) return []
86
+ return [{ text: `hub is running shipkit ${BOOT_VERSION} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` }]
87
+ }),
74
88
  guard('failing checks', 'red', async () => {
75
89
  const p = join(root, 'STATUS.md')
76
90
  if (!existsSync(p)) return []
package/src/shipkit.ts CHANGED
@@ -58,3 +58,5 @@ export { lintMd } from './wiki/lint-md.ts'
58
58
  export { serveMdGraph } from './wiki/serve-graph.ts'
59
59
  export { graphHtml } from './wiki/graph-page.ts'
60
60
  export type { MdGraph, MdNode, MdEdge } from './wiki/md-graph.ts'
61
+ export { movePage } from './mv/mv-md.ts'
62
+ export type { MvIo, MvOpts } from './mv/mv-md.ts'
@@ -0,0 +1,50 @@
1
+ // `shipkit start` — ensure <root>/<name> exists and, when --include is
2
+ // given, that CLAUDE.md carries that include line. Plain module behind
3
+ // cmd_Start. Status lines go to STDERR — the ONLY stdout output is the
4
+ // resolved dir, so a sourced shell alias can `cd "$(shipkit start <name>)"`
5
+ // (the one thing a child process can't do for its parent shell).
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
7
+ import { homedir } from 'os'
8
+ import { join } from 'path'
9
+
10
+ export type StartOpts = {
11
+ /** parent dir for repos — default ~/dev */
12
+ root?: string
13
+ /** line CLAUDE.md must start with; omitted = only ensure the dir */
14
+ include?: string
15
+ /** alternative lines that already count as wired — never double-add */
16
+ alsoOk?: string[]
17
+ /** repo names that never get the include */
18
+ skip?: string[]
19
+ }
20
+
21
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
22
+
23
+ export function startRepo(name: string, opts: StartOpts = {}): number {
24
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
25
+ console.error(`FAIL: bad repo name '${name}' — letters/digits/._- only, no leading dot, no slashes`)
26
+ return 2
27
+ }
28
+ const dir = join(opts.root ?? join(homedir(), 'dev'), name)
29
+ if (!existsSync(dir)) {
30
+ mkdirSync(dir, { recursive: true })
31
+ console.error(`ok: created ${dir}`)
32
+ }
33
+ if (opts.include == null || (opts.skip ?? []).includes(name)) {
34
+ if (opts.include != null) console.error(`skip: ${name} is include-exempt — CLAUDE.md untouched`)
35
+ } else {
36
+ const claudeMd = join(dir, 'CLAUDE.md')
37
+ if (!existsSync(claudeMd)) {
38
+ writeFileSync(claudeMd, `${opts.include}\n`)
39
+ console.error('ok: CLAUDE.md created with the include')
40
+ } else {
41
+ const wiredRe = new RegExp([opts.include, ...(opts.alsoOk ?? [])].map((l) => `^${escapeRe(l)}$`).join('|'), 'm')
42
+ if (!wiredRe.test(readFileSync(claudeMd, 'utf8'))) {
43
+ writeFileSync(claudeMd, `${opts.include}\n\n${readFileSync(claudeMd, 'utf8')}`)
44
+ console.error('ok: include prepended to CLAUDE.md')
45
+ }
46
+ }
47
+ }
48
+ console.log(dir)
49
+ return 0
50
+ }
@@ -13,7 +13,7 @@ import { TuiStore } from './tui-store.ts'
13
13
 
14
14
  export async function runTui(root: string): Promise<number> {
15
15
  const c = await loadRepoConfig(root)
16
- // playbook (build/mobx): enforceActions warns on stray mutations — the
16
+ // playbook (build/mobx-mechanics): enforceActions warns on stray mutations — the
17
17
  // only signal. Read-warning flags stay off: useInput handler reads are
18
18
  // legitimately untracked and would false-positive.
19
19
  configure({ enforceActions: 'always' })
@@ -1,4 +1,4 @@
1
- // TUI state — MobX per the house playbook (build/state + build/mobx):
1
+ // TUI state — MobX per the house playbook (build/app-state-tree + build/mobx-mechanics):
2
2
  // `makeAutoObservable` WITH exceptions (the 2026-07-21 doctrine — no
3
3
  // decorators), runInAction around every post-await mutation. Panels are
4
4
  // plain data providers (no mobx): they signal mutations through update(),