rvlib-shipkit 0.32.0 → 0.33.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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.0
4
+
5
+ - **ONE WAY IN, enforced. A wired repo's `test`, `lint` and `typecheck` scripts route through shipkit, and the harness refuses the raw runner.** Measured in a real wired repo before this: `"lint": "eslint ."`, `"test": "bun test"`, no ci script, an empty `.claude/`. An agent doing the obvious thing ran the tool, shipkit never heard the call, and the cache it was building was worthless. **This changes the shape of every wired repo's package.json**, and 0.32.0 already shipped the first half of it without saying so, which is the gap this note closes. Rule SK001 (blocking, autofixable) now FAILs a governed script that runs the tool directly; the fix slides the body to `<id>:raw` and makes the name `shipkit ci --only <id>`. `<id>:raw` is what the gate itself runs, and what you use to iterate on one suite. Those THREE names only: `imports:check` and `cycles` are shipkit's own vocabulary, and `fmt` is the slot the fix pass invokes by script, so routing it would make the fixer call itself. `resolveFloor` moved to `core/floor.ts` and refuses to run a routed script, which spawns the gate from inside the gate.
6
+ - **New rule SK013 (blocking, autofixable) writes the harness half**, because a package.json cannot reach it: `bun test` is a bun builtin and no script can intercept it. A tracked `.claude/settings.json` gets `permissions.deny` for every raw runner plus a PreToolUse guard, both rendered from ONE table. The deny is the floor, unbypassable in either direction; the guard is the messenger, because a bare deny tells the model only that it was denied and it then goes hunting for another runner. Every refusal names the shipkit command, the narrow way in, and the exact line that lifts it. The guard reads the deny list out of the settings FILE, so deleting one `Bash(...)` line lifts both mechanisms at once. It also catches what a prefix rule cannot (`CI=1 bun test`, `./node_modules/.bin/tsc`) and segments at quote depth zero, so a commit message quoting a runner is data. SK013 FAILs when the declared guard script does not resolve: a hook that errors refuses nothing.
7
+ - **`shipkit init` repairs an existing repo, not just a fresh one**, calling the same fixers `shipkit check --fix` runs. That is the half that matters, because the repos that need this most already exist. `shipkit start` inherits it.
8
+ - **A `--only` run repairs the mechanical slots ALONE.** The structural rule fixers rewrite package.json and repo config, and arming rewrites `hooks/`. That is the right price for the full gate and the wrong one for `bun run test`, which now routes here: the reflex verb must not leave files in an agent's diff that it never wrote, and under CI a formatting rewrite came back as a test failure.
9
+ - **Escaping it**: delete the `Bash(<runner>*)` line from `.claude/settings.json`; or `repo.config.ts oneWayIn: { exempt: ['test'] }`, which drops the script requirement and the shell rules together (an unknown name throws at config load); a slot turned off with `defaults: { lint: false }` is exempt already. `bun run test:raw <name filter>` stays open for iterating on one suite. A repo with sub-packages gives each one its own `test:raw`, or the escape does not reach where the work is. The formatter is deliberately not denied at all.
10
+ - `--allow-dirty` now says WHY it is safe in one place and not the other: `npm publish` packs the working tree, not the commit, so dirt in other paths cannot reach the tarball and dirt inside the unit ships a half-written file under a tag that describes something else.
11
+
3
12
  ## 0.32.0
4
13
 
5
14
  - **`shipkit pb collection <name>` scaffolds a PocketBase collection's whole UI folder.** Building a CRUD screen meant retyping a schema that already exists in the app's generated types, one column at a time, and the boring half of that is exactly where a column goes missing. The command reads `src/pb.types.ts` (`.tsx` and `.d.ts` too), resolves one collection, and writes `src/collections/<name>/`: `columns.tsx` with one entry per response field, `card.tsx`, `grid.tsx` over `LiveGrid`, `form.tsx` (a create/edit modal writing through `db` fire and forget), and an `index.ts` calling `defineCollectionUI`. It then registers the folder in `src/collections/index.ts`, creating that file when it is the first one, because a collection folder nothing imports is dead code whose failure mode is a runtime throw. A field the schema cannot turn into a cell on its own (relation, file, json, raw html) is emitted COMMENTED with the reason rather than dropped, because a missing column is invisible and a commented one takes ten seconds to accept or delete. The card leads on the fields a human reads, not on the first three of an alphabetical record block, and the label picks the first of `name`, `label`, `title`, `number`, `code`, `email`, else `id`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",
@@ -28,6 +28,9 @@
28
28
  "mobx": "^7.0.0",
29
29
  "mobx-react-lite": "^5.0.0"
30
30
  },
31
+ "scripts": {
32
+ "test:raw": "bun test"
33
+ },
31
34
  "dependencies": {
32
35
  "@napi-rs/canvas": "^1.0.2",
33
36
  "clipanion": "^4.0.0-rc.4",
@@ -60,7 +60,9 @@ export function routedBody(id: string, opts: ScriptStyle = {}): string {
60
60
  * under a shipkit checkout (a repo calling rvlib's source-graph cli by path)
61
61
  * is not a route back in. */
62
62
  export function routesToShipkit(body: string): boolean {
63
- return /\bshipkit\b/.test(body) && /\bci\b/.test(body)
63
+ // the `ci` verb must belong to the shipkit invocation, not merely appear
64
+ // somewhere in the line: `shipkit contracts && bun run x:ci` routes nothing
65
+ return /\bshipkit\b\S*(?:\s+\S+)*?\s+ci(?:\s|$)/.test(body.split(/&&|\|\||;/)[0] ?? body)
64
66
  }
65
67
 
66
68
  // recover the consumption style from existing scripts: any shipkit command
@@ -0,0 +1,183 @@
1
+ // ONE WAY IN, the harness half (build/shipkit.md). The package.json half
2
+ // (rule SK001) routes `bun run test` into shipkit, but `bun test` is a bun
3
+ // BUILTIN: no script can intercept it, so a package.json alone leaves the
4
+ // reflex wide open. Claude Code permission rules can close it.
5
+ //
6
+ // ONE table, two renderings, because two hand-kept lists drift:
7
+ // - `permissions.deny` in .claude/settings.json — declarative, unbypassable
8
+ // (deny beats allow at every settings level, and bypassPermissions mode
9
+ // does not lift it), and it survives a missing bun.
10
+ // - a PreToolUse hook — the only thing that can put a REASON in front of the
11
+ // model. A bare deny says "Permission to use Bash with command bun test
12
+ // has been denied." and nothing else, so the agent goes looking for
13
+ // another way in, which is the failure this whole file exists to prevent.
14
+ // The deny is the floor and the hook is the messenger. When the hook cannot
15
+ // run, enforcement degrades to terse rather than to nothing.
16
+ //
17
+ // Subprocesses are NOT intercepted (verified): shipkit spawning oxlint or
18
+ // `bun run test:raw` from inside a permitted `shipkit ci` is invisible to
19
+ // these rules, which is exactly what makes the gate itself still work.
20
+
21
+ export type RawRunner = {
22
+ /** the command head as an agent types it — the deny rule is this + `*` */
23
+ head: string
24
+ /** the floor slot it answers, so ONE exemption governs the script name and
25
+ * the shell command together: a repo that keeps `test` raw is not then
26
+ * refused `bun test` */
27
+ slot: 'test' | 'lint' | 'typecheck'
28
+ /** the shipkit command that answers the same question, cache and all */
29
+ use: string
30
+ /** the narrower way in, when there is one: iterating on a single repro
31
+ * test while you fix a bug is not a verification and stays normal. A
32
+ * FILTER, not a path: `bun run` quotes what it forwards, and bun reads a
33
+ * quoted path as a name filter that then matches nothing */
34
+ iterate?: string
35
+ }
36
+
37
+ // the house stack, plus the foreign runners a wired repo is actually found
38
+ // carrying. Kept tight on purpose: a deny an agent hits for no good reason
39
+ // teaches it to route around the whole file.
40
+ export const RAW_RUNNERS: readonly RawRunner[] = [
41
+ { head: 'bun test', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
42
+ { head: 'bunx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
43
+ { head: 'npx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
44
+ { head: 'vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
45
+ { head: 'npx jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
46
+ { head: 'jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
47
+ { head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
48
+ { head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
49
+ { head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
50
+ { head: 'bunx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
51
+ { head: 'npx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
52
+ { head: 'oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
53
+ { head: 'npx eslint', slot: 'lint', use: 'shipkit ci --only lint' },
54
+ { head: 'eslint', slot: 'lint', use: 'shipkit ci --only lint' },
55
+ ]
56
+
57
+ // the FORMATTER is deliberately absent, and that is not an oversight. It
58
+ // answers no cached question and produces no green anyone could mistake for a
59
+ // verification, shipkit's own fix pass runs it BY SCRIPT (`bun run fmt`), and
60
+ // denying the bare binary while that stays open would be theatre. `shipkit ci`
61
+ // formats before it gates, which is the only reason the reflex matters at all.
62
+
63
+ /** the `permissions.deny` entries, in the syntax Claude Code matches:
64
+ * `Bash(<head>*)` is a prefix match, and a compound command is checked one
65
+ * segment at a time, so `cd x && bun test` is caught too */
66
+ export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
67
+ const off = new Set(exemptSlots)
68
+ return RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`)
69
+ }
70
+
71
+ /** what a refusal says. Never just the ban: a refusal with no replacement
72
+ * sends the agent hunting for another runner. `segment` is the part of the
73
+ * command that offended, shown only when it says more than the head already
74
+ * does: a long compound refused on `CI=1 bun test --timeout 5` otherwise
75
+ * leaves the reader guessing which of its clauses was the problem */
76
+ export function refusal(r: RawRunner, segment?: string): string {
77
+ const alt = r.iterate == null ? '' : `, or \`${r.iterate}\` to iterate on ONE suite while you fix a bug`
78
+ const which = segment == null || segment.trim() === '' || segment.trim() === r.head ? '' : ` (refused on \`${segment.trim().slice(0, 80)}\`)`
79
+ return `\`${r.head}\`${which} bypasses shipkit, so nothing is cached and no other agent's run can be attached to. Use \`${r.use}\`${alt}. Lifting this for good: delete the "Bash(${r.head}*)" line from .claude/settings.json permissions.deny (build/shipkit.md, ONE WAY IN).`
80
+ }
81
+
82
+ /** shell wrappers that do not change what is being run */
83
+ const WRAPPERS = new Set(['time', 'timeout', 'nice', 'nohup', 'stdbuf', 'command', 'builtin', 'sudo', 'env'])
84
+
85
+ /** the heads a settings file actually denies. The guard asks THE FILE rather
86
+ * than the table, so deleting a `Bash(...)` line lifts the deny AND the
87
+ * hook's refusal in one edit: two mechanisms an agent has to find and lift
88
+ * separately is the deny it cannot escape, which is worse than the disease */
89
+ export function deniedHeads(settings: unknown): Set<string> {
90
+ const deny = (settings as { permissions?: { deny?: unknown } } | null)?.permissions?.deny
91
+ const out = new Set<string>()
92
+ if (!Array.isArray(deny)) return out
93
+ for (const rule of deny) {
94
+ const m = typeof rule === 'string' ? /^Bash\((.+?)\*?\)$/.exec(rule) : null
95
+ if (m != null) out.add(m[1]!.trim())
96
+ }
97
+ return out
98
+ }
99
+
100
+ /** a word that could be the name of a program. Anything carrying a quote, a
101
+ * parenthesis or a bracket is prose or code, not an invocation, and matching
102
+ * it is how a guard earns a reputation for crying wolf */
103
+ const PROGRAM_WORD = /^[\w./@+-]+$/
104
+
105
+ /** split a command line on the shell operators, at QUOTE DEPTH ZERO only.
106
+ * Without that, `git commit -m "why cd x && bun test is refused"` splits
107
+ * inside the message and the second half reads as a command. House commit
108
+ * bodies are dense and multi-line, so this is not a corner case here, it is
109
+ * the most common shape of command an agent runs. */
110
+ export function shellSegments(command: string): string[] {
111
+ // everything after a heredoc opener is the document, never a command
112
+ const shell = command.split(/<<-?\s*['"]?\w+/)[0] ?? command
113
+ const out: string[] = []
114
+ let cur = ''
115
+ let quote: '"' | "'" | null = null
116
+ for (let i = 0; i < shell.length; i++) {
117
+ const ch = shell[i]!
118
+ if (quote != null) {
119
+ cur += ch
120
+ if (ch === '\\' && quote === '"') { cur += shell[++i] ?? ''; continue }
121
+ if (ch === quote) quote = null
122
+ continue
123
+ }
124
+ if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
125
+ if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
126
+ // `&&` and `||` are one operator, and a lone `&` backgrounds
127
+ if ((ch === '&' || ch === '|') && shell[i + 1] === ch) i++
128
+ out.push(cur)
129
+ cur = ''
130
+ continue
131
+ }
132
+ cur += ch
133
+ }
134
+ out.push(cur)
135
+ return out
136
+ }
137
+
138
+ /** the raw runner this shell command reaches for, plus the segment that
139
+ * offended, or null.
140
+ *
141
+ * Deliberately conservative in BOTH directions. It must catch the forms a
142
+ * prefix deny rule misses (a leading `CI=1`, `./node_modules/.bin/tsc`), and
143
+ * it must never fire on a command that merely CONTAINS the words: a heredoc
144
+ * body is data and so is a quoted commit message, and a false refusal teaches
145
+ * an agent to route around the whole file. So: the heredoc body is dropped,
146
+ * segments are cut at quote depth zero, each is reduced to its first program
147
+ * word, and that word must match a head exactly or as the basename of a path.
148
+ * Longest head first, so `bunx tsc` is never reported as `tsc`. `only`
149
+ * narrows to the heads this repo really denies. */
150
+ export function rawRunnerIn(command: string, only?: ReadonlySet<string>): RawRunner | null {
151
+ return rawRunnerHit(command, only)?.runner ?? null
152
+ }
153
+
154
+ export function rawRunnerHit(command: string, only?: ReadonlySet<string>): { runner: RawRunner; segment: string } | null {
155
+ const heads = [...RAW_RUNNERS]
156
+ .filter((r) => only == null || only.has(r.head))
157
+ .sort((a, b) => b.head.length - a.head.length)
158
+ for (const seg of shellSegments(command)) {
159
+ let s = seg.trim().replace(/^[(){}\s]+/, '')
160
+ // leading VAR=value assignments, then a wrapper word, repeatedly. A
161
+ // wrapper's own flags and durations go with it (`timeout 60 bun test`)
162
+ let wrapped = false
163
+ for (;;) {
164
+ const next = s.replace(/^\w+=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
165
+ if (next !== s) { s = next; continue }
166
+ const w = /^(\S+)\s+/.exec(s)
167
+ if (w != null && WRAPPERS.has(w[1]!.split('/').pop() ?? '')) { s = s.slice(w[0].length); wrapped = true; continue }
168
+ if (wrapped && w != null && /^(?:-\S*|\d+[smhd]?)$/.test(w[1]!)) { s = s.slice(w[0].length); continue }
169
+ break
170
+ }
171
+ const words = s.split(/\s+/).filter((w) => w !== '')
172
+ const first = words[0]
173
+ if (first == null || !PROGRAM_WORD.test(first)) continue
174
+ // a path-qualified binary counts: ./node_modules/.bin/tsc IS tsc
175
+ const program = first.split('/').pop() ?? first
176
+ for (const r of heads) {
177
+ const parts = r.head.split(' ')
178
+ if (parts[0] !== program) continue
179
+ if (parts.length === 1 || words[1] === parts[1]) return { runner: r, segment: seg }
180
+ }
181
+ }
182
+ return null
183
+ }
@@ -102,8 +102,16 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
102
102
  const c = o.config ?? (await loadRepoConfig(root))
103
103
  // mechanical failures are not information, so they never cost a round trip
104
104
  let fixFailed = false
105
+ // a NARROWED run repairs only the mechanical slots. The structural rule
106
+ // fixers rewrite package.json and repo config, and arming rewrites hooks/:
107
+ // that is the right price for the full gate and the wrong one for
108
+ // `bun run test`, which routes here and must answer the one question its
109
+ // name promises without leaving files in the agent's diff that it never
110
+ // wrote. Under CI it is worse than surprising, a formatting rewrite comes
111
+ // back as a test failure.
112
+ const narrowed = (o.onlyIds ?? []).length > 0
105
113
  if (o.fix !== false)
106
- fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
114
+ fixFailed = (await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })).failed
107
115
  else sink('skip: fix pass (--no-fix)')
108
116
  const all = allTasks(c, root)
109
117
  const { disabled, unfilled } = all
@@ -124,7 +132,14 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
124
132
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
125
133
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
126
134
  // look like a full one (the fresh-repo floor gap)
127
- for (const u of unfilled) sink(`skip: ${u.id} (${u.reason})`)
135
+ for (const u of unfilled) {
136
+ sink(`skip: ${u.id} (${u.reason})`)
137
+ // asked for BY NAME and unfillable: this is where an agent lands after
138
+ // the harness refused it the raw runner, so the line says what to add
139
+ // rather than leaving it with `unknown task id` and no next move
140
+ if ((o.onlyIds ?? []).includes(u.id))
141
+ sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
142
+ }
128
143
  // the release survey is ~4s of network that five discovered checks need
129
144
  // and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
130
145
  // costs the run nothing; started inside the first check that wants it, it
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bun
2
+ // the PreToolUse guard for ONE WAY IN (core/one-way-in-policy.ts owns the
3
+ // table and the wording). Its ONE job is to refuse a raw runner with the
4
+ // shipkit command to type instead: `permissions.deny` already blocks these,
5
+ // but a bare deny tells the model only that it was denied, and a refusal with
6
+ // no replacement just sends it looking for another way in.
7
+ //
8
+ // It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
9
+ // small json read, no clipanion, no repo config. Anything it cannot decide it
10
+ // waves through, because a guard that fails closed on its own bugs would wedge
11
+ // a repo with no way to tell why, and `permissions.deny` is still the floor
12
+ // underneath it. Measured at ~10ms per call.
13
+ import { deniedHeads, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
14
+
15
+ const input = await Bun.stdin.text()
16
+ let command = ''
17
+ try {
18
+ const ev = JSON.parse(input) as { tool_name?: string; tool_input?: { command?: string } }
19
+ if (ev.tool_name !== 'Bash') process.exit(0)
20
+ command = ev.tool_input?.command ?? ''
21
+ } catch {
22
+ process.exit(0)
23
+ }
24
+ // the settings file is the authority on WHICH runners this repo refuses, so
25
+ // deleting one `Bash(...)` line lifts the deny and this refusal together
26
+ const settingsPath = `${process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()}/.claude/settings.json`
27
+ let denied: Set<string>
28
+ try {
29
+ denied = deniedHeads(await Bun.file(settingsPath).json())
30
+ } catch {
31
+ process.exit(0)
32
+ }
33
+ const hit = command === '' || denied.size === 0 ? null : rawRunnerHit(command, denied)
34
+ if (hit == null) process.exit(0)
35
+ // exit 2 is the block: stderr becomes the reason the model reads
36
+ // naming the segment earns its place only when the command had MORE in it:
37
+ // on a bare call it repeats the head back at the reader
38
+ console.error(refusal(hit.runner, hit.segment.trim() === command.trim() ? undefined : hit.segment))
39
+ process.exit(2)
package/src/init-repo.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  import { existsSync, readFileSync, writeFileSync } from 'fs'
10
10
  import { join } from 'path'
11
11
  import { canonicalBodies } from './core/canonical-scripts.ts'
12
- import { hasTestFiles } from './rules/SK001-package-json-shape.ts'
12
+ import { hasTestFiles, packageJsonShape } from './rules/SK001-package-json-shape.ts'
13
+ import { oneWayInPolicy } from './rules/SK013-one-way-in-policy.ts'
13
14
  import { reportToMotherhome } from './core/motherhome.ts'
14
15
  import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
15
16
  import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
@@ -67,6 +68,9 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
67
68
  const cfg = await loadRepoConfig(root)
68
69
  const g = await gitignorePolicy.fix!({ root, config: cfg })
69
70
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
71
+ // the repos that need ONE WAY IN most are the ones that already exist,
72
+ // so a re-run REPAIRS rather than only seeding a fresh checkout
73
+ await repairOneWayIn(root, cfg, p)
70
74
  const mh = reportToMotherhome(root, cfg)
71
75
  if (mh.note != null) p(mh.note)
72
76
  if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
@@ -99,6 +103,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
99
103
  // `shipkit check --fix SK004`, one code path
100
104
  const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
101
105
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
106
+ await repairOneWayIn(root, defineRepo({ name }), p)
102
107
  // what the gate actually runs, in ONE line: a thin floor must be seen at
103
108
  // init time rather than discovered after a bad commit slipped through.
104
109
  // The per-task resolution is diagnostics, and a `skip:` naming a script
@@ -128,3 +133,16 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
128
133
  if (opts.noPublish !== true) p('next: `shipkit ci` is the gate — run it to check your work')
129
134
  return 0
130
135
  }
136
+
137
+ /** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
138
+ * (build/shipkit.md). Nothing here is a second implementation: rule SK001
139
+ * routes the package.json scripts, rule SK013 writes the harness policy, and
140
+ * init just calls them so a wired repo is born enforced and a re-run repairs
141
+ * one that was not. The deny is a RESTRICTION, which is why it lands
142
+ * unconditionally where the scaffold's allow list stays opt in. */
143
+ async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
144
+ const pkg = await packageJsonShape.fix!({ root, config })
145
+ if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
146
+ const policy = await oneWayInPolicy.fix!({ root, config })
147
+ p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
148
+ }
@@ -12,7 +12,8 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
14
14
  import type { CheckCtx } from '../core/check-ctx.ts'
15
- import { resolveFloor, type RepoConfig } from '../repo-config.ts'
15
+ import { resolveFloor } from '../core/floor.ts'
16
+ import type { RepoConfig } from '../repo-config.ts'
16
17
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
17
18
 
18
19
  type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
@@ -0,0 +1,136 @@
1
+ // SK013 — the harness half of ONE WAY IN (build/shipkit.md). Rule SK001 routes
2
+ // the package.json scripts, and that alone is not enough: `bun test` is a bun
3
+ // BUILTIN, so no script can intercept it and the reflex stays open. This rule
4
+ // makes the repo carry the two things that CAN close it, and repairs them:
5
+ //
6
+ // - `permissions.deny` — declarative and unbypassable (deny beats allow at
7
+ // every settings level, and bypassPermissions does not lift it)
8
+ // - a PreToolUse hook — the only way to put a REASON in front of the model.
9
+ // A bare deny says only that the call was denied, and a refusal with no
10
+ // replacement sends the agent looking for another runner.
11
+ //
12
+ // Both are generated from ONE table (core/one-way-in-policy.ts). The file is
13
+ // TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
14
+ // scaffold, so a policy written there would bind exactly one checkout.
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
16
+ import { dirname, join } from 'path'
17
+ import { inferBin } from '../core/canonical-scripts.ts'
18
+ import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
19
+ import type { RepoConfig } from '../repo-config.ts'
20
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
21
+
22
+ const OWNER = 'build/shipkit.md'
23
+ export const SETTINGS_PATH = '.claude/settings.json'
24
+
25
+ type Settings = {
26
+ permissions?: { deny?: string[] }
27
+ hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ type?: string; command?: string }> }>>
28
+ }
29
+
30
+ /** slots this repo does not route, so it must not be refused the shell command
31
+ * either: ONE declaration governs the script name and the raw call together */
32
+ export function exemptSlots(c: RepoConfig): Set<string> {
33
+ const off = new Set(c.oneWayIn?.exempt ?? [])
34
+ for (const [slot, def] of Object.entries(c.defaults ?? {})) if (def === false) off.add(slot)
35
+ return off
36
+ }
37
+
38
+ /** how the hook invokes the guard. `$CLAUDE_PROJECT_DIR` because a hook's cwd
39
+ * is not promised to be the repo root */
40
+ export function guardCommand(bin?: string): string {
41
+ const path = bin == null
42
+ ? 'node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts'
43
+ : bin.replace(/cli\.ts$/, 'hooks/one-way-in-guard.ts')
44
+ return `bun "$CLAUDE_PROJECT_DIR/${path}"`
45
+ }
46
+
47
+ const readSettings = (root: string): Settings | null => {
48
+ const p = join(root, SETTINGS_PATH)
49
+ if (!existsSync(p)) return null
50
+ try { return JSON.parse(readFileSync(p, 'utf8')) as Settings } catch { return null }
51
+ }
52
+
53
+ const guardHookCommand = (s: Settings): string | null => {
54
+ for (const m of s.hooks?.['PreToolUse'] ?? [])
55
+ for (const h of m.hooks ?? [])
56
+ if (h.command?.includes('one-way-in-guard') === true) return h.command
57
+ return null
58
+ }
59
+
60
+ const hasGuardHook = (s: Settings): boolean => guardHookCommand(s) != null
61
+
62
+ /** the guard file the hook command points at, resolved against the repo root.
63
+ * A hook whose script does not exist errors on EVERY Bash call and refuses
64
+ * nothing, so the agent sees only the bare deny, which is the one failure
65
+ * this rule exists to prevent. Declaring it and never running it is worse
66
+ * than not declaring it, so it FAILs. */
67
+ function guardScriptMissing(root: string, command: string): string | null {
68
+ const m = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)
69
+ const rel = m?.[1]
70
+ if (rel == null) return null // a form this rule cannot resolve is not a form it may condemn
71
+ return existsSync(join(root, rel)) ? null : rel
72
+ }
73
+
74
+ const readBin = (root: string): string | undefined => {
75
+ try { return inferBin((JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}) }
76
+ catch { return undefined }
77
+ }
78
+
79
+ export const oneWayInPolicy: Rule = {
80
+ id: 'SK013',
81
+ blocking: true,
82
+ title: 'one way in policy',
83
+ owner: OWNER,
84
+ lint: (t, env) => {
85
+ const want = denyRules(exemptSlots(env.config))
86
+ if (want.length === 0) return t.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
87
+ const s = readSettings(env.root)
88
+ if (s == null) {
89
+ t.fail(`${SETTINGS_PATH} ${existsSync(join(env.root, SETTINGS_PATH)) ? 'is unparseable' : 'missing'} — nothing stops an agent typing \`bun test\`, and a package.json script cannot intercept a bun builtin`)
90
+ return
91
+ }
92
+ const have = new Set(s.permissions?.deny ?? [])
93
+ const missing = want.filter((r) => !have.has(r))
94
+ if (missing.length > 0)
95
+ t.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
96
+ const guard = guardHookCommand(s)
97
+ if (guard == null)
98
+ t.fail(`${SETTINGS_PATH}: no PreToolUse guard — a bare deny tells the model only that it was denied, so it goes looking for another runner instead of the shipkit command`)
99
+ else {
100
+ const missing = guardScriptMissing(env.root, guard)
101
+ if (missing != null)
102
+ t.fail(
103
+ `${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which does not exist — every Bash call errors and no refusal ever names the shipkit command`,
104
+ `bun install (or bun add -d rvlib-shipkit)`,
105
+ )
106
+ }
107
+ if (!t.failed) t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s), with the replacement named`)
108
+ },
109
+ // MERGES, never rewrites: this file also carries the attribution silencer,
110
+ // a repo's own allow list and its own hooks, and clobbering those to fix a
111
+ // deny list would cost more than the finding it repairs
112
+ fix: (env: RuleEnv): FixResult => {
113
+ const want = denyRules(exemptSlots(env.config))
114
+ if (want.length === 0) return { changed: false, summary: 'every governed slot is exempt — nothing to write' }
115
+ const p = join(env.root, SETTINGS_PATH)
116
+ const existing = readSettings(env.root)
117
+ if (existing == null && existsSync(p)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
118
+ const s: Settings = existing ?? {}
119
+ const changes: string[] = []
120
+ const deny = s.permissions?.deny ?? []
121
+ const added = want.filter((r) => !deny.includes(r))
122
+ if (added.length > 0) {
123
+ s.permissions = { ...s.permissions, deny: [...deny, ...added] }
124
+ changes.push(`${added.length} raw runner(s) denied`)
125
+ }
126
+ if (!hasGuardHook(s)) {
127
+ const pre = s.hooks?.['PreToolUse'] ?? []
128
+ s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: guardCommand(readBin(env.root)) }] }] }
129
+ changes.push('PreToolUse guard wired (every refusal names the shipkit command)')
130
+ }
131
+ if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
132
+ mkdirSync(dirname(p), { recursive: true })
133
+ writeFileSync(p, JSON.stringify(s, null, 2) + '\n')
134
+ return { changed: true, summary: `${SETTINGS_PATH}: ${changes.join(', ')}` }
135
+ },
136
+ }
@@ -15,6 +15,7 @@ import { commitAiTells } from './SK009-commit-ai-tells.ts'
15
15
  import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
16
16
  import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
17
17
  import { configBarrelImport } from './SK012-config-barrel-import.ts'
18
+ import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
18
19
 
19
20
  export const RULES: readonly Rule[] = [
20
21
  packageJsonShape,
@@ -29,6 +30,7 @@ export const RULES: readonly Rule[] = [
29
30
  undeclaredReleaseUnit,
30
31
  siblingUnitRanges,
31
32
  configBarrelImport,
33
+ oneWayInPolicy,
32
34
  ]
33
35
 
34
36
  // THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
@@ -3,10 +3,8 @@
3
3
  "allow": [
4
4
  "Bash(shipkit *)",
5
5
  "Bash(bun run *)",
6
- "Bash(bun test*)",
7
6
  "Bash(bun install*)",
8
7
  "Bash(bun add *)",
9
- "Bash(bunx tsc*)",
10
8
  "Bash(bunx wrangler*)",
11
9
  "Bash(git status*)",
12
10
  "Bash(git diff*)",
@@ -15,7 +13,6 @@
15
13
  "Bash(git commit*)",
16
14
  "Bash(git push*)",
17
15
  "Bash(curl *)",
18
- "Bash(oxlint*)",
19
16
  "Bash(./deploy.sh)"
20
17
  ],
21
18
  "deny": []
@@ -11,7 +11,7 @@ description: Prove a change in THIS project actually works — drive the real fl
11
11
 
12
12
  ## The rules (house-wide)
13
13
  - Failing repro test BEFORE the fix. Green checks ≠ verification.
14
- - Logic → fast headless `bun test` (tiny ~10-line test files reusing fixtures).
14
+ - Logic → fast headless bun tests, run through `shipkit ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <name filter>` is the narrow way in.
15
15
  - Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
16
16
  - Verify as the end user would: drive the real flow, observe the real signal.
17
17