rvlib-shipkit 2.1.0 → 2.2.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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +2 -1
  3. package/src/assets/kits/identity-kit.ts +7 -3
  4. package/src/assets/run-assets.ts +18 -4
  5. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  6. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  7. package/src/cli/cmd_CacheAudit.ts +3 -2
  8. package/src/cli/cmd_Calls.ts +53 -0
  9. package/src/cli/cmd_Ci.ts +4 -3
  10. package/src/cli/cmd_Commands.ts +158 -43
  11. package/src/cli/cmd_Fleet.ts +3 -1
  12. package/src/cli/cmd_GlobalProcesses.ts +7 -2
  13. package/src/cli/cmd_Shot.ts +63 -0
  14. package/src/cli/cmd_Start.ts +8 -4
  15. package/src/cli/cmd_Trailer.ts +108 -0
  16. package/src/cli/kit/builtins.ts +40 -0
  17. package/src/cli/kit/calls.ts +177 -0
  18. package/src/cli/kit/families.ts +67 -0
  19. package/src/cli/kit/index.ts +8 -0
  20. package/src/cli/kit/pages-check.ts +36 -0
  21. package/src/cli/kit/project-cli.ts +73 -0
  22. package/src/cli/kit/verb.ts +157 -0
  23. package/src/cli/list-flag.ts +3 -0
  24. package/src/cli.ts +13 -2
  25. package/src/core/layout.ts +10 -2
  26. package/src/core/migrate-layout.ts +31 -8
  27. package/src/core/one-way-in-policy.ts +12 -4
  28. package/src/mv/mv-md.ts +2 -1
  29. package/src/reflect/prompt-corpus.ts +5 -0
  30. package/src/scaffold/cli-skeleton.ts +149 -0
  31. package/src/scaffold/files/agent-index.md +2 -2
  32. package/src/scaffold/files/tsconfig.json +3 -0
  33. package/src/scaffold/files/verify-skill.md +1 -1
  34. package/src/scaffold/project-scaffold.ts +90 -12
  35. package/src/shipkit-files.ts +8 -4
  36. package/src/shot/client.ts +67 -0
  37. package/src/shot/clip-encoder.ts +146 -0
  38. package/src/shot/dom-clip.ts +124 -0
  39. package/src/shot/serve-shot.ts +54 -0
  40. package/src/shot/shooter.ts +343 -0
  41. package/src/shot/shot-ask.ts +189 -0
  42. package/src/shot/shot.shipkit.ts +14 -0
  43. package/src/smoke/run-smoke.ts +2 -2
  44. package/src/start-repo.ts +29 -14
  45. package/src/suite/judge-suites.ts +56 -10
  46. package/src/suite/select.ts +2 -5
  47. package/src/suite/suite-cli.ts +3 -2
  48. package/src/trailer/adapter.ts +23 -0
  49. package/src/trailer/build.ts +344 -0
  50. package/src/trailer/ffmpeg.ts +50 -0
  51. package/src/trailer/footage.ts +104 -0
  52. package/src/trailer/grammar.ts +915 -0
  53. package/src/trailer/graph.ts +323 -0
  54. package/src/trailer/load-unit.ts +14 -0
  55. package/src/trailer/look.ts +110 -0
  56. package/src/trailer/plan.ts +133 -0
  57. package/src/trailer/stitch.ts +150 -0
  58. package/src/trailer/titles.ts +274 -0
  59. package/src/trailer/url.ts +108 -0
  60. package/src/trailer/vertical-draw.ts +286 -0
  61. package/src/trailer/vertical.ts +148 -0
  62. package/src/trailer/vocab.ts +40 -0
  63. package/src/ts/rename.ts +9 -0
@@ -0,0 +1,157 @@
1
+ // a project CLI command over code that already exists is a ROW, never a second
2
+ // implementation: `verbs([...])` turns rows into clipanion commands that spawn
3
+ // the script, ask the warm tool, or run the command line, from the repo root.
4
+ // A family file imports this module and nothing heavy at its top: the bare
5
+ // listing loads every family.
6
+ import { type BaseContext, Command, type CommandClass as ClipanionClass, Option } from 'clipanion'
7
+ import { existsSync } from 'fs'
8
+ import { isAbsolute, resolve } from 'path'
9
+
10
+ /** what a kit command sees in `this.context`, beside clipanion's streams */
11
+ export type KitContext = BaseContext & {
12
+ /** the repo root: every row runs from it */
13
+ root: string
14
+ /** where the caller stood: a filmable row's `--out` resolves against it */
15
+ cwd: string
16
+ /** every command class registered in this process (the docs read their marks) */
17
+ classes: CommandClass[]
18
+ /** the warm path of a `{ tool }` row, when the project has a warm host */
19
+ askTool?: AskTool
20
+ }
21
+
22
+ export type CommandClass = ClipanionClass<KitContext>
23
+
24
+ /** asks a warm process for a tool's answer; null = no warm host, run the cold script */
25
+ export type AskTool = (tool: string, args: string[]) => Promise<number | null>
26
+
27
+ /** how a row runs */
28
+ export type Run =
29
+ /** `bun <script> ...pre ...args` */
30
+ | { bun: string; pre?: string[] }
31
+ /** the warm host's answer when it runs (`askTool`), else `bun <cold> ...pre ...args` */
32
+ | { tool: string; cold: string; pre?: string[] }
33
+ /** any command line, the args appended */
34
+ | { cmd: string[] }
35
+
36
+ /** a row that shoots a state: `still` writes one image at `--out`, `clip` a video, its length `--seconds` */
37
+ export type Film = 'still' | 'clip'
38
+
39
+ /** an everyday command, listed on the essentials page every session loads */
40
+ export type Essential = {
41
+ /** its arguments as an agent types them: '<x> <y>' */
42
+ args?: string
43
+ /** how many of its examples the page shows under its line */
44
+ examples?: number
45
+ }
46
+
47
+ export type Verb = {
48
+ /** the words after the binary: 'world at' */
49
+ path: string
50
+ /** the group the listing prints it under */
51
+ category: string
52
+ /** one line, under 100 characters */
53
+ description: string
54
+ /** the longer text `--help` prints */
55
+ details?: string
56
+ /** [label, command line], each line starting with the binary and the row's words */
57
+ examples: [string, string][]
58
+ run: Run
59
+ /** 'own': `--help` also runs the tool with `--help`, which prints its flags and exits 0 */
60
+ help?: 'own'
61
+ essential?: Essential
62
+ /** filmable: takes `--out <path>`, a clip also `--seconds <n>` (a trailer `film` line calls it) */
63
+ film?: Film
64
+ }
65
+
66
+ /** the argv a row runs */
67
+ export function argvOf(run: Run, args: readonly string[]): string[] {
68
+ if ('bun' in run) return ['bun', run.bun, ...(run.pre ?? []), ...args]
69
+ if ('tool' in run) return ['bun', run.cold, ...(run.pre ?? []), ...args]
70
+ return [...run.cmd, ...args]
71
+ }
72
+
73
+ /** what a row runs, as the commands page prints it */
74
+ export function runText(run: Run): string {
75
+ if ('bun' in run) return [run.bun, ...(run.pre ?? [])].join(' ')
76
+ if ('tool' in run) return `tool ${run.tool} (cold: ${[run.cold, ...(run.pre ?? [])].join(' ')})`
77
+ return run.cmd.join(' ')
78
+ }
79
+
80
+ /** the value after `flag` in args (`--out x` or `--out=x`), with its index */
81
+ function flagAt(args: readonly string[], flag: string): { i: number; value: string; joined: boolean } | null {
82
+ for (let i = 0; i < args.length; i++) {
83
+ const a = args[i]!
84
+ if (a === flag && i + 1 < args.length) return { i, value: args[i + 1]!, joined: false }
85
+ if (a.startsWith(`${flag}=`)) return { i, value: a.slice(flag.length + 1), joined: true }
86
+ }
87
+ return null
88
+ }
89
+
90
+ /** the film contract on one call: a usage error, or the args with `--out`
91
+ * made absolute against the caller's cwd and the file the run must leave */
92
+ export function filmCall(film: Film | undefined, args: readonly string[], cwd: string): { error: string } | { args: string[]; out: string | null } {
93
+ if (film == null) return { args: [...args], out: null }
94
+ if (film === 'still' && flagAt(args, '--seconds') != null) return { error: '--seconds films a clip, and this row films a still' }
95
+ const out = flagAt(args, '--out')
96
+ if (out == null) return { args: [...args], out: null }
97
+ const abs = isAbsolute(out.value) ? out.value : resolve(cwd, out.value)
98
+ const next = [...args]
99
+ if (out.joined) next[out.i] = `--out=${abs}`
100
+ else next[out.i + 1] = abs
101
+ return { args: next, out: abs }
102
+ }
103
+
104
+ const isHelp = (a: string): boolean => a === '--help' || a === '-h'
105
+
106
+ /** runs an argv from `cwd` with the terminal handed over; its exit code */
107
+ export async function spawnHere(argv: string[], cwd: string): Promise<number> {
108
+ const p = Bun.spawn(argv, { cwd, env: process.env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
109
+ return await p.exited
110
+ }
111
+
112
+ /** the row each class was made from, so the docs print what it runs */
113
+ export const ROW_OF = new WeakMap<object, Verb>()
114
+
115
+ /** one clipanion command per row: the args after the path go to the code as typed */
116
+ export function verbs(rows: Verb[]): CommandClass[] {
117
+ return rows.map((v) => {
118
+ class VerbCommand extends Command<KitContext> {
119
+ static override paths = [v.path.split(' ')]
120
+ static override usage = Command.Usage({ category: v.category, description: v.description, details: v.details, examples: v.examples })
121
+ static essential = v.essential
122
+ static film = v.film
123
+ args = Option.Proxy()
124
+ async execute(): Promise<number> {
125
+ const { root, cwd } = this.context
126
+ if (this.args.some(isHelp)) {
127
+ this.context.stdout.write(this.cli.usage(VerbCommand, { detailed: true }))
128
+ if (v.help !== 'own') return 0
129
+ this.context.stdout.write(`\nthe tool's own help:\n\n`)
130
+ return spawnHere(argvOf(v.run, this.args), root)
131
+ }
132
+ const call = filmCall(v.film, this.args, cwd)
133
+ if ('error' in call) { this.context.stderr.write(`FAIL: ${v.path}: ${call.error}\n`); return 2 }
134
+ let code: number | null = null
135
+ if ('tool' in v.run && this.context.askTool != null) code = await this.context.askTool(v.run.tool, [...(v.run.pre ?? []), ...call.args])
136
+ code ??= await spawnHere(argvOf(v.run, call.args), root)
137
+ if (code === 0 && call.out != null && !existsSync(call.out)) {
138
+ this.context.stderr.write(`FAIL: ${v.path} exited 0 and wrote no ${call.out}\n fix: a filmable row writes its --out path\n`)
139
+ return 1
140
+ }
141
+ return code
142
+ }
143
+ }
144
+ ROW_OF.set(VerbCommand, v)
145
+ return VerbCommand
146
+ })
147
+ }
148
+
149
+ /** the marks a command class carries: set by `verbs()` from its row, or
150
+ * declared as statics on a hand written class */
151
+ export function marksOf(c: object): { essential?: Essential; film?: Film } {
152
+ const o = c as { essential?: unknown; film?: unknown }
153
+ const out: { essential?: Essential; film?: Film } = {}
154
+ if (typeof o.essential === 'object' && o.essential !== null) out.essential = o.essential as Essential
155
+ if (o.film === 'still' || o.film === 'clip') out.film = o.film
156
+ return out
157
+ }
@@ -0,0 +1,3 @@
1
+ /** a repeatable flag's values, each also split on commas: `--only a,b` is `--only a --only b` */
2
+ export const listFlag = (values: readonly string[] | undefined): string[] =>
3
+ (values ?? []).flatMap((v) => v.split(',')).map((v) => v.trim()).filter((v) => v !== '')
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { Builtins, Cli } from 'clipanion'
7
7
  import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
8
  import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
9
9
  import { BumpCommand } from './cli/cmd_Bump.ts'
10
+ import { CallsCommand } from './cli/cmd_Calls.ts'
10
11
  import { CheckCommand } from './cli/cmd_Check.ts'
11
12
  import { CommandsCommand } from './cli/cmd_Commands.ts'
12
13
  import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
@@ -35,8 +36,10 @@ import { CacheAuditCommand } from './cli/cmd_CacheAudit.ts'
35
36
  import { TraceAuditCommand } from './cli/cmd_TraceAudit.ts'
36
37
  import { ServeCommand } from './cli/cmd_Serve.ts'
37
38
  import { SmokeCommand } from './cli/cmd_Smoke.ts'
39
+ import { ShotCommand } from './cli/cmd_Shot.ts'
38
40
  import { StatusCommand } from './cli/cmd_Status.ts'
39
41
  import { StartCommand } from './cli/cmd_Start.ts'
42
+ import { TrailerCheckCommand, TrailerCommand, TrailerShotCommand } from './cli/cmd_Trailer.ts'
40
43
  import { TuiCommand } from './cli/cmd_Tui.ts'
41
44
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
42
45
 
@@ -67,6 +70,9 @@ export function buildCli(): Cli {
67
70
  cli.register(MvCommand)
68
71
  cli.register(CheckCommand)
69
72
  cli.register(TuiCommand)
73
+ cli.register(TrailerCommand)
74
+ cli.register(TrailerCheckCommand)
75
+ cli.register(TrailerShotCommand)
70
76
  cli.register(ServeCommand)
71
77
  cli.register(PmCommand)
72
78
  cli.register(DaemonCommand)
@@ -91,6 +97,7 @@ export function buildCli(): Cli {
91
97
  cli.register(FleetCommand)
92
98
  cli.register(PbCollectionCommand)
93
99
  cli.register(SmokeCommand)
100
+ cli.register(ShotCommand)
94
101
  cli.register(ReflectCommand)
95
102
  cli.register(KeysCommand)
96
103
  cli.register(GlobalProcessesCommand)
@@ -109,6 +116,7 @@ export function buildCli(): Cli {
109
116
  cli.register(TraceAuditCommand)
110
117
  cli.register(UnitsAliasCommand)
111
118
  cli.register(CommandsCommand)
119
+ cli.register(CallsCommand)
112
120
  cli.register(ConfigKeysCommand)
113
121
 
114
122
  return cli
@@ -117,14 +125,17 @@ export function buildCli(): Cli {
117
125
  // only when this file IS the process, so importing it costs nothing. A repo's
118
126
  // installed copy first hands the command to the house source (handoff.ts). One copy
119
127
  // of the lib per process: a repo config that imports its own install lands on
120
- // this one (one-lib.ts), so the machine's version runs in every repo. Then pm
121
- // is started when it is down (autostart.ts), before the command runs
128
+ // this one (one-lib.ts), so the machine's version runs in every repo. The run
129
+ // is logged (cli/kit/calls.ts, read back by `sk calls`). Then pm is started
130
+ // when it is down (autostart.ts), before the command runs
122
131
  if (import.meta.main) {
123
132
  const argv = aliasArgs(process.argv.slice(2))
124
133
  const { handoff } = await import('./home/handoff.ts')
125
134
  handoff(argv)
126
135
  const { installOneLib } = await import('./core/one-lib.ts')
127
136
  installOneLib()
137
+ const { callsFile, logCall } = await import('./cli/kit/calls.ts')
138
+ logCall(callsFile((await import('os')).homedir(), 'sk'), 'sk', argv)
128
139
  const { autostart } = await import('./home/autostart.ts')
129
140
  await autostart(argv)
130
141
  void buildCli().runExit(argv)
@@ -22,6 +22,9 @@ export const LAYOUT = {
22
22
  // a bug's repro test once its fix lands: kept for the record, never run
23
23
  // (bun test skips dot dirs), moved into the suite only when it earns its wait
24
24
  repros: sk('repros'),
25
+ // plans, arc boards and lane files: live state a crashed session resumes
26
+ // from. Tracked in a private repo only, since a board quotes the owner
27
+ plans: sk('plans'),
25
28
  // local: drafts, private notes, venue listings until they go live
26
29
  private: sk('private'),
27
30
  reflections: sk('reflections'),
@@ -33,8 +36,10 @@ export const LAYOUT = {
33
36
  discord: sk('discord'),
34
37
  // a review with no output file named by its caller (YYYYMMDD-HHMMSS-<name>.md)
35
38
  reviews: sk('reviews'),
36
- // scratch: plans, logs, leases, detached runs, screenshots
39
+ // gitignored scratch, never a plan home: logs, leases, detached runs, screenshots
37
40
  tmp: sk('tmp'),
41
+ // a CLI's call log, `<cli>-calls.jsonl`: local, never tracked
42
+ logs: sk('logs'),
38
43
  // built app bundles, one per global process opened as an app (home/gui-app.ts)
39
44
  wrapper: sk('wrapper'),
40
45
  // generated on every run
@@ -44,7 +49,10 @@ export const LAYOUT = {
44
49
  } as const
45
50
 
46
51
  /** the children the SK004 block re-includes: everything else under `.shipkit/` stays local */
47
- export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros] as const
52
+ export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros, LAYOUT.plans] as const
53
+
54
+ /** a plan file name: `YYYYMMDD-HHMMSS-<slug>.md` */
55
+ export const PLAN_FILE = /^\d{8}-\d{6}-.+\.md$/
48
56
 
49
57
  /** the machine-global private folder, under $HOME: one keyword list and one
50
58
  * allowlist per machine, never copied into a repo */
@@ -3,8 +3,8 @@
3
3
  // repair a contract folder typed under a wrong name, in ONE pass and ONE commit.
4
4
  //
5
5
  // Nothing here is a decision, so no human is asked: `.rv-<x>/` became
6
- // `.shipkit/<x>/`, `asks/`, `hooks/` and the `tmp/` scratch moved in, the
7
- // generated files moved out of the root. A typo (`rv-reflections`, `.rv-reflection`) is the same
6
+ // `.shipkit/<x>/`, `asks/`, `hooks/` and the `tmp/` scratch moved in (its dated
7
+ // plans into `.shipkit/plans/`), the generated files moved out of the root. A typo (`rv-reflections`, `.rv-reflection`) is the same
8
8
  // move, because a folder under a name no scanner reads is silence.
9
9
  //
10
10
  // The invariant a reader breaks first: A MOVE NEVER CHANGES WHAT GIT TRACKS.
@@ -27,7 +27,7 @@ import { dirname, join, posix } from 'path'
27
27
  import type { RepoConfig } from '../repo-config.ts'
28
28
  import { gitBin } from './git-bin.ts'
29
29
  import { probeGitRoot } from './git-root.ts'
30
- import { LAYOUT, MACHINE_PRIVATE, SHIPKIT_DIR } from './layout.ts'
30
+ import { LAYOUT, MACHINE_PRIVATE, PLAN_FILE, SHIPKIT_DIR } from './layout.ts'
31
31
  import { machineCommit } from './machine-commit.ts'
32
32
  import { nestedIn } from './nesting.ts'
33
33
 
@@ -41,9 +41,11 @@ const CONFIG_FILE = 'repo.config.ts'
41
41
  * hole where the list should be */
42
42
  export function layoutSummary(r: { moved: LayoutMove[]; removed: string[] }): string {
43
43
  const list = (xs: string[]): string => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`)
44
- const folders = r.moved.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`)
44
+ const folders = r.moved.filter((m) => m.kind !== 'cache' && m.kind !== 'plan').map((m) => `${m.from}/`)
45
+ const plans = r.moved.filter((m) => m.kind === 'plan').length
45
46
  const parts = [
46
47
  ...(folders.length > 0 ? [`move ${list(folders)} under ${SHIPKIT_DIR}/`] : []),
48
+ ...(plans > 0 ? [`file ${plans} plan${plans === 1 ? '' : 's'} under ${LAYOUT.plans}/`] : []),
47
49
  ...(r.removed.length > 0 ? [`drop the root ${list(r.removed)}, which shipkit writes under ${SHIPKIT_DIR}/`] : []),
48
50
  ]
49
51
  return parts.join(', and ')
@@ -76,7 +78,8 @@ const shapeOf = (s: string): string => s.toLowerCase().replace(/^\.+/, '').repla
76
78
 
77
79
  const LEGACY_NAME = /^\.rv-([a-z0-9][a-z0-9-]*)$/
78
80
 
79
- export type MoveKind = 'contract' | 'typo' | 'asks' | 'hooks' | 'cache' | 'tmp'
81
+ /** 'plan' moves one FILE, every other kind a folder */
82
+ export type MoveKind = 'contract' | 'typo' | 'asks' | 'hooks' | 'cache' | 'tmp' | 'plan'
80
83
  /** a move onto a folder that already exists, whose files collide nowhere.
81
84
  * `drop`: stale leases left behind rather than moved over the live ones */
82
85
  export type MergeMove = LayoutMove & { merge: true; drop?: string[] }
@@ -97,6 +100,10 @@ export type PlanInput = {
97
100
  legacyCache?: boolean
98
101
  /** a clone or a worktree sits somewhere under `tmp/` */
99
102
  tmpHoldsRepo?: boolean
103
+ /** file names at the top of `tmp/` */
104
+ tmpFiles?: string[]
105
+ /** names already under `.shipkit/plans/` */
106
+ planEntries?: string[]
100
107
  }
101
108
 
102
109
  /** a `.git` anywhere under a folder: a clone or a linked worktree, whose
@@ -146,7 +153,15 @@ export function planLayoutMigration(i: PlanInput): LayoutPlan {
146
153
  // every repo's scratch, shipkit's own logs and the repo's plans alike
147
154
  if (i.rootDirs.includes(LEGACY_TMP)) {
148
155
  if (i.tmpHoldsRepo === true) refusals.push({ from: LEGACY_TMP, to: LAYOUT.tmp, reason: 'a git checkout sits under it, and a moved worktree loses its registration' })
149
- else add(LEGACY_TMP, LAYOUT.tmp, 'tmp')
156
+ else {
157
+ // a dated plan is live state, so it lands where plans live, never in
158
+ // the scratch any agent may empty. A name already taken there stays
159
+ // with the scratch rather than overwrite a plan
160
+ const taken = new Set(i.planEntries ?? [])
161
+ for (const f of (i.tmpFiles ?? []).filter((f) => PLAN_FILE.test(f) && !taken.has(f)).sort())
162
+ moves.push({ from: `${LEGACY_TMP}/${f}`, to: `${LAYOUT.plans}/${f}`, kind: 'plan' })
163
+ add(LEGACY_TMP, LAYOUT.tmp, 'tmp')
164
+ }
150
165
  }
151
166
  return { moves, refusals }
152
167
  }
@@ -293,7 +308,9 @@ function mergeWhereNothingCollides(root: string, plan: LayoutPlan): LayoutPlan {
293
308
  let clash = true
294
309
  let drop: string[] = []
295
310
  try {
296
- const both = twin || !isDir(from) || !isDir(to) ? null : filesUnder(from).filter((f) => existsSync(join(to, f)))
311
+ // a plan leaves the scratch before it moves, so it collides nowhere
312
+ const filed = new Set(moves.filter((m) => m.kind === 'plan').map((m) => m.from.slice(r.from.length + 1)))
313
+ const both = twin || !isDir(from) || !isDir(to) ? null : filesUnder(from).filter((f) => !filed.has(f) && existsSync(join(to, f)))
297
314
  drop = scratch && both != null ? both.filter((f) => STALE_LEASE.test(f)) : []
298
315
  clash = both == null || both.length > drop.length
299
316
  } catch { clash = true }
@@ -361,6 +378,10 @@ const dirsIn = (root: string): string[] => {
361
378
 
362
379
  const isDir = (p: string): boolean => { try { return statSync(p).isDirectory() } catch { return false } }
363
380
 
381
+ const filesIn = (dir: string): string[] => {
382
+ try { return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name) } catch { return [] }
383
+ }
384
+
364
385
  /** does core.hooksPath point at the legacy `hooks/`, holding a shipkit pre-commit */
365
386
  export function legacyHooksOurs(root: string): boolean {
366
387
  const r = git(root, ['config', 'core.hooksPath'])
@@ -405,13 +426,15 @@ export function readPlanInput(root: string, config?: RepoConfig): PlanInput {
405
426
  hooksOurs: existsSync(join(root, 'hooks')) && legacyHooksOurs(root),
406
427
  legacyCache: isDir(join(root, LEGACY_CACHE)),
407
428
  tmpHoldsRepo: isDir(join(root, LEGACY_TMP)) && holdsRepo(join(root, LEGACY_TMP)),
429
+ tmpFiles: filesIn(join(root, LEGACY_TMP)),
430
+ planEntries: isDir(join(root, LAYOUT.plans)) ? readdirSync(join(root, LAYOUT.plans)) : [],
408
431
  }
409
432
  }
410
433
 
411
434
  /** a repo still carrying the old layout: the names a check reports */
412
435
  export function legacyLayoutLeft(root: string, config?: RepoConfig): string[] {
413
436
  const plan = planLayoutMigration(readPlanInput(root, config))
414
- return [...plan.moves.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`), ...plan.refusals.map((r) => `${r.from}/`)]
437
+ return [...plan.moves.filter((m) => m.kind !== 'cache' && m.kind !== 'plan').map((m) => `${m.from}/`), ...plan.refusals.map((r) => `${r.from}/`)]
415
438
  }
416
439
 
417
440
  const lsFiles = (root: string, paths: string[]): string[] => {
@@ -59,7 +59,7 @@ export const RAW_RUNNERS: readonly RawRunner[] = [
59
59
  * segment at a time, so `cd x && bun test` is caught too */
60
60
  export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
61
61
  const off = new Set(exemptSlots)
62
- return [...RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`), GIT_STASH_RULE]
62
+ return [...RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`), ...GIT_STASH_RULES]
63
63
  }
64
64
 
65
65
  // `git stash` in a tree that other agents share takes every agent's
@@ -69,11 +69,19 @@ export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
69
69
  // `bash -c '...'`, after git's own global options (`git -C dir stash`), or
70
70
  // through `eval`: the guard closes those (stashCallIn).
71
71
  export const GIT_STASH_HEAD = 'git stash'
72
- export const GIT_STASH_RULE = `Bash(${GIT_STASH_HEAD}*)`
73
72
 
74
- /** the stash subcommands that only read, which the guard lets through */
73
+ /** the stash subcommands that only read: no deny row and no guard refusal touches them */
75
74
  const STASH_READS = new Set(['list', 'show'])
76
75
 
76
+ /** the stash forms that move work, one deny row each. Claude Code matches a row by prefix, so one blanket
77
+ * `Bash(git stash*)` row also refused `git stash list` and `git stash show`. `Bash(git stash)` (no star) is the bare
78
+ * form, `git stash -*` the option led ones (`git stash -u`, `-m msg`), both meaning push */
79
+ const STASH_WRITES = ['push', 'pop', 'apply', 'drop', 'clear', 'save', 'store', 'create', 'branch'] as const
80
+ export const GIT_STASH_RULES: readonly string[] = [`Bash(${GIT_STASH_HEAD})`, `Bash(${GIT_STASH_HEAD} -*)`, ...STASH_WRITES.map((w) => `Bash(${GIT_STASH_HEAD} ${w}*)`)]
81
+
82
+ /** the blanket row older repos carry: it blocks the reads too, SK013 replaces it with GIT_STASH_RULES */
83
+ export const LEGACY_STASH_RULE = `Bash(${GIT_STASH_HEAD}*)`
84
+
77
85
  /** git's global options that take their value as the NEXT word */
78
86
  const GIT_VALUE_OPTS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path'])
79
87
 
@@ -158,7 +166,7 @@ export function stashCallIn(command: string, depth = 0): string | null {
158
166
 
159
167
  /** what a stash refusal says: why, what to do instead, how to lift it */
160
168
  export function stashRefusal(segment: string): string {
161
- return `\`${segment.slice(0, 80)}\` stashes: in a tree that other agents share, a stash takes every agent's uncommitted work, not just yours. Set your own work aside with a commit by pathspec (git commit -m … -- <paths>), and read an existing stash by revision: \`git log -g --oneline refs/stash\`, \`git show stash@{0}:<file>\`. Lifting this: delete the "${GIT_STASH_RULE}" line from .claude/settings.json permissions.deny (identity/how-we-commit.md).`
169
+ return `\`${segment.slice(0, 80)}\` stashes: in a tree that other agents share, a stash takes every agent's uncommitted work, not just yours. Set your own work aside with a commit by pathspec (git commit -m … -- <paths>), and read an existing stash by revision: \`git log -g --oneline refs/stash\`, \`git show stash@{0}:<file>\`. Reading a stash (\`git stash list\`, \`git stash show\`) is never refused. SK013 writes these rows in every shipkit repo (identity/how-we-commit.md): lifting them is a change to that rule, not a settings edit.`
162
170
  }
163
171
 
164
172
  /** what a refusal says. Never just the ban: a refusal with no replacement
package/src/mv/mv-md.ts CHANGED
@@ -154,7 +154,8 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
154
154
  let touched = 0
155
155
  for (const f of tracked) {
156
156
  const p = join(root, f)
157
- if (!existsSync(p)) continue
157
+ // a submodule is one ls-files entry and a directory: nothing to read
158
+ if (!isFile(p)) continue
158
159
  const before = readFileSync(p, 'utf8')
159
160
  let after = retargetRelativeRefs(root, before, dirOf(f), dirOf(moves.get(f) ?? f), moves)
160
161
  for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
@@ -67,6 +67,11 @@ const NOISE_PREFIXES = [
67
67
  'Caveat: The messages below',
68
68
  '[Request interrupted',
69
69
  'This session is being continued from a previous conversation',
70
+ // model output parked in the queue (or wrapped as a meta entry): a
71
+ // sub-agent's hand-back, and a message another Claude session sent
72
+ '<agent-message',
73
+ '<cross-session-message',
74
+ 'Another Claude session sent a message',
70
75
  ]
71
76
 
72
77
  export function isTypedPrompt(text: string): boolean {
@@ -0,0 +1,149 @@
1
+ // the project CLI a started repo gets on day one: `src/cli.ts` on the kit
2
+ // (cli/kit/), named for the project, one example family, and the package.json
3
+ // `cli` script plus a `bin` of the project's name. Pure text here; the scaffold
4
+ // writes it create-only, so a repo that already has any of it keeps its own.
5
+ import { Cli } from 'clipanion'
6
+ import { commandRows, type Definition, newCommandsPage, renderEssentials, renderMarkdown, spliceBlock } from '../cli/cmd_Commands.ts'
7
+ import { builtinCommands } from '../cli/kit/builtins.ts'
8
+ import { type Verb, verbs } from '../cli/kit/verb.ts'
9
+
10
+ /** the entry: the family table and nothing else. `absolute`: the repo's
11
+ * tsconfig maps `src/*`, the house import form; else a relative import */
12
+ export function cliEntry(name: string, absolute = true): string {
13
+ return `#!/usr/bin/env bun
14
+ // ${name}: the project CLI, one command for every act an agent or a human does in this repo.
15
+ // A family is src/cli/cmd_<Family>.ts exporting \`commands\`; the table names the first words
16
+ // each family owns, so a call loads its family alone. \`${name}\` lists every command.
17
+ import { resolve } from 'path'
18
+ import { runProjectCli } from 'rvlib-shipkit/cli-kit'
19
+
20
+ await runProjectCli({
21
+ name: '${name}',
22
+ root: resolve(import.meta.dir, '..'),
23
+ families: [
24
+ { roots: ['dev', 'shot'], load: () => import('${absolute ? 'src/cli/cmd_Dev.ts' : './cli/cmd_Dev.ts'}') },
25
+ ],
26
+ })
27
+ `
28
+ }
29
+
30
+ /** the example family's live row, as data: the source text and the day-one
31
+ * pages are both made from it, so they cannot disagree */
32
+ export function exampleRow(name: string, devScript = false): Verb {
33
+ return {
34
+ path: 'dev',
35
+ category: 'Run',
36
+ description: devScript ? 'the dev process (package.json dev)' : 'the dev process: src/index.ts, restarted on save',
37
+ examples: [['start it', `${name} dev`]],
38
+ run: devScript ? { cmd: ['bun', 'run', 'dev'] } : { cmd: ['bun', '--watch', 'src/index.ts'] },
39
+ essential: {},
40
+ }
41
+ }
42
+
43
+ const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
44
+
45
+ /** the example family: a row over code that exists, and the shot row a visual
46
+ * project uncomments once it has a renderer */
47
+ export function exampleFamily(name: string, devScript = false): string {
48
+ const v = exampleRow(name, devScript)
49
+ const run = 'cmd' in v.run ? `{ cmd: [${v.run.cmd.map(q).join(', ')}] }` : ''
50
+ return `// a command over code that exists is a row: run is { bun: <script> }, { cmd: [...] }
51
+ // or { tool, cold } (a warm host, else the cold script). Mark an everyday row
52
+ // \`essential: {}\`. \`sk ci\` rewrites the agent pages (cli-pages.check.shipkit.ts), or: ${pagesCommand(name)}
53
+ import { verbs } from 'rvlib-shipkit/cli-kit'
54
+
55
+ export const commands = verbs([
56
+ {
57
+ path: ${q(v.path)},
58
+ category: ${q(v.category)},
59
+ description: ${q(v.description)},
60
+ examples: [[${v.examples[0]!.map(q).join(', ')}]],
61
+ run: ${run},
62
+ essential: {},
63
+ },
64
+ // a visual project: any state as a png, the same command filmed by a trailer line.
65
+ // film: 'still' means the script takes --out <path>; 'clip' also takes --seconds <n>.
66
+ // {
67
+ // path: 'shot',
68
+ // category: 'Look',
69
+ // description: 'any state of the app as a png',
70
+ // examples: [['the home screen', '${name} shot home --out shot.png']],
71
+ // run: { bun: 'scripts/shot.ts' },
72
+ // film: 'still',
73
+ // },
74
+ ])
75
+ `
76
+ }
77
+
78
+ /** where a project CLI's two agent pages live */
79
+ export const cliPages = (name: string): { all: string; essentials: string } => ({ all: `agent/${name}.md`, essentials: `agent/${name}-essentials.md` })
80
+
81
+ /** the command that writes both pages */
82
+ export const pagesCommand = (name: string): string => `${name} commands --out ${cliPages(name).all} --essentials ${cliPages(name).essentials}`
83
+
84
+ /** the day-one pages, exactly as \`<name> commands --out … --essentials …\` writes them */
85
+ export function skeletonPages(name: string, devScript = false): { all: string; essentials: string } {
86
+ const cli = new Cli({ binaryName: name })
87
+ const builtins = builtinCommands(name)
88
+ const classes = [...verbs([exampleRow(name, devScript)]), ...builtins.docs, ...builtins.calls]
89
+ for (const c of classes) cli.register(c)
90
+ const defs = cli.definitions() as Definition[]
91
+ const rows = commandRows(defs, (cli.definitions as (o?: { detailed?: boolean }) => Definition[])({ detailed: true }), name, classes)
92
+ return {
93
+ all: spliceBlock(newCommandsPage(name, cliPages(name).all), renderMarkdown(rows, name), name)!,
94
+ essentials: renderEssentials(rows, name, pagesCommand(name)),
95
+ }
96
+ }
97
+
98
+ /** the package.json keys the project CLI needs, only where the key is free.
99
+ * Returns the keys it filled */
100
+ export function addCliKeys(pkg: { scripts?: Record<string, string>; bin?: Record<string, string> | string }, name: string): string[] {
101
+ const filled: string[] = []
102
+ pkg.scripts ??= {}
103
+ if (pkg.scripts.cli == null) { pkg.scripts.cli = 'bun src/cli.ts'; filled.push('scripts.cli') }
104
+ if (pkg.bin == null) { pkg.bin = { [name]: './src/cli.ts' }; filled.push(`bin.${name}`) }
105
+ return filled
106
+ }
107
+
108
+ /** what says a repo already has a CLI, its own or its product's: a `cli`
109
+ * script, any `bin`, any script that runs a cli.ts or cli.js of the repo, a
110
+ * src/cli.ts or a src/cli/ folder. null = the skeleton has room */
111
+ export function existingCli(pkg: { scripts?: Record<string, string>; bin?: unknown } | null, files: { entry: boolean; cliDir: boolean }): string | null {
112
+ if (pkg?.scripts?.cli != null) return `package.json scripts.cli runs \`${pkg.scripts.cli}\``
113
+ if (pkg?.bin != null) return `package.json has a bin (${typeof pkg.bin === 'string' ? pkg.bin : Object.keys(pkg.bin as object).join(', ')})`
114
+ // a script running an installed tool's cli (the house `hub` runs shipkit's) is not the repo's own
115
+ const runner = Object.entries(pkg?.scripts ?? {}).find(([, body]) => body.split(/\s+/).some((w) => /\bcli\.(ts|js)$/.test(w) && !/node_modules\/|rvlib-shipkit/.test(w)))
116
+ if (runner != null) return `package.json scripts.${runner[0]} runs \`${runner[1]}\``
117
+ if (files.entry) return 'src/cli.ts exists'
118
+ if (files.cliDir) return 'src/cli/ exists'
119
+ return null
120
+ }
121
+
122
+ /** whether an installed rvlib-shipkit carries the kit: null = none installed */
123
+ export function kitInstalled(installedPkg: { exports?: Record<string, unknown> } | null): boolean | null {
124
+ if (installedPkg == null) return null
125
+ return installedPkg.exports?.['./cli-kit'] != null
126
+ }
127
+
128
+ /** the check file that keeps both agent pages equal to the registry: the gate
129
+ * fails a stale page and its fix pass writes it again */
130
+ export function pagesCheckFile(name: string): string {
131
+ return `// agent/${name}.md and agent/${name}-essentials.md list the ${name} commands as the registry has them.
132
+ // A stale page sends an agent at a command that does not exist; the gate's fix pass writes both again
133
+ import { check } from 'rvlib-shipkit/src/check.ts'
134
+ import { cliPagesCheck } from 'rvlib-shipkit/src/cli/kit/pages-check.ts'
135
+
136
+ const pages = cliPagesCheck('${name}')
137
+
138
+ export default check({
139
+ description: pages.description,
140
+ blocking: true,
141
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
142
+ cache: { by: 'files', inputs: pages.inputs, key: null },
143
+ fix: 'ci',
144
+ applies: 'always',
145
+ run: pages.run,
146
+ repair: pages.repair,
147
+ })
148
+ `
149
+ }
@@ -12,14 +12,14 @@ BEFORE the change it describes.
12
12
 
13
13
  **Always loaded every session:**
14
14
 
15
- @coding.md
15
+ @coding.md{{CLI_INCLUDE}}
16
16
 
17
17
  Read the matching page before touching a surface:
18
18
 
19
19
  | read when touching… | page |
20
20
  |---|---|
21
21
  | any source file | `agent/coding.md` |
22
- | recent changes and why | `agent/changelog.md` |
22
+ | recent changes and why | `agent/changelog.md` |{{CLI_ROW}}
23
23
 
24
24
  Grow this table: a subsystem, a service, a runbook each earn their own page,
25
25
  cross-linked from here.
@@ -23,6 +23,9 @@
23
23
  "scripts/*": ["./scripts/*"]
24
24
  },
25
25
 
26
+ // no @types package loads unless named here: bun's globals (Bun, import.meta.dir)
27
+ "types": ["bun"],
28
+
26
29
  // every file is a module even with no import/export (standalone scripts)
27
30
  "moduleDetection": "force",
28
31
 
@@ -12,7 +12,7 @@ description: Prove a change in THIS project actually works — drive the real fl
12
12
  ## The rules (house-wide)
13
13
  - Failing repro test BEFORE the fix. Green checks ≠ verification.
14
14
  - Logic → fast headless bun tests, run through `sk ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <test file>` is the narrow way in, and one test by name is `bun run test:raw -- -t "<name>"`.
15
- - Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
15
+ - Look & feel → your own screenshot is a check before you report, never the verdict; the repo owner playtests it.
16
16
  - Verify as the end user would: drive the real flow, observe the real signal.
17
17
 
18
18
  ## This project