rvlib-shipkit 0.37.0 → 0.37.1

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,4 +1,14 @@
1
- # Changelog
1
+ ## 0.37.1
2
+
3
+ - **SK013 no longer wires a Bash guard that cannot run.** A consumer that runs shipkit from a checkout by ABSOLUTE path, with no `rvlib-shipkit` dependency and no `node_modules`, carried `bun "$CLAUDE_PROJECT_DIR/node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts"`, so every Bash call of every agent in that repo printed `Module not found`, while the rule only WARNed `not installed yet, bun install` and the gate stayed green. `bun install` installs nothing in a repo that declares no dependency, so that WARN now fires only when `package.json` declares `rvlib-shipkit`, and otherwise the missing guard FAILs. `guardCommand` glued an absolute cli path under the project dir (`$CLAUDE_PROJECT_DIR//Users/...`), and it now writes the absolute path alone. The lint resolves an absolute guard path too, where it used to pass any form it could not read. The fixer, which `shipkit ci` runs by default, repoints a guard whose script does not exist at the copy the repo actually runs: the cli path its scripts name when a guard sits beside it (a repo's own `src/cli.ts` matches the same script shape), else its declared dependency, else the shipkit doing the fixing, which is what the launcher falls back to. A guard that resolves, or waits on a declared install, stays as written, and a target that does not resolve itself is never written.
4
+
5
+ - **The gate says what an undeclared floor costs, and declares nothing on a repo's behalf.** A slot with no `inputs`, no `ttl` and no `never` is measured every run, and `resolveFloor` declares none for any of the five, so a second gate over an UNCHANGED tree re-runs everything: 17.4s per gate measured on a field repo, 14.8s of it the test task, against the shape an agent actually runs (verify, commit, verify again). Nothing pointed at the repair either, since `shipkit init` scaffolded no `defaults` block, so a fresh repo stayed that way until somebody read `shipkit keys --burn-down` and knew what to write. `init` now scaffolds the commented `defaults` line naming the fix.
6
+ - **What this release deliberately does NOT do is auto-declare `['.']` for those slots**, and the reason is worth more than the speed. `['.']` is not the work tree, it is the TRACKED tree: `git-scan` force-adds only a path a declaration NAMES, so an ignored path enters a key by NAME and never by content. Every floor task reads ignored content in an ordinary repo, a suite through its fixtures and its local database, tsc and the two graph walks through generated types and `node_modules`. Measured on a scratch repo: warm the ledger with two green runs, break a gitignored file the suite reads, and the third gate prints `ok: test (replayed, 0.0s saved)` over a suite that exits 1. `tests/floor-cache-safety.test.ts` holds that repro, watched fail, with the declaring lookalike beside it still replaying. Speed here is bought by a REPO declaring what its own tasks read, which is the only declaration with a real hit rate anyway.
7
+ - Two doc sentences that argued the other way are corrected where they live: `TaskDef.inputs` no longer says `['.']` "cannot under declare", and the `git-key` header no longer promises the never-cached default is the whole safety story without naming who can declare on a unit's behalf.
8
+
9
+ - **SK004 asks git what is ignored, instead of trusting the text it wrote.** The rule compared the managed block to the expected string and stopped there, so it reported `current` over a repo where git ignored none of what the block claims. Two ways that happens, both from the field. A bare `printf 'work/\n' >> .gitignore` onto a file whose last line carried no terminator fuses the two into `sourcework/`, which ignores neither path, and the file ends with a newline afterwards so nothing textual looks wrong (cost in that repo: 1.1GB and 700 screenshots pushed across four commits, a rejected push, and a `git filter-repo` rewrite). And a negation below the block is the DOCUMENTED override, so nothing stopped one landing on `.rv-private/`, the directory holding the keyword list the leak guard itself reads. Every policy pattern is now probed through one `git check-ignore --stdin` call and a contradiction FAILs, naming the pattern and the `check-ignore -v` that reports the winning line. The three venue folders stay exempt, because unignoring one is how a live listing ships. A `.gitignore` that does not end with a newline is now a FAIL of its own, since that missing byte is the trap rather than a cosmetic defect, and `--fix` terminates the file even when the block is current. Outside a git repo the rule states the block went unverified rather than reporting a green nothing measured.
10
+
11
+ - **A check declaration the gate's own formatter wrote was unreadable to the gate.** `BLOCKING_RE` and `GATE_RE` both ended in `\s*$`, and oxfmt (which the fix pass RUNS) terminates every statement with a semicolon. So `shipkit ci` reformatted `export const blocking = false` into `export const blocking = false;`, then failed the file with `does not declare whether it blocks` and printed as the fix the exact line it had just written. Two repos in the field shipped `// oxfmt-ignore` workarounds to get a green gate. The worse half was silent: a `export const gate = false;` was not read either, so a world probe that had opted out of the gate quietly re-entered it, and a repo could declare fifteen off-gate probes and watch all fifteen run. Both readers now accept an optional `as const` and an optional semicolon through one shared tail. `INPUTS_RE`, `TTL_RE` and `NEVER_RE` never anchored the line end, so they were never affected. Pinned by three tests: the formatted shapes, the gate opt-out in both spellings, and a CONTROL that the unterminated shapes and a silent file still read as before.
2
12
 
3
13
  ## 0.37.0
4
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "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",
6
6
  "license": "MIT",
@@ -42,7 +42,11 @@ export type DiscoveredCheck = {
42
42
 
43
43
  // read without importing: checks run as subprocesses, and discovery must stay
44
44
  // pure-fs. `export const blocking = true` (or false), nothing cleverer.
45
- const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)\s*$/m
45
+ // the tail is the gate's OWN formatter: oxfmt terminates every statement, and
46
+ // the fix pass runs it, so a reader that refuses a semicolon refuses the shape
47
+ // the gate itself just wrote.
48
+ const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
49
+ const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
46
50
  // `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
47
51
  // (a computed list, a spread) is deliberately unreadable here and lands as no
48
52
  // declaration, which means never cached: a scanner guessing at a program is
@@ -53,7 +57,7 @@ const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+
53
57
  // `export const gate = false` takes a check out of `shipkit ci`. Only the
54
58
  // literal false opts out: anything unreadable stays in the gate, because a
55
59
  // scanner that guesses its way to SKIPPING a check is how coverage disappears
56
- const GATE_RE = /^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false\s*$/m
60
+ const GATE_RE = new RegExp(String.raw`^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false` + TAIL, 'm')
57
61
  const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
58
62
 
59
63
  export function discoverChecks(root: string): DiscoveredCheck[] {
package/src/core/floor.ts CHANGED
@@ -68,6 +68,24 @@ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: Fl
68
68
  return `no package.json '${id}' script`
69
69
  }
70
70
 
71
+ // NOTHING here declares a read set on a repo's behalf, and that is a decision
72
+ // rather than an omission. A floor slot with no `inputs` is measured every run,
73
+ // which costs speed and never correctness (ledger/git-key.ts owns that default).
74
+ //
75
+ // The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
76
+ // under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
77
+ // path a declaration NAMES, so an ignored path enters the key by name and never
78
+ // by content. Every floor slot reads ignored content in an ordinary repo, a
79
+ // suite through its fixtures and a local database, tsc and the graph walks
80
+ // through generated types and node_modules. Shipped for one day, that made the
81
+ // gate print `ok: test (replayed)` over a suite that exits 1
82
+ // (tests/floor-cache-safety.test.ts holds the repro).
83
+ //
84
+ // Speed here is bought by a REPO saying what its own tasks read, in
85
+ // `defaults`, which is the only declaration with a real hit rate anyway.
86
+ // `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
87
+ // still undeclared, and the gate prints the cost of it once per run.
88
+
71
89
  export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
72
90
  const pkgPath = join(root, 'package.json')
73
91
  let scripts: Record<string, string> = {}
@@ -36,8 +36,12 @@ export type TaskDef = {
36
36
  * speed and never correctness. A list MISSING a path the command reads is
37
37
  * the one that costs correctness: the key cannot move when that path
38
38
  * changes, so the answer replays over a tree it no longer describes. Over
39
- * declaring costs a re-run, under declaring is a false green, and `['.']`
40
- * is the whole work tree and cannot under declare. A task carries no code
39
+ * declaring costs a re-run, under declaring is a false green. `['.']` is
40
+ * the whole TRACKED tree, and it is NOT a catch-all: an ignored path
41
+ * enters a key by name and never by content, so a task reading a
42
+ * gitignored fixture, a built dir or a .env under declares with `['.']`
43
+ * exactly as it would with a narrow list. A task that reads the network,
44
+ * a keychain or the clock declares `ttl`, never `inputs`. A task carries no code
41
45
  * closure, so the script it runs belongs in here too.
42
46
  * Declaring both this and `ttl` is a registration failure. */
43
47
  inputs?: string[]
package/src/init-repo.ts CHANGED
@@ -29,10 +29,14 @@ export default defineRepo({
29
29
  venues: [], // e.g. ['itch', 'steam'], selects which .rv-<venue>/ contracts are REQUIRED
30
30
  social: [], // e.g. ['x', 'discord'], the .rv-social/<channel>/ post folders (+ .rv-discord/)
31
31
  tasks: [], // repo commit-gate tasks; deps double as race guards
32
+ // lint, typecheck, imports:check and cycles replay off the tracked tree.
33
+ // \`test\` measures every run until you say what it reads, because an
34
+ // ignored path (a fixture, a local db, a .env) is not in a tree key:
35
+ // defaults: { test: { inputs: ['tests', 'src', 'package.json'] } },
32
36
  // .comfy-ts/ holds schema dumps naming a private model collection.
33
37
  // gitignore (SK004) is advisory, this pre-commit gate is not
34
38
  hooks: { blockedPaths: ['.comfy-ts/'] },
35
- // the mandated reviewer agent type (rule SK008). Uncomment with YOUR
39
+ // the declared reviewer agent type (rule SK008). Uncomment with YOUR
36
40
  // standing brief, then \`shipkit check --fix SK008\` writes the agent file:
37
41
  // reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
38
42
  // health checks need no config. Drop a <name>.check.ts file anywhere,
@@ -16,8 +16,11 @@
16
16
  // NEITHER declares nothing. Never cached. This is the DEFAULT, and it is
17
17
  // the whole safety story: forgetting to declare costs speed, never
18
18
  // correctness, so the burn-down list below is a to-do rather than a
19
- // hazard. Declaring BOTH is a registration failure, loud, because
20
- // the two answers contradict each other.
19
+ // hazard. It holds only while nothing declares ON a unit's behalf:
20
+ // the gate floor gives `['.']` to the four slots tracked source
21
+ // decides and deliberately leaves `test` here (core/floor.ts says
22
+ // why). Declaring BOTH is a registration failure, loud, because the
23
+ // two answers contradict each other.
21
24
  //
22
25
  // CROSS WORKTREE. Every component here is content addressed or a version
23
26
  // string; not one of them is an absolute path. Two worktrees of one repo
@@ -90,12 +90,12 @@ export type RepoConfig = {
90
90
  /** declared github visibility INTENT (absent = 'private') — rule SK007
91
91
  * fails loud when `gh repo view` disagrees in either direction */
92
92
  visibility?: 'private' | 'public'
93
- /** the reviewer agent type this repo mandates: `.claude/agents/<name>.md`,
93
+ /** the reviewer agent type this repo declares: `.claude/agents/<name>.md`,
94
94
  * whose system prompt points at `brief` (an md file holding the standing
95
95
  * review framing — one home, shared across repos). `tools` overrides the
96
96
  * read-only allowlist SK008 enforces (default: Read, Grep, Glob, Bash).
97
97
  * Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
98
- * mandated reviewer. */
98
+ * declared reviewer. */
99
99
  reviewer?: { name: string; brief: string; tools?: string[] }
100
100
  /** ONE WAY IN escape hatch (build/shipkit.md): governed script names this
101
101
  * repo keeps as a direct tool call. Declared per NAME, never as a list of
@@ -7,6 +7,7 @@
7
7
  // PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
8
8
  // last-match-wins) keep their position; everything outside the block is
9
9
  // never touched. `shipkit init` writes the same block through this fixer.
10
+ import { execFileSync } from 'child_process'
10
11
  import { existsSync, readFileSync } from 'fs'
11
12
  import { join } from 'path'
12
13
  import type { RepoConfig } from '../repo-config.ts'
@@ -58,6 +59,60 @@ export function policyPatterns(config: RepoConfig): string[] {
58
59
  return [...POLICY_LINES.filter((l) => !l.startsWith('#')), ...(config.gitignore ?? [])]
59
60
  }
60
61
 
62
+ // the three venue folders are DESIGNED to be unignored below the block once a
63
+ // listing is live (last-match-wins, the comment inside POLICY_LINES says so),
64
+ // so an override on one of them is the mechanism working. Every other policy
65
+ // path is a claim the block makes, and a claim git contradicts is a bug.
66
+ function endsWithNewline(root: string): boolean {
67
+ const p = join(root, '.gitignore')
68
+ if (!existsSync(p)) return true
69
+ const text = readFileSync(p, 'utf8')
70
+ return text === '' || text.endsWith('\n')
71
+ }
72
+
73
+ const OVERRIDABLE = new Set(['.rv-itch/', '.rv-steam/', '.rv-discord/'])
74
+
75
+ /** a path git can be asked about, for a pattern that may be a glob or a dir */
76
+ function probePath(pattern: string): string {
77
+ if (pattern.endsWith('/')) return `${pattern}probe`
78
+ // `.*.tmp-*` and `*.loras.local.txt`: a literal standing in for the glob
79
+ return pattern.includes('*') ? pattern.replace(/\*/g, 'probe') : pattern
80
+ }
81
+
82
+ /** which of `paths` git does NOT ignore. null when this is not a git repo, so
83
+ * a rule can say it did not measure rather than report a green it invented */
84
+ export function notIgnored(root: string, paths: string[]): string[] | null {
85
+ if (paths.length === 0) return []
86
+ try {
87
+ // --stdin -z -n: one call, every path, and `-n` makes git print a row
88
+ // for the MISSES too, which is the answer we want
89
+ const out = execFileSync('git', ['check-ignore', '--stdin', '-z', '-n', '-v'], {
90
+ cwd: root, input: paths.join('\0'), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
91
+ })
92
+ // -v -z rows are source\0line\0pattern\0path; an unignored path has an
93
+ // empty source and an empty pattern
94
+ const f = out.split('\0')
95
+ const miss: string[] = []
96
+ for (let i = 0; i + 3 < f.length + 1; i += 4) {
97
+ if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
98
+ }
99
+ return miss
100
+ } catch (e) {
101
+ // exit 1 means "none of them are ignored", which is an ANSWER, and git
102
+ // still wrote the rows we parse. Anything else (no repo, no git) is not
103
+ const err = e as { status?: number; stdout?: string }
104
+ if (err.status === 1 && typeof err.stdout === 'string') {
105
+ const f = err.stdout.split('\0')
106
+ const miss: string[] = []
107
+ for (let i = 0; i + 3 < f.length + 1; i += 4) {
108
+ if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
109
+ }
110
+ return miss
111
+ }
112
+ return null
113
+ }
114
+ }
115
+
61
116
  type Parsed = { text: string; crlf: boolean; lines: string[]; b: number; e: number }
62
117
 
63
118
  function parse(root: string): Parsed | null {
@@ -122,11 +177,48 @@ export const gitignorePolicy: Rule = {
122
177
  case 'missing-block': return t.fail('.gitignore has no shipkit policy block — contract surfaces can leak into git')
123
178
  case 'corrupted': return t.fail('.gitignore policy block corrupted (unpaired marker)')
124
179
  case 'stale': return t.fail('.gitignore policy block is stale — the policy or repo-contributed lines moved since it was written')
125
- case 'current': return t.ok(`.gitignore policy block current (${policyPatterns(env.config).length} patterns)`)
126
180
  }
181
+ // an unterminated last line is a TRAP, not a cosmetic defect: the next
182
+ // `>> .gitignore` fuses onto it and the result is one pattern that
183
+ // ignores neither path, with the file ending in a newline afterwards
184
+ // so nothing textual looks wrong
185
+ if (!endsWithNewline(env.root)) {
186
+ t.fail('.gitignore does not end with a newline — the next append fuses onto its last line and ignores neither path')
187
+ t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) terminates it' })
188
+ return
189
+ }
190
+ // the block states what must be ignored. git is the only thing that
191
+ // knows whether it IS: a stray negation below the block, or a line
192
+ // fused into another, leaves the text current over a broken repo
193
+ const claimed = policyPatterns(env.config).filter((p) => !OVERRIDABLE.has(p))
194
+ const probes = new Map(claimed.map((p) => [probePath(p), p]))
195
+ const miss = notIgnored(env.root, [...probes.keys()])
196
+ if (miss == null) {
197
+ // not a git repo (a fixture, or a dir before `git init`). Say the
198
+ // verification did not run rather than reporting a green nothing
199
+ // measured, and stay quiet: there is nothing wrong here to fix
200
+ t.ok(`.gitignore policy block current (${claimed.length} patterns)`)
201
+ t.lines.push({ kind: 'skip', text: 'not a git repo here, so nothing verified the block against what git actually ignores' })
202
+ return
203
+ }
204
+ if (miss.length > 0) {
205
+ const names = [...new Set(miss.map((m) => probes.get(m) ?? m))]
206
+ t.fail(`.gitignore block claims ${names.join(', ')} ignored, git says otherwise — a negation below the block, or a line fused into its neighbour`)
207
+ t.lines.push({ kind: 'fix-fail', text: `git check-ignore -v ${miss[0]} names the line that wins` })
208
+ return
209
+ }
210
+ t.ok(`.gitignore policy block current, ${claimed.length} patterns verified against git`)
127
211
  },
128
212
  fix: (env: RuleEnv): FixResult => {
129
213
  const before = blockState(env.root, env.config)
214
+ // terminate an unterminated file even when the block itself is current:
215
+ // that missing byte is what the next append fuses onto
216
+ let terminated: string | null = null
217
+ if (before === 'current' && !endsWithNewline(env.root)) {
218
+ const p = join(env.root, '.gitignore')
219
+ atomicWrite(p, `${readFileSync(p, 'utf8')}\n`)
220
+ terminated = '.gitignore terminated with a newline (the next append would have fused onto its last line)'
221
+ }
130
222
  const blockSummary = before === 'current'
131
223
  ? null
132
224
  : before === 'missing-file' ? '.gitignore created with the policy block'
@@ -137,10 +229,10 @@ export const gitignorePolicy: Rule = {
137
229
  // the block must be written FIRST: untracking a path the ignore list
138
230
  // does not yet carry leaves it staged for re-add on the next `git add`
139
231
  const un = untrackGenerated(env.root, { enabled: env.config.untrackGenerated })
140
- const parts = [blockSummary]
232
+ const parts = [blockSummary, terminated]
141
233
  if (un.did === 'untracked') parts.push(`untracked ${un.paths.join(', ')} (generated, now gitignored, committed as ${un.commit})`)
142
234
  if (un.did === 'refused') parts.push(`left ${trackedGeneratedPaths(env.root).join(', ')} tracked: ${un.why}`)
143
235
  const summary = parts.filter((x): x is string => x != null).join(' · ')
144
- return { changed: blockSummary != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
236
+ return { changed: blockSummary != null || terminated != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
145
237
  },
146
238
  }
@@ -1,4 +1,4 @@
1
- // SK008 — the mandated reviewer agent type. A repo that declares
1
+ // SK008 — the declared reviewer agent type. A repo that declares
2
2
  // `reviewer: { name, brief }` must carry `.claude/agents/<name>.md`: an agent
3
3
  // definition whose system prompt POINTS at the brief file (ONE home for the
4
4
  // text, so review framing can never drift per repo) and whose `tools:` line
@@ -50,7 +50,7 @@ export function briefMissing(root: string, brief: string): boolean {
50
50
  function canonicalAgent(reviewer: NonNullable<RepoConfig['reviewer']>): string {
51
51
  return `---
52
52
  name: ${reviewer.name}
53
- description: The mandated critical reviewer for this repo. Use before every commit, and whenever a diff needs judging against what was actually asked for. Judges whether a change should exist at all, not just whether it is correct.
53
+ description: The critical reviewer for this repo. Whether a review is worth its wall clock is the session's call: reach for it on code whose failure the gate cannot see, and tell it to verify by running rather than to opine. Judges whether a change should exist at all, not just whether it is correct.
54
54
  tools: ${(reviewer.tools ?? REVIEWER_TOOLS).join(', ')}
55
55
  model: opus
56
56
  ---
@@ -82,7 +82,7 @@ export const reviewerAgent: Rule = {
82
82
  const rel = reviewerAgentPath(r.name)
83
83
  const p = join(env.root, rel)
84
84
  if (briefMissing(env.root, r.brief)) t.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
85
- if (!existsSync(p)) return t.fail(`${rel} missing — the mandated reviewer type is unavailable, so every review runs on whatever framing the spawner improvised`)
85
+ if (!existsSync(p)) return t.fail(`${rel} missing — the declared reviewer type is unavailable, so any review runs on whatever framing the spawner improvised`)
86
86
  const issues = reviewerAgentIssues(readFileSync(p, 'utf8'), r)
87
87
  for (const i of issues) t.fail(`${rel}: ${i}`)
88
88
  if (issues.length === 0 && !t.failed) t.ok(`${rel} → ${r.brief}`)
@@ -13,7 +13,7 @@
13
13
  // TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
14
14
  // scaffold, so a policy written there would bind exactly one checkout.
15
15
  import { existsSync, mkdirSync, readFileSync } from 'fs'
16
- import { dirname, join } from 'path'
16
+ import { dirname, isAbsolute, join } from 'path'
17
17
  import { inferBin } from '../core/canonical-scripts.ts'
18
18
  import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
19
19
  import type { RepoConfig } from '../repo-config.ts'
@@ -36,19 +36,30 @@ export function exemptSlots(c: RepoConfig): Set<string> {
36
36
  return off
37
37
  }
38
38
 
39
- /** how the hook invokes the guard. `$CLAUDE_PROJECT_DIR` because a hook's cwd
40
- * is not promised to be the repo root */
39
+ /** how the hook invokes the guard. A relative path hangs off
40
+ * `$CLAUDE_PROJECT_DIR` because a hook's cwd is not promised to be the repo
41
+ * root, and an absolute one stands alone */
41
42
  export function guardCommand(bin?: string): string {
42
43
  const path = bin == null
43
44
  ? 'node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts'
44
45
  : bin.replace(/cli\.ts$/, 'hooks/one-way-in-guard.ts')
45
- return `bun "$CLAUDE_PROJECT_DIR/${path}"`
46
+ return isAbsolute(path) ? `bun "${path}"` : `bun "$CLAUDE_PROJECT_DIR/${path}"`
46
47
  }
47
48
 
48
- /** a guard under `node_modules/` that is not there yet says the dependency is
49
- * not installed, which `bun install` fixes and no edit to this repo can. Any
50
- * other unresolvable path is a config this repo chose and got wrong. */
51
- const isUninstalledDep = (rel: string): boolean => rel.startsWith('node_modules/')
49
+ const readPkg = (root: string): { scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string> } | null => {
50
+ try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as never } catch { return null }
51
+ }
52
+
53
+ const declaresShipkit = (root: string): boolean => {
54
+ const pkg = readPkg(root)
55
+ return pkg?.dependencies?.['rvlib-shipkit'] != null || pkg?.devDependencies?.['rvlib-shipkit'] != null
56
+ }
57
+
58
+ /** a guard under `node_modules/` that is not there yet, in a repo that
59
+ * declares rvlib-shipkit, says the dependency is not installed, which
60
+ * `bun install` fixes and no edit to this repo can. Without the declaration
61
+ * no install ever brings it, so it is a path this repo got wrong like any other. */
62
+ const isUninstalledDep = (root: string, rel: string): boolean => rel.startsWith('node_modules/') && declaresShipkit(root)
52
63
 
53
64
  const readSettings = (root: string): Settings | null => {
54
65
  const p = join(root, SETTINGS_PATH)
@@ -65,21 +76,32 @@ const guardHookCommand = (s: Settings): string | null => {
65
76
 
66
77
  const hasGuardHook = (s: Settings): boolean => guardHookCommand(s) != null
67
78
 
68
- /** the guard file the hook command points at, resolved against the repo root.
79
+ /** the guard file the hook command points at, resolved against the repo root
80
+ * when it hangs off `$CLAUDE_PROJECT_DIR`, as written when it is absolute.
69
81
  * A hook whose script does not exist errors on EVERY Bash call and refuses
70
82
  * nothing, so the agent sees only the bare deny, which is the one failure
71
83
  * this rule exists to prevent. Declaring it and never running it is worse
72
84
  * than not declaring it, so it FAILs. */
73
85
  function guardScriptMissing(root: string, command: string): string | null {
74
- const m = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)
75
- const rel = m?.[1]
76
- if (rel == null) return null // a form this rule cannot resolve is not a form it may condemn
77
- return existsSync(join(root, rel)) ? null : rel
86
+ const rel = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)?.[1]
87
+ if (rel != null) return existsSync(join(root, rel)) ? null : rel
88
+ const abs = /^bun\s+["']?(\/[^"'\s]+)["']?/.exec(command.trim())?.[1]
89
+ if (abs != null) return existsSync(abs) ? null : abs
90
+ return null // a form this rule cannot resolve is not a form it may condemn
78
91
  }
79
92
 
80
- const readBin = (root: string): string | undefined => {
81
- try { return inferBin((JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}) }
82
- catch { return undefined }
93
+ /** the guard the fixer writes: the cli path the scripts run when a guard sits
94
+ * beside it (a repo's own `src/cli.ts` matches the same shape), else the
95
+ * repo's own dependency, else the copy of shipkit doing the fixing, which is
96
+ * the one the launcher falls back to in a repo that has none */
97
+ export function guardCommandFor(root: string): string {
98
+ const bin = inferBin(readPkg(root)?.scripts ?? {})
99
+ if (bin != null) {
100
+ const fromBin = guardCommand(bin)
101
+ if (guardScriptMissing(root, fromBin) == null) return fromBin
102
+ }
103
+ if (declaresShipkit(root)) return guardCommand()
104
+ return guardCommand(join(import.meta.dir, '..', 'cli.ts'))
83
105
  }
84
106
 
85
107
  export const oneWayInPolicy: Rule = {
@@ -115,7 +137,7 @@ export const oneWayInPolicy: Rule = {
115
137
  // that fixes it, not a red gate on a repo nobody has installed yet,
116
138
  // and the deny list is the floor throughout. A path this repo chose
117
139
  // and got wrong is still a FAIL: it will never resolve on its own.
118
- if (missing != null && isUninstalledDep(missing))
140
+ if (missing != null && isUninstalledDep(env.root, missing))
119
141
  t.warn(
120
142
  `${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which is not installed yet — until then a refusal names no replacement, and the deny list is the whole floor`,
121
143
  `bun install (or bun add -d rvlib-shipkit)`,
@@ -149,10 +171,24 @@ export const oneWayInPolicy: Rule = {
149
171
  s.permissions = { ...s.permissions, deny: [...deny, ...added] }
150
172
  changes.push(`${added.length} raw runner(s) denied`)
151
173
  }
174
+ const wantGuard = guardCommandFor(env.root)
152
175
  if (!hasGuardHook(s)) {
153
176
  const pre = s.hooks?.['PreToolUse'] ?? []
154
- s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: guardCommand(readBin(env.root)) }] }] }
177
+ s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: wantGuard }] }] }
155
178
  changes.push('PreToolUse guard wired (every refusal names the shipkit command)')
179
+ } else {
180
+ // a guard that cannot run errors on every Bash call, so it is repointed
181
+ // at the copy this repo runs. One that resolves, or waits on a declared
182
+ // install, is the repo's choice and stays as written
183
+ for (const m of s.hooks?.['PreToolUse'] ?? [])
184
+ for (const h of m.hooks ?? []) {
185
+ if (h.command?.includes('one-way-in-guard') !== true) continue
186
+ const missing = guardScriptMissing(env.root, h.command)
187
+ if (missing == null || isUninstalledDep(env.root, missing) || h.command === wantGuard) continue
188
+ if (guardScriptMissing(env.root, wantGuard) != null) continue
189
+ changes.push(`PreToolUse guard repointed from ${missing}, which does not exist`)
190
+ h.command = wantGuard
191
+ }
156
192
  }
157
193
  if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
158
194
  mkdirSync(dirname(p), { recursive: true })