rvlib-shipkit 0.32.0 → 0.34.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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +4 -1
  3. package/src/asks/asks-queue.ts +15 -2
  4. package/src/cli/cmd_Asks.ts +2 -1
  5. package/src/cli/cmd_Keys.ts +107 -0
  6. package/src/cli.ts +2 -0
  7. package/src/core/canonical-scripts.ts +3 -1
  8. package/src/core/check-suite.ts +95 -33
  9. package/src/core/discover-checks.ts +30 -1
  10. package/src/core/floor.ts +21 -2
  11. package/src/core/locked-write.ts +170 -1
  12. package/src/core/one-way-in-policy.ts +183 -0
  13. package/src/core/run-ci.ts +42 -3
  14. package/src/core/run-fixers.ts +61 -18
  15. package/src/core/task-runner.ts +55 -6
  16. package/src/hooks/one-way-in-guard.ts +39 -0
  17. package/src/hooks/run-hooks.ts +23 -2
  18. package/src/init-repo.ts +19 -1
  19. package/src/ledger/gate-units.ts +58 -0
  20. package/src/ledger/git-key.ts +330 -0
  21. package/src/ledger/git-scan.ts +314 -0
  22. package/src/ledger/toolchain-identity.ts +15 -8
  23. package/src/release/run-bump.ts +50 -5
  24. package/src/release/unit-gate.ts +17 -4
  25. package/src/rules/SK001-package-json-shape.ts +10 -3
  26. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  27. package/src/rules/SK003-claude-md-shape.ts +7 -2
  28. package/src/rules/SK004-gitignore-policy.ts +11 -2
  29. package/src/rules/SK005-rvlib-versions.ts +28 -1
  30. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  31. package/src/rules/SK008-reviewer-agent.ts +6 -2
  32. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  33. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  34. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  35. package/src/rules/SK012-config-barrel-import.ts +6 -2
  36. package/src/rules/SK013-one-way-in-policy.ts +156 -0
  37. package/src/rules/rule-registry.ts +2 -0
  38. package/src/rules/rule.ts +22 -1
  39. package/src/rules/run-fix.ts +5 -3
  40. package/src/scaffold/files/claude-settings-local.json +0 -3
  41. package/src/scaffold/files/verify-skill.md +1 -1
  42. package/src/ts/config.ts +66 -6
  43. package/src/ts/cycles.ts +7 -92
  44. package/src/ts/source-graph.ts +158 -0
  45. package/src/ts/tsconfig-projects.ts +101 -0
  46. package/src/ts/typecheck-cli.ts +46 -0
@@ -3,7 +3,7 @@
3
3
  // whole read-modify-write, the write lands via tmp + rename so a killed
4
4
  // process can never leave a torn file, and the loser of a race SKIPS —
5
5
  // never corrupts, never blocks.
6
- import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
6
+ import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
7
7
  import { basename, dirname, join } from 'path'
8
8
 
9
9
  // a writer that died without unlocking must not wedge the file forever
@@ -37,3 +37,172 @@ export function atomicWrite(path: string, content: string): void {
37
37
  writeFileSync(tmpPath, content)
38
38
  renameSync(tmpPath, path)
39
39
  }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // the MUTATE LEASE — the second concurrency shape, and the only other one.
43
+ //
44
+ // withFileLock guards ONE file and the loser SKIPS: right for a render that
45
+ // re-renders next run. The lease guards a PHASE that rewrites the worktree
46
+ // (the fix pass, SK005's `bun update --latest`, hooks arming, the asks
47
+ // archive), where skipping means the repair never happens and overlapping
48
+ // means two processes rewrite the same file at once. So the loser WAITS.
49
+ //
50
+ // TWO PRIMITIVES, both atomic, and both were measured before they were
51
+ // trusted. `writeFileSync(..., 'wx')` creates the NAME first and writes the
52
+ // body second, so a racer that reads in between sees an empty file, calls the
53
+ // lock corrupt, and steals a lease whose holder is very much alive: 5 of 15
54
+ // synchronized starts overlapped. So the body is written to a private name and
55
+ // `linkSync`d into place, one step that fails when the name exists and never
56
+ // exposes a half lock. And a steal of `read, rmSync, grab` is three steps, so
57
+ // every racer wins it: 31 of 40 synchronized starts overlapped after a crash.
58
+ // So a steal is a single `renameSync` of the lock out of the way, which
59
+ // exactly one racer can perform; everyone else gets ENOENT and goes back to
60
+ // grabbing.
61
+ //
62
+ // Bounded in every direction, because a gate that hangs is worse than a gate
63
+ // that overlaps. A holder is stale when its pid is not running OR when the
64
+ // lock has not been touched for LOCK_STALE_MS, so a pid reused by a long
65
+ // lived process cannot wedge a repo forever. A holder that is genuinely still
66
+ // working HEARTBEATS the lock while it holds it, so the age rule only ever
67
+ // fires on a run that is gone or wedged. A waiter gives up after maxWaitMs and
68
+ // says so by name, path and escape hatch. Every line it prints carries a
69
+ // FROZEN wire prefix (wire.ts), so a lease that waits or steals inside
70
+ // `shipkit ci` cannot break that contract.
71
+
72
+ const LEASE_POLL_MS = 100
73
+ const LEASE_ANNOUNCE_MS = 1_000
74
+ const LEASE_REANNOUNCE_MS = 15_000
75
+ const LEASE_HEARTBEAT_MS = 20_000
76
+ /** shorter than the shortest caller timeout (a discovered check is killed at
77
+ * 120s), so a waiter always gets to PRINT its refusal rather than being
78
+ * SIGTERMed mid wait with no cause on screen */
79
+ const LEASE_MAX_WAIT_MS = 60_000
80
+
81
+ type Holder = { pid: number; at: number; label: string }
82
+
83
+ let leaseSeq = 0
84
+
85
+ const readHolder = (lockPath: string): Holder | null => {
86
+ try {
87
+ const h = JSON.parse(readFileSync(lockPath, 'utf8')) as Partial<Holder>
88
+ return typeof h.pid === 'number' ? { pid: h.pid, at: typeof h.at === 'number' ? h.at : 0, label: String(h.label ?? '?') } : null
89
+ } catch { return null }
90
+ }
91
+
92
+ /** is that pid still running? signal 0 asks without delivering anything.
93
+ * EPERM means alive and owned by another user, which still counts as alive. */
94
+ const alive = (pid: number): boolean => {
95
+ if (!Number.isInteger(pid) || pid <= 0) return false
96
+ try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
97
+ }
98
+
99
+ /** publish a COMPLETE lock under a name that did not exist a moment ago */
100
+ const grabLease = (lockPath: string, label: string): boolean => {
101
+ const tmp = `${lockPath}.tmp-${process.pid}-${leaseSeq++}`
102
+ try {
103
+ writeFileSync(tmp, JSON.stringify({ pid: process.pid, at: Date.now(), label } satisfies Holder))
104
+ linkSync(tmp, lockPath)
105
+ return true
106
+ } catch { return false }
107
+ finally { rmSync(tmp, { force: true }) }
108
+ }
109
+
110
+ const sameHolder = (a: Holder | null, b: Holder | null): boolean =>
111
+ a == null ? b == null : b != null && a.pid === b.pid && a.at === b.at
112
+
113
+ /** move the lock aside, and ONLY the lock we judged dead. Exactly one racer
114
+ * can win the rename; the rest see ENOENT and go back to grabbing.
115
+ *
116
+ * The identity check on both sides is the whole point, and it is a measured
117
+ * bug, not a precaution. Reading the holder and stat'ing the file are two
118
+ * syscalls, so a racer that read the DEAD holder, then lost the steal, then
119
+ * stat'ed the WINNER's fresh lock, judged a live holder dead and threw its
120
+ * lease away: 1 in 40 four-process starts against a stale lock. So the holder
121
+ * is re-read immediately before the rename, and the moved file is checked
122
+ * after it. A lock that turns out to belong to somebody else goes straight
123
+ * back and this round is lost. */
124
+ const stealLease = (lockPath: string, expect: Holder | null): boolean => {
125
+ if (!sameHolder(readHolder(lockPath), expect)) return false
126
+ const grave = `${lockPath}.dead-${process.pid}-${leaseSeq++}`
127
+ try { renameSync(lockPath, grave) } catch { return false }
128
+ if (sameHolder(readHolder(grave), expect)) { rmSync(grave, { force: true }); return true }
129
+ // it was not the lock we judged: put it back before the name can be taken
130
+ try { linkSync(grave, lockPath) } catch { /* taken in the gap: the new holder owns it now */ }
131
+ rmSync(grave, { force: true })
132
+ return false
133
+ }
134
+
135
+ /** drop the lock, but only while it is still OURS: a lease that was stolen
136
+ * belongs to whoever stole it, and deleting it would hand a third process a
137
+ * lease two others believe they hold */
138
+ const releaseLease = (lockPath: string): void => {
139
+ const h = readHolder(lockPath)
140
+ if (h == null || h.pid === process.pid) rmSync(lockPath, { force: true })
141
+ }
142
+
143
+ export type LeaseOutcome<T> = { ok: true; value: T; waitedMs: number } | { ok: false; warn: string }
144
+
145
+ /** hold `<path>.lock` for the duration of fn. Blocking, stolen from a holder
146
+ * that is gone or wedged, refused (never hung) past maxWaitMs. */
147
+ export async function withMutateLease<T>(
148
+ path: string,
149
+ o: { label: string; print?: (line: string) => void; maxWaitMs?: number; escape?: string },
150
+ fn: () => T | Promise<T>,
151
+ ): Promise<LeaseOutcome<T>> {
152
+ const lockPath = `${path}.lock`
153
+ const print = o.print ?? ((l: string) => console.log(l))
154
+ const maxWait = o.maxWaitMs ?? LEASE_MAX_WAIT_MS
155
+ const start = Date.now()
156
+ // the first announcement is due at LEASE_ANNOUNCE_MS, every later one a
157
+ // re-announce interval after it
158
+ let announced = -LEASE_REANNOUNCE_MS
159
+ mkdirSync(dirname(lockPath), { recursive: true })
160
+
161
+ for (;;) {
162
+ if (grabLease(lockPath, o.label)) break
163
+ const holder = readHolder(lockPath)
164
+ let ageMs = 0
165
+ // the lock vanished between grab and stat: the holder just finished,
166
+ // so go straight back and take it
167
+ try { ageMs = Date.now() - statSync(lockPath).mtimeMs } catch { continue }
168
+ // gone (no such process), or wedged (nothing has touched it for the
169
+ // stale window, and a live holder heartbeats), or a body no reader can
170
+ // make sense of that is old enough not to be a write in flight
171
+ const dead = holder == null ? ageMs > 2_000 : !alive(holder.pid) || ageMs > LOCK_STALE_MS
172
+ if (dead) {
173
+ if (stealLease(lockPath, holder)) print(`ok: stole the ${o.label} lease from a holder that is gone (pid ${holder?.pid ?? '?'}, untouched ${Math.round(ageMs / 1000)}s) — a run died mid phase`)
174
+ continue
175
+ }
176
+ const waited = Date.now() - start
177
+ if (waited > maxWait)
178
+ return {
179
+ ok: false,
180
+ warn: `${o.label} lease held by ${holder == null ? 'an unreadable lock' : `pid ${holder.pid} (${holder.label})`} for ${Math.round(ageMs / 1000)}s — gave up after ${Math.round(waited / 1000)}s rather than run two of these at once. Lock: ${lockPath}${o.escape == null ? '' : `; skip this phase with ${o.escape}`}`,
181
+ }
182
+ if (waited >= LEASE_ANNOUNCE_MS && waited - announced >= LEASE_REANNOUNCE_MS) {
183
+ announced = waited
184
+ print(`ok: waiting ${Math.round(waited / 1000)}s for the ${o.label} lease — ${holder == null ? 'an unreadable lock holds it' : `pid ${holder.pid} holds it (${holder.label})`}, lock ${lockPath}`)
185
+ }
186
+ await Bun.sleep(LEASE_POLL_MS)
187
+ }
188
+
189
+ // a phase that legitimately outlives the stale window must keep proving it
190
+ // is working, or the age rule steals from it. unref'd, so a held lease
191
+ // never keeps a process alive on its own
192
+ const beat = setInterval(() => { try { utimesSync(lockPath, new Date(), new Date()) } catch { /* stolen or gone: the finally sorts it out */ } }, LEASE_HEARTBEAT_MS)
193
+ beat.unref()
194
+ try {
195
+ return { ok: true, value: await fn(), waitedMs: Date.now() - start }
196
+ } finally {
197
+ clearInterval(beat)
198
+ releaseLease(lockPath)
199
+ }
200
+ }
201
+
202
+ /** where a repo's mutate leases live. Under the gitignored tmp/ dir so a lease
203
+ * file can never enter a commit, and per WORKTREE, because the phases it
204
+ * guards rewrite worktree-local files. The NAME is the concern: two phases
205
+ * that cannot corrupt each other take different leases, so `asks` never
206
+ * blocks a `fix`. `git config` is the one shared-across-worktrees write and
207
+ * carries its own retry (hooks/run-hooks.ts). */
208
+ export const mutateLease = (root: string, name: string): string => join(root, 'tmp', `shipkit-${name}`)
@@ -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
+ * FILE filter (measured): bun's positional argument matches the test FILE
33
+ * path, and a test NAME needs `-- -t "<name>"` forwarded through bun run */
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 <test file>' },
42
+ { head: 'bunx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
43
+ { head: 'npx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
44
+ { head: 'vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
45
+ { head: 'npx jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
46
+ { head: 'jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
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
+ }
@@ -18,6 +18,8 @@ import { rmSync } from 'fs'
18
18
  import { SHARE_ENV } from '../release/releases-share.ts'
19
19
  import { collectReports } from '../release/run-releases.ts'
20
20
  import { withContractsScope } from '../contracts/contracts-once.ts'
21
+ import { gateUnits } from '../ledger/gate-units.ts'
22
+ import { validateRegistration } from '../ledger/git-key.ts'
21
23
 
22
24
  export type CiOpts = {
23
25
  bail?: boolean
@@ -102,8 +104,16 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
102
104
  const c = o.config ?? (await loadRepoConfig(root))
103
105
  // mechanical failures are not information, so they never cost a round trip
104
106
  let fixFailed = false
107
+ // a NARROWED run repairs only the mechanical slots. The structural rule
108
+ // fixers rewrite package.json and repo config, and arming rewrites hooks/:
109
+ // that is the right price for the full gate and the wrong one for
110
+ // `bun run test`, which routes here and must answer the one question its
111
+ // name promises without leaving files in the agent's diff that it never
112
+ // wrote. Under CI it is worse than surprising, a formatting rewrite comes
113
+ // back as a test failure.
114
+ const narrowed = (o.onlyIds ?? []).length > 0
105
115
  if (o.fix !== false)
106
- fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
116
+ fixFailed = (await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })).failed
107
117
  else sink('skip: fix pass (--no-fix)')
108
118
  const all = allTasks(c, root)
109
119
  const { disabled, unfilled } = all
@@ -122,9 +132,38 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
122
132
  : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
123
133
  for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
124
134
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
135
+ // the cache-declaration state, once per run. NO git work happens here and
136
+ // nothing is cached yet: this is the REGISTRATION half, which is pure, and
137
+ // it has to be loud in the gate rather than only in `shipkit keys`,
138
+ // because a unit declaring two contradictory populations is a bug in what
139
+ // binds every repo. The burn-down is one line, never a wall.
140
+ let registrationFailed = false
141
+ try {
142
+ const reg = validateRegistration(gateUnits(root, c))
143
+ for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
144
+ if (registrationFailed) sink(' fix: a unit declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
145
+ // both counts, always. Printing only the burn-down made `never:` the
146
+ // cheap way to shrink the visible number, and that field carries no
147
+ // verification at all: a growing decided population has to be as
148
+ // visible as a shrinking undeclared one. No causal clause either,
149
+ // because nothing is cached yet and "so nothing can replay them"
150
+ // implies the rest are being replayed.
151
+ const decided = reg.units - reg.burnDown.length - reg.declared
152
+ if (reg.burnDown.length > 0 || decided > 0)
153
+ sink(`skip: cache declarations — ${reg.declared}/${reg.units} declared, ${decided} decided uncacheable, ${reg.burnDown.length} undeclared (shipkit keys --burn-down)`)
154
+ } catch (e) {
155
+ sink(`WARN cache declarations unreadable (${String(e).slice(0, 160)}) — nothing is cached, so the gate is unaffected`)
156
+ }
125
157
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
126
158
  // look like a full one (the fresh-repo floor gap)
127
- for (const u of unfilled) sink(`skip: ${u.id} (${u.reason})`)
159
+ for (const u of unfilled) {
160
+ sink(`skip: ${u.id} (${u.reason})`)
161
+ // asked for BY NAME and unfillable: this is where an agent lands after
162
+ // the harness refused it the raw runner, so the line says what to add
163
+ // rather than leaving it with `unknown task id` and no next move
164
+ if ((o.onlyIds ?? []).includes(u.id))
165
+ 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)`)
166
+ }
128
167
  // the release survey is ~4s of network that five discovered checks need
129
168
  // and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
130
169
  // costs the run nothing; started inside the first check that wants it, it
@@ -180,7 +219,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
180
219
  sink(' fix: shipkit check')
181
220
  } else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
182
221
  }
183
- const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
222
+ const code = summary.ok && !fixFailed && !registrationFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
184
223
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
185
224
  // and never fires under frozen: a bump/release gate must not re-dirty
186
225
  // the tree it just proved clean (field wart 2026-08-05, second head)
@@ -15,6 +15,7 @@ import { armHooks } from '../hooks/run-hooks.ts'
15
15
  import type { RepoConfig } from '../repo-config.ts'
16
16
  import { runFix } from '../rules/run-fix.ts'
17
17
  import { routesToShipkit } from './canonical-scripts.ts'
18
+ import { mutateLease, withMutateLease } from './locked-write.ts'
18
19
  import { spawnWithTimeout } from './spawn-timeout.ts'
19
20
 
20
21
  const FIXER_TIMEOUT_MS = 120_000
@@ -72,37 +73,47 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
72
73
  if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }
73
74
  else if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
74
75
  else {
75
- let cli: string | null = null
76
- cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
76
+ const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
77
77
  slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (shipkit)' })
78
78
  }
79
79
  return { slots, unfilled, disabled }
80
80
  }
81
81
 
82
- /** worktree fingerprint: every dirty path mapped to `<status>:<content hash>`,
83
- * or null outside a git repo. The fix pass diffs this before and after so the
84
- * report names real files instead of asserting "fixed" and hoping.
82
+ /** worktree fingerprint: every dirty path mapped to its CONTENT hash, or null
83
+ * outside a git repo. The fix pass diffs this before and after so the report
84
+ * names real files instead of asserting "fixed" and hoping.
85
85
  *
86
- * The hash is load-bearing, not decoration. Status alone is blind to the most
87
- * common case there is: a file the agent already edited (` M`) that a fixer
88
- * then reformats stays ` M`, so a status-only compare reports "nothing to
89
- * rewrite" while the tree changed under it, and in verify mode that is a
90
- * silent green on unformatted code. Hashing is confined to the dirty set,
91
- * which is small by construction. */
86
+ * The hash is the whole comparison, and the porcelain STATUS is deliberately
87
+ * not part of it. Status alone is blind to the commonest case there is: a
88
+ * file the agent already edited (` M`) that a fixer then reformats stays
89
+ * ` M`, so a status-only compare reports "nothing to rewrite" while the tree
90
+ * changed under it, and in verify mode that is a silent green on unformatted
91
+ * code. The reverse is just as wrong with three agents in one checkout: a
92
+ * second agent staging or committing a file during the pass moves its status,
93
+ * or drops it out of porcelain entirely, with the bytes untouched. Comparing
94
+ * bytes attributes only what a fixer can actually have done.
95
+ *
96
+ * It watches the WHOLE tree on purpose. A narrower porcelain was tried and
97
+ * deleted: the only fixer whose targets are declarable is the formatter, and
98
+ * the same pass also runs the rule fixers, which rewrite package.json,
99
+ * .gitignore, CLAUDE.md and .claude/, none of them inside a formatter's
100
+ * scope. Narrowing to the formatter's paths therefore hid real rewrites from
101
+ * the verify mode, which is a silent green on a tree a fixer changed. What
102
+ * actually stops another agent's work being reported here is the lease plus
103
+ * the byte comparison, not a path list. */
92
104
  function treeState(root: string): Map<string, string> | null {
93
105
  const p = Bun.spawnSync(['git', 'status', '--porcelain'], { cwd: root })
94
106
  if (p.exitCode !== 0) return null
95
107
  const m = new Map<string, string>()
96
108
  for (const l of p.stdout.toString().split('\n')) {
97
109
  if (l.trim() === '') continue
98
- const status = l.slice(0, 2)
99
110
  // rename entries read `R old -> new`; the new path is what exists
100
111
  const path = l.slice(3).trim().split(' -> ').at(-1) ?? ''
101
112
  if (path === '') continue
102
113
  const abs = join(root, path.replace(/^"|"$/g, ''))
103
114
  let hash = 'absent'
104
115
  try { if (existsSync(abs)) hash = String(Bun.hash(readFileSync(abs))) } catch { hash = 'unreadable' }
105
- m.set(path, `${status}:${hash}`)
116
+ m.set(path, hash)
106
117
  }
107
118
  return m
108
119
  }
@@ -119,7 +130,7 @@ export type FixPassOpts = {
119
130
 
120
131
  export type FixPassResult = {
121
132
  ran: boolean
122
- /** paths whose git status changed across the pass */
133
+ /** paths whose CONTENT changed across the pass */
123
134
  touched: string[]
124
135
  /** verify mode only: a fixer had to rewrite something, so the commit is not clean */
125
136
  failed: boolean
@@ -129,6 +140,30 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
129
140
  // verify mode: same fixers, but rewriting anything means the COMMIT was
130
141
  // not clean, and the gate says so instead of quietly repairing it
131
142
  const verify = process.env.CI != null && process.env.CI !== ''
143
+ // EVERY write this function makes sits inside one lease. Three agents in
144
+ // one checkout is the normal case, and two formatters rewriting the same
145
+ // file at once, or two runs arming hooks and rewriting package.json
146
+ // together, is the one hazard a snapshot diff cannot even detect. The
147
+ // loser waits rather than skipping, because a skipped repair never
148
+ // happens; a live holder that outlasts the cap is REFUSED by name rather
149
+ // than overlapped, and the gate below still runs, unrepaired and saying so.
150
+ const lease = await withMutateLease(mutateLease(root, 'fix'), { label: 'fix pass', print: o.print, escape: '--no-fix' }, () => fixPassBody(root, config, o, verify))
151
+ if (lease.ok) return lease.value
152
+ // in VERIFY mode the fix pass is the whole check: reporting "not run" and
153
+ // exiting 0 would pass a commit nobody verified, which is the one failure
154
+ // this gate exists to prevent. Locally it is a WARN, because the tasks and
155
+ // checks below still ran and still mean what they say.
156
+ if (verify) {
157
+ o.print(`FAIL: fixers never ran — ${lease.warn}`)
158
+ o.print(' fix: nothing was verified. re-run once that process finishes, or pass --no-fix to state that on purpose')
159
+ return { ran: false, touched: [], failed: true }
160
+ }
161
+ o.print(`WARN fix pass skipped — ${lease.warn}`)
162
+ o.print(' fix: nothing was repaired this run; the gate below reports the tree as it stands')
163
+ return { ran: false, touched: [], failed: false }
164
+ }
165
+
166
+ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
132
167
  // commit gates arm themselves on the interactive gate: a repo.config with
133
168
  // dark hooks is the half-wired state that let a whole drill session
134
169
  // commit ungated (2026-08-04). Runs BEFORE the tree snapshot: arming is
@@ -157,10 +192,18 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
157
192
  const after = treeState(root)
158
193
  const touched: string[] = []
159
194
  if (before != null && after != null) {
160
- for (const [path, st] of after) if (before.get(path) !== st) touched.push(path)
161
- // a file the fixer restored to its committed state disappears from
162
- // porcelain entirely, that is still a change worth naming
163
- for (const path of before.keys()) if (!after.has(path)) touched.push(path)
195
+ for (const [path, hash] of after) if (before.get(path) !== hash) touched.push(path)
196
+ // a path that left porcelain either got RESTORED to its committed
197
+ // bytes by a fixer, which is a rewrite worth naming, or was committed
198
+ // by another agent mid-pass, which is not this pass's doing. Only the
199
+ // bytes tell the two apart, so read them.
200
+ for (const [path, hash] of before) {
201
+ if (after.has(path)) continue
202
+ const abs = join(root, path.replace(/^"|"$/g, ''))
203
+ let now = 'absent'
204
+ try { if (existsSync(abs)) now = String(Bun.hash(readFileSync(abs))) } catch { now = 'unreadable' }
205
+ if (now !== hash) touched.push(path)
206
+ }
164
207
  }
165
208
  const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
166
209
  if (before == null) {