rvlib-shipkit 1.9.0 → 2.0.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/check.ts +5 -2
  4. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  5. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  6. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  7. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  8. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  9. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  10. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  11. package/src/cli/cmd_Ci.ts +3 -3
  12. package/src/cli/cmd_Commands.ts +21 -5
  13. package/src/cli/cmd_Hooks.ts +6 -2
  14. package/src/cli/cmd_Init.ts +1 -1
  15. package/src/cli/cmd_Serve.ts +1 -2
  16. package/src/cli/cmd_Start.ts +1 -1
  17. package/src/cli.ts +2 -4
  18. package/src/core/budget.ts +6 -8
  19. package/src/core/bun-builtins.ts +36 -0
  20. package/src/core/bun-tool-cli.ts +23 -0
  21. package/src/core/canonical-scripts.ts +6 -4
  22. package/src/core/check-ctx.ts +104 -10
  23. package/src/core/check-file-names.ts +2 -3
  24. package/src/core/check-pass.ts +17 -1
  25. package/src/core/check-runner.ts +19 -6
  26. package/src/core/config-rewrite.ts +27 -6
  27. package/src/core/discover-checks.ts +30 -10
  28. package/src/core/floor-resolve.ts +16 -7
  29. package/src/core/job-run.ts +2 -2
  30. package/src/core/layout.ts +4 -1
  31. package/src/core/legacy-shapes.ts +40 -0
  32. package/src/core/one-lib.ts +1 -1
  33. package/src/core/one-way-in-policy.ts +106 -13
  34. package/src/core/pre-commit-discovery.ts +106 -0
  35. package/src/core/retired-checks.ts +2 -0
  36. package/src/core/run-ci.ts +16 -10
  37. package/src/core/run-fix.ts +9 -0
  38. package/src/core/run-fixers.ts +91 -35
  39. package/src/core/runs-matrix.ts +7 -0
  40. package/src/core/slots.ts +1 -1
  41. package/src/core/vendored-read.ts +39 -0
  42. package/src/core/vendored.ts +82 -0
  43. package/src/core/waivers.ts +125 -0
  44. package/src/core/when-block.ts +25 -185
  45. package/src/core/when.ts +30 -13
  46. package/src/core/wire.ts +17 -1
  47. package/src/fleet/update-fleet.ts +11 -2
  48. package/src/git-bootstrap.ts +1 -1
  49. package/src/home/attach.ts +3 -5
  50. package/src/home/insights.ts +6 -1
  51. package/src/home/pm-config.ts +2 -2
  52. package/src/home/run-daemon.ts +2 -2
  53. package/src/home/run-unit-action.ts +2 -2
  54. package/src/hooks/commit-message.ts +2 -14
  55. package/src/hooks/hook-gates.ts +38 -0
  56. package/src/hooks/leak-guard.ts +8 -3
  57. package/src/hooks/one-way-in-guard.ts +8 -2
  58. package/src/hooks/root-identity.ts +9 -6
  59. package/src/hooks/run-hooks.ts +72 -41
  60. package/src/hooks/staged.ts +49 -0
  61. package/src/hooks/tree-shrink.ts +41 -0
  62. package/src/init-repo.ts +0 -42
  63. package/src/ledger/check-table.ts +1 -1
  64. package/src/ledger/gate-checks.ts +45 -9
  65. package/src/ledger/ledger.ts +6 -1
  66. package/src/ledger/repo-view.ts +14 -4
  67. package/src/ledger/toolchain-identity.ts +4 -2
  68. package/src/mv/mv-md.ts +6 -1
  69. package/src/problems/extract.ts +27 -5
  70. package/src/problems/problem-line.ts +2 -3
  71. package/src/release/run-release.ts +13 -8
  72. package/src/repo-config.ts +44 -27
  73. package/src/scaffold/files/bunfig.toml +11 -0
  74. package/src/scaffold/files/vscode-settings.json +1 -2
  75. package/src/scaffold/jsonc-spans.ts +16 -0
  76. package/src/scaffold/project-scaffold.ts +43 -77
  77. package/src/serve/hub-app/check-view.tsx +4 -4
  78. package/src/serve/hub-app/check-words.ts +1 -0
  79. package/src/serve/hub-app/checks-svc.ts +1 -2
  80. package/src/serve/hub-app/home.html +0 -1
  81. package/src/serve/hub-app/hub-types.ts +0 -2
  82. package/src/serve/hub-app/index.html +0 -1
  83. package/src/serve/hub-app/project-list.css +1 -2
  84. package/src/serve/hub-app/tree-build.ts +3 -2
  85. package/src/serve/hub-app/tree-svc.ts +2 -2
  86. package/src/serve/hub-port.ts +3 -3
  87. package/src/serve/hub-runtime.ts +0 -3
  88. package/src/serve/serve-hub.ts +1 -16
  89. package/src/serve/tracked-watch.ts +1 -1
  90. package/src/start-repo.ts +15 -3
  91. package/src/suite/judge-suites.ts +29 -28
  92. package/src/suite/select.ts +2 -0
  93. package/src/suite/suite-cli.ts +6 -3
  94. package/src/suite/suite-cpu-preload.ts +7 -11
  95. package/src/suite/test-units.ts +7 -4
  96. package/src/ts/config.ts +12 -5
  97. package/src/ts/imports.ts +3 -1
  98. package/src/tui/build-steps-panel.ts +1 -1
  99. package/src/wiki/scan-md.ts +16 -9
  100. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  101. package/src/checks/SK014-prose-ste.check.ts +0 -78
  102. package/src/cli/cmd_Prose.ts +0 -74
  103. package/src/prose/prose-view.ts +0 -90
  104. package/src/prose/run-prose.ts +0 -187
  105. package/src/prose/ste.ts +0 -552
  106. package/src/scaffold/files/vscode-tasks.json +0 -24
  107. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  108. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  109. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -7,7 +7,7 @@
7
7
  // at once (lib-watch.ts): the new image follows the jobs still running. The
8
8
  // machine resources are pm's.
9
9
  import { fileURLToPath } from 'url'
10
- import { useOneLib } from '../core/one-lib.ts'
10
+ import { installOneLib } from '../core/one-lib.ts'
11
11
  import { reclaimPreviousImage } from '../core/instance.ts'
12
12
  import { reloadOnOwnCode } from './lib-watch.ts'
13
13
  import { loadHub } from '../serve/hub-runtime.ts'
@@ -47,7 +47,7 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
47
47
  }
48
48
  // before the first repo config loads: every one of them, and every check
49
49
  // file, then imports this copy of the lib
50
- useOneLib()
50
+ installOneLib()
51
51
  // a process this one starts runs its gate and its hub in process
52
52
  process.env[WORKER_ENV] = '1'
53
53
  // a restart in place keeps the pid: the check children its previous
@@ -12,8 +12,8 @@ if (import.meta.main) {
12
12
  console.error('usage: bun run-unit-action.ts <unit file> <action id>')
13
13
  process.exit(2)
14
14
  }
15
- const { useOneLib } = await import('../core/one-lib.ts')
16
- useOneLib()
15
+ const { installOneLib } = await import('../core/one-lib.ts')
16
+ installOneLib()
17
17
  const u = ((await import(file)) as { default?: GlobalProcessUnit }).default
18
18
  const run = u?.actions?.[id]?.run
19
19
  if (run == null) {
@@ -1,9 +1,7 @@
1
1
  // the commit-message predicate of the commit-msg hook, which blocks at write
2
- // time: the one guard against AI attribution (SK009 checks it is armed).
3
- //
4
- // mechanical text rules only, so this stays publishable.
2
+ // time. mechanical text rules only, so this stays publishable.
5
3
 
6
- export type OffenseKind = 'dash' | 'attribution' | 'language'
4
+ export type OffenseKind = 'dash' | 'language'
7
5
  export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
8
6
 
9
7
  /** git surfaces are English, always (identity/writing-voice.md). cheap French
@@ -34,13 +32,6 @@ export function frenchHits(line: string, languageAllow: string[] = []): boolean
34
32
  return words.size >= 2
35
33
  }
36
34
 
37
- /** trailers and footers that name an assistant, assistant session trailers,
38
- * assistant share-domain urls (a session link is a louder tell than any
39
- * trailer), plus the robot emoji. the session/url family lists every
40
- * assistant, not just the one that bit first. */
41
- export const AI_ATTRIBUTION =
42
- /(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|\b(?:claude|chatgpt|copilot|cursor|aider|devin|gemini|codex)-session:|\b(?:claude\.(?:ai|com)|chatgpt\.com|chat\.openai\.com)\b|🤖/i
43
-
44
35
  /** everything below git's scissors line is the verbose-mode diff, which git
45
36
  * strips before committing, diff content is not ours to police. */
46
37
  export function stripNonMessage(message: string): string {
@@ -62,8 +53,6 @@ export function commitMessageOffenses(message: string, opts?: { languageAllow?:
62
53
  const body = raw.replace(/^\s*[-*] /, '')
63
54
  if (body.includes(' - '))
64
55
  offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
65
- if (AI_ATTRIBUTION.test(raw))
66
- offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, session trailer, claude.ai/chatgpt.com url, "Generated with" footer, or robot emoji' })
67
56
  if (frenchHits(raw, opts?.languageAllow))
68
57
  offenses.push({ line: i + 1, text: raw, kind: 'language', why: 'French: git surfaces are English, always (identity/writing-voice.md); quote product copy in "…" or `…` to exempt it' })
69
58
  })
@@ -71,4 +60,3 @@ export function commitMessageOffenses(message: string, opts?: { languageAllow?:
71
60
  }
72
61
 
73
62
  export const hasDashPunctuation = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'dash')
74
- export const hasAiAttribution = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'attribution')
@@ -0,0 +1,38 @@
1
+ // THE COMMIT GATES the git hooks run, as an inventory. They run from
2
+ // hooks/run-hooks.ts, never through the runner, so the hook stays a leaf that
3
+ // loads no check discovery. This list is what every other surface reads to
4
+ // show them beside the checks (`shipkit checks`, `shipkit when`, the hub) and
5
+ // what `off` names to turn one off. Type imports only: the hook loads it.
6
+ import type { RepoConfig } from '../repo-config.ts'
7
+ import { repoOff, type RepoOff } from '../core/when.ts'
8
+
9
+ /** which git hook runs the gate */
10
+ export type HookName = 'pre-commit' | 'commit-msg' | 'both'
11
+
12
+ export type HookGate = { id: string; hook: HookName; title: string; description: string }
13
+
14
+ export const HOOK_GATES = [
15
+ { id: 'tree-shrink', hook: 'pre-commit', title: 'tree shrink', description: 'the commit keeps at least half of the files HEAD tracks' },
16
+ { id: 'leak-guard', hook: 'both', title: 'leak guard', description: 'no staged added line, staged path or commit message matches a banned keyword' },
17
+ { id: 'blocked-paths', hook: 'pre-commit', title: 'blocked paths', description: 'nothing under a hooks.blockedPaths prefix is staged' },
18
+ { id: 'root-identity', hook: 'pre-commit', title: 'root identity', description: 'the repo name in package.json and repo.config.ts holds, and no scratch file is staged' },
19
+ { id: 'commit-message', hook: 'commit-msg', title: 'commit message', description: 'the commit message carries no dash punctuation and no French' },
20
+ { id: 'commit-language', hook: 'commit-msg', title: 'commit language', description: 'the commit message carries no French, the half of commit-message that off lifts alone' },
21
+ ] as const satisfies readonly HookGate[]
22
+
23
+ export type HookGateId = (typeof HOOK_GATES)[number]['id']
24
+
25
+ /** the page that owns every commit gate */
26
+ export const HOOK_GATES_OWNER = 'build/shipkit/commit-gates.md'
27
+
28
+ /** the whole pre-commit hook, boot included, past which it warns */
29
+ export const HOOK_BUDGET_MS = 1_000
30
+
31
+ /** the repo turned this gate off, and why. commit-language is the French half
32
+ * of commit-message, so turning commit-message off turns it off too */
33
+ export function hookGateOff(c: RepoConfig, id: HookGateId): RepoOff | null {
34
+ const own = repoOff(c, id)
35
+ if (own != null || id !== 'commit-language') return own
36
+ const parent = repoOff(c, 'commit-message')
37
+ return parent == null ? null : { ...parent, reason: `commit-message is off: ${parent.reason}` }
38
+ }
@@ -285,6 +285,8 @@ export type LeakGuardOpts = {
285
285
  allow?: LeakAllowEntry[]
286
286
  /** override the machine-global allowlist path; null reads none */
287
287
  allowFile?: string | null
288
+ /** the staged paths, when the caller already listed them (hooks/staged.ts) */
289
+ stagedNames?: string[]
288
290
  }
289
291
 
290
292
  /** git c-quotes a path that carries a space or a non-ascii byte. */
@@ -368,13 +370,16 @@ export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResu
368
370
  }
369
371
 
370
372
  const targets: ScanTarget[] = []
371
- if (o.staged !== false) {
373
+ if (o.staged !== false && o.stagedNames?.length !== 0) {
372
374
  const diff = Bun.spawnSync([gitBin(), 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
373
375
  if (diff.exitCode === 0)
374
376
  for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
375
377
  // a filename can leak as loudly as a line of code
376
- const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
377
- if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
378
+ const names = o.stagedNames ?? (() => {
379
+ const p = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
380
+ return p.exitCode === 0 ? p.stdout.toString().split('\n') : []
381
+ })()
382
+ for (const n of names) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
378
383
  }
379
384
  for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
380
385
 
@@ -4,14 +4,15 @@
4
4
  // type instead: `permissions.deny` already blocks these, but a bare deny tells
5
5
  // the model only that it was denied, and a refusal with no replacement just
6
6
  // sends it looking for another way in. It also refuses a gate piped into
7
- // another command, whose exit code the pipe hides.
7
+ // another command, whose exit code the pipe hides, and a `git stash` in the
8
+ // forms the deny row cannot see.
8
9
  //
9
10
  // It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
10
11
  // small json read, no clipanion, no repo config. Anything it cannot decide it
11
12
  // waves through, because a guard that fails closed on its own bugs would wedge
12
13
  // a repo with no way to tell why, and `permissions.deny` is still the floor
13
14
  // underneath it. Measured at ~10ms per call.
14
- import { deniedHeads, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
15
+ import { deniedHeads, GIT_STASH_HEAD, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal, stashCallIn, stashRefusal } from '../core/one-way-in-policy.ts'
15
16
 
16
17
  const input = await Bun.stdin.text()
17
18
  let command = ''
@@ -38,6 +39,11 @@ try {
38
39
  } catch {
39
40
  process.exit(0)
40
41
  }
42
+ const stash = command !== '' && denied.has(GIT_STASH_HEAD) ? stashCallIn(command) : null
43
+ if (stash != null) {
44
+ console.error(stashRefusal(stash))
45
+ process.exit(2)
46
+ }
41
47
  const hit = command === '' || denied.size === 0 ? null : rawRunnerHit(command, denied)
42
48
  if (hit == null) process.exit(0)
43
49
  // exit 2 is the block: stderr becomes the reason the model reads
@@ -8,6 +8,7 @@
8
8
  import { existsSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { gitBin } from '../core/git-bin.ts'
11
+ import { readStaged, type Staged } from './staged.ts'
11
12
 
12
13
  export type IdentityOutcome =
13
14
  | { status: 'skipped'; reason: string }
@@ -47,19 +48,21 @@ function identityDrift(root: string, path: string, read: (s: string) => string |
47
48
  return `${path}: name '${before}' → '${after}'`
48
49
  }
49
50
 
50
- export function runRootIdentity(root: string): IdentityOutcome {
51
- if (git(root, ['rev-parse', '--verify', 'HEAD']).code !== 0)
51
+ export function runRootIdentity(root: string, staged: Staged = readStaged(root)): IdentityOutcome {
52
+ if (!staged.head)
52
53
  return { status: 'skipped', reason: 'no HEAD yet — nothing to compare the first commit against' }
53
54
  if (process.env['SHIPKIT_ALLOW_IDENTITY_CHANGE'] === '1')
54
55
  return { status: 'skipped', reason: 'SHIPKIT_ALLOW_IDENTITY_CHANGE=1' }
55
56
 
57
+ // a file the commit does not stage is the same blob in the index and in
58
+ // HEAD, so only a staged one is worth reading
59
+ const touched = new Set(staged.names)
56
60
  const changes = [
57
- identityDrift(root, 'package.json', packageName),
58
- ...(existsSync(join(root, 'repo.config.ts')) ? [identityDrift(root, 'repo.config.ts', configName)] : []),
61
+ touched.has('package.json') ? identityDrift(root, 'package.json', packageName) : null,
62
+ touched.has('repo.config.ts') && existsSync(join(root, 'repo.config.ts')) ? identityDrift(root, 'repo.config.ts', configName) : null,
59
63
  ].filter((c): c is string => c != null)
60
64
 
61
- const added = git(root, ['diff', '--cached', '--name-only', '--diff-filter=A'])
62
- const scratch = added.out.split('\n').map((l) => l.trim()).filter((l) => l !== '' && SCRATCH.test(l))
65
+ const scratch = staged.entries.filter((e) => e.status === 'A' && SCRATCH.test(e.path)).map((e) => e.path)
63
66
 
64
67
  return changes.length === 0 && scratch.length === 0 ? { status: 'clean' } : { status: 'changed', changes, scratch }
65
68
  }
@@ -4,20 +4,24 @@
4
4
  // while its next commit is already doomed, and nothing can enumerate what
5
5
  // the hooks enforce. `shipkit hooks list` answers that without reading shell.
6
6
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
7
- import { dirname, join } from 'path'
7
+ import { dirname, join, relative } from 'path'
8
8
  import { fileURLToPath } from 'url'
9
9
  import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
10
10
  import { commitMessageOffenses } from './commit-message.ts'
11
11
  import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, SK_KEY_ROW, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
12
12
  import { runRootIdentity } from './root-identity.ts'
13
+ import { readStaged } from './staged.ts'
14
+ import { ALLOW_MASS_DELETE, runTreeShrink } from './tree-shrink.ts'
13
15
  import { gitRoot } from '../core/git-root.ts'
14
16
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
15
17
  import type { RepoConfig } from '../repo-config.ts'
16
18
  import { gitBin } from '../core/git-bin.ts'
17
- import { discoverChecks } from '../core/discover-checks.ts'
19
+ import { discoverPreCommit, SLOW_IMPORT_MS } from '../core/pre-commit-discovery.ts'
18
20
  import { CheckCtx } from '../core/check-ctx.ts'
21
+ import { CheckPass } from '../core/check-pass.ts'
19
22
  import { runCheckBody, type CheckDef } from '../check.ts'
20
23
  import { repoOff } from '../core/when.ts'
24
+ import { HOOK_BUDGET_MS, hookGateOff, type HookGateId } from './hook-gates.ts'
21
25
  import { formatLine } from '../core/wire.ts'
22
26
 
23
27
  export type HooksConfig = {
@@ -56,11 +60,11 @@ export type HooksConfig = {
56
60
  /** words the commit-message language gate never counts as French
57
61
  * (proper nouns that carry accents without being a language choice) */
58
62
  languageAllow?: string[]
59
- /** disable a built-in gate, visible in `hooks list`, never silent.
60
- * 'commit-language' lifts ONLY the French check, keeping the dash and
61
- * AI-attribution guards armed. 'commit-message' lifts the dash and French
62
- * checks; the AI-attribution guard is never lifted. */
63
- disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
63
+ /** RETIRED, read as `off` while SK029 ports it there: a commit gate is
64
+ * turned off like any check, `off: { <gate id>: '<why>' }`
65
+ * (build/shipkit/runs.md). Dies once `shipkit fleet` shows no repo
66
+ * carrying it */
67
+ disable?: HookGateId[]
64
68
  }
65
69
 
66
70
  const HOOK_DIR = LAYOUT.hooks
@@ -220,9 +224,26 @@ const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
220
224
  export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
221
225
  const started = performance.now()
222
226
  const h = config.hooks ?? {}
227
+ const off = (id: HookGateId): boolean => hookGateOff(config, id) != null
223
228
  const out: GateOutcome[] = []
224
- if (!(h.disable ?? []).includes('leak-guard')) {
225
- const r = runLeakGuard(root, { keywordFile: h.keywordFile, allow: h.leakAllow })
229
+ const staged = readStaged(root)
230
+ if (!off('tree-shrink')) {
231
+ const r = runTreeShrink(root, staged)
232
+ if (r.status === 'skipped') out.push({ id: 'tree-shrink', ok: true, lines: [`skip: tree-shrink (${r.reason})`] })
233
+ else if (r.status === 'clean') out.push({ id: 'tree-shrink', ok: true, lines: ['ok: tree-shrink, the commit keeps the tree'] })
234
+ else if (r.status === 'shrunk') out.push({
235
+ id: 'tree-shrink',
236
+ ok: false,
237
+ lines: [
238
+ `FAIL: this commit keeps ${r.stagedFiles} of HEAD's ${r.headFiles} files (${r.deleted} deleted), under half the tree`,
239
+ ` a commit from a private GIT_INDEX_FILE that was never filled with \`git read-tree HEAD\` deletes every file it did not stage`,
240
+ ` fix: commit from the real index by pathspec (git commit -m … -- <paths>), or fill the private index first`,
241
+ ` a deliberate mass delete: ${ALLOW_MASS_DELETE}=1 git commit ...`,
242
+ ],
243
+ })
244
+ }
245
+ if (!off('leak-guard')) {
246
+ const r = runLeakGuard(root, { keywordFile: h.keywordFile, allow: h.leakAllow, stagedNames: staged.names })
226
247
  if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
227
248
  else if (r.status === 'clean') out.push({
228
249
  id: 'leak-guard',
@@ -246,10 +267,8 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
246
267
  ],
247
268
  })
248
269
  }
249
- if ((h.blockedPaths ?? []).length > 0) {
250
- const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
251
- const staged = names.stdout.toString().split('\n').filter(Boolean)
252
- const blocked = blockedStaged(staged, config)
270
+ if ((h.blockedPaths ?? []).length > 0 && !off('blocked-paths')) {
271
+ const blocked = blockedStaged(staged.names, config)
253
272
  out.push(blocked.length === 0
254
273
  ? { id: 'blocked-paths', ok: true, lines: [`ok: blocked-paths clean (${h.blockedPaths!.join(', ')})`] }
255
274
  : {
@@ -262,8 +281,8 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
262
281
  ],
263
282
  })
264
283
  }
265
- if (!(h.disable ?? []).includes('root-identity')) {
266
- const r = runRootIdentity(root)
284
+ if (!off('root-identity')) {
285
+ const r = runRootIdentity(root, staged)
267
286
  if (r.status === 'skipped') out.push({ id: 'root-identity', ok: true, lines: [`skip: root-identity (${r.reason})`] })
268
287
  else if (r.status === 'clean') out.push({ id: 'root-identity', ok: true, lines: ['ok: root-identity unchanged'] })
269
288
  else out.push({
@@ -297,15 +316,19 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
297
316
  return out
298
317
  }
299
318
 
300
- /** the whole hook, boot included, past which it warns */
301
- export const HOOK_BUDGET_MS = 1_000
319
+ export { HOOK_BUDGET_MS }
302
320
 
303
321
  /** the checks that declare `onPreCommit` and apply here, with their budget */
304
322
  export type PreCommitCheck = { id: string; title: string; owner: string; budgetMs: number; run: NonNullable<CheckDef['run']>; repair: unknown }
305
323
 
306
324
  export async function preCommitChecks(root: string, config: RepoConfig): Promise<PreCommitCheck[]> {
325
+ return (await preCommitDiscovery(root, config)).checks
326
+ }
327
+
328
+ async function preCommitDiscovery(root: string, config: RepoConfig): Promise<{ checks: PreCommitCheck[]; slow: { path: string; ms: number }[] }> {
329
+ const found = await discoverPreCommit(root)
307
330
  const out: PreCommitCheck[] = []
308
- for (const u of await discoverChecks(root)) {
331
+ for (const u of found.checks) {
309
332
  const p = u.def.runs.onPreCommit
310
333
  if (p === false || u.def.run == null) continue
311
334
  if (repoOff(config, u.id) != null) continue
@@ -314,16 +337,25 @@ export async function preCommitChecks(root: string, config: RepoConfig): Promise
314
337
  }
315
338
  out.push({ id: u.id, title: u.title, owner: u.owner, budgetMs: p.budgetMs, run: u.def.run, repair: u.def.repair })
316
339
  }
317
- return out
340
+ return { checks: out, slow: found.slow }
318
341
  }
319
342
 
320
343
  /** every `onPreCommit` check, run RAW on the working tree: no ledger, no
321
344
  * cache, no repair. A failure refuses the commit, a pass over its budget
322
- * warns by name and never blocks on time */
345
+ * warns by name and never blocks on time. One pass for all of them, and one
346
+ * that records no config reads, since nothing stores these answers */
323
347
  async function runPreCommitChecks(root: string, config: RepoConfig): Promise<GateOutcome[]> {
324
348
  const out: GateOutcome[] = []
325
- for (const k of await preCommitChecks(root, config)) {
326
- const c = CheckCtx.open({ root, config })
349
+ const found = await preCommitDiscovery(root, config)
350
+ for (const s of found.slow)
351
+ out.push({
352
+ id: 'slow-import',
353
+ ok: true,
354
+ lines: [`WARN ${relative(root, s.path)} took ${Math.round(s.ms)}ms to import, over ${SLOW_IMPORT_MS}ms: every surface that lists the checks pays it, so move its heavy imports inside \`run\` (build/shipkit/writing-a-check.md)`],
355
+ })
356
+ const pass = new CheckPass(root, config, undefined, false)
357
+ for (const k of found.checks) {
358
+ const c = new CheckCtx(pass)
327
359
  const t = performance.now()
328
360
  await runCheckBody({ id: k.id, title: k.title, owner: k.owner, run: k.run, fix: k.repair }, c)
329
361
  const ms = performance.now() - t
@@ -339,12 +371,12 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
339
371
  const h = config.hooks ?? {}
340
372
  const out: GateOutcome[] = []
341
373
  const message = readFileSync(messageFile, 'utf8')
342
- if (!(h.disable ?? []).includes('commit-message')) {
343
- const languageOff = (h.disable ?? []).includes('commit-language')
374
+ if (hookGateOff(config, 'commit-message') == null) {
375
+ const languageOff = hookGateOff(config, 'commit-language') != null
344
376
  const offenses = commitMessageOffenses(message, { languageAllow: h.languageAllow })
345
377
  .filter((o) => o.kind !== 'language' || !languageOff)
346
378
  out.push(offenses.length === 0
347
- ? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation, no AI attribution${languageOff ? '' : ', English'})`] }
379
+ ? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation${languageOff ? '' : ', English'})`] }
348
380
  : {
349
381
  id: 'commit-message',
350
382
  ok: false,
@@ -353,17 +385,10 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
353
385
  ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
354
386
  ],
355
387
  })
356
- } else {
357
- // AI attribution is the one message rule no config lifts: this hook is
358
- // the guard against it in every repo (identity/how-we-commit.md)
359
- const offenses = commitMessageOffenses(message).filter((o) => o.kind === 'attribution')
360
- out.push(offenses.length === 0
361
- ? { id: 'ai-attribution', ok: true, lines: ['ok: no AI attribution in the commit message'] }
362
- : { id: 'ai-attribution', ok: false, lines: [`FAIL: commit message carries AI attribution`, ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`)] })
363
388
  }
364
389
  // the leak guard also reads the MESSAGE, a private name pasted into a
365
390
  // commit body is as public as one pasted into a file
366
- if (!(h.disable ?? []).includes('leak-guard')) {
391
+ if (hookGateOff(config, 'leak-guard') == null) {
367
392
  const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, allow: h.leakAllow, text: [{ label: 'commit message', content: message }] })
368
393
  if (r.status === 'hit')
369
394
  out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map(hitLine), ...waivedLines(r.waived)] })
@@ -381,27 +406,33 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
381
406
  return out
382
407
  }
383
408
 
384
- /** what blocks a commit here, the enumeration hand-rolled hooks never had. */
409
+ /** what blocks a commit here, the enumeration hand-rolled hooks never had.
410
+ * A gate the repo turned off stays listed, with the reason `off` gives it */
385
411
  export function listGates(config: RepoConfig, root = process.cwd(), checks: { id: string; budgetMs: number }[] = []): string[] {
386
412
  const h = config.hooks ?? {}
387
- const off = new Set(h.disable ?? [])
413
+ const offWord = (id: HookGateId): string => {
414
+ const o = hookGateOff(config, id)
415
+ return o == null ? '' : ` (OFF: ${o.reason})`
416
+ }
388
417
  const kw = h.keywordFile ?? KEYWORD_FILE
389
418
  // a gate listed as active but unable to fire is worse than no listing:
390
419
  // this surface exists so nobody has to guess
391
- const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.shipkit/private list)`
420
+ const leakState = offWord('leak-guard') !== '' ? offWord('leak-guard') : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.shipkit/private list)`
392
421
  // a waiver nobody can enumerate is a hole nobody can audit: the count of
393
422
  // declared exemptions belongs on the same surface as the gate itself
394
423
  const waivers = (h.leakAllow ?? []).length + (globalAllowFile() != null ? 1 : 0)
395
424
  const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
425
+ const messageOff = offWord('commit-message')
396
426
  return [
397
427
  `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
398
- ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
399
- `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
428
+ ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths${offWord('blocked-paths')} — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
429
+ `pre-commit root-identity${offWord('root-identity')} — the repo's own name in package.json + repo.config.ts, and scratch files`,
430
+ `pre-commit tree-shrink${offWord('tree-shrink')} — the commit keeps at least half of HEAD's files (${ALLOW_MASS_DELETE}=1 lifts it for one commit)`,
400
431
  ...checks.map((c) => `pre-commit ${c.id} — the check, onPreCommit, budget ${c.budgetMs}ms`),
401
432
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
402
- off.has('commit-message')
403
- ? `commit-msg commit-message (DISABLED by config) — dash punctuation + French; ai-attribution stays on, never disabled`
404
- : `commit-msg commit-message — dash punctuation + AI attribution (never disabled) + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
433
+ messageOff !== ''
434
+ ? `commit-msg commit-message${messageOff} — dash punctuation + French`
435
+ : `commit-msg commit-message — dash punctuation + French${offWord('commit-language') === '' ? '' : ` (commit-language${offWord('commit-language')})`}`,
405
436
  `commit-msg leak-guard${leakState} — the message text`,
406
437
  ...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
407
438
  ]
@@ -0,0 +1,49 @@
1
+ // what this commit changes against HEAD, listed ONCE for every git based
2
+ // step of the pre-commit gate: each step spawning its own `git diff --cached`
3
+ // costs a process start per step on every commit. Every call inherits the
4
+ // environment, so a commit made under a private GIT_INDEX_FILE is read from
5
+ // that same index, which is the one git is about to commit.
6
+ import { gitBin } from '../core/git-bin.ts'
7
+ import { gitRoot } from '../core/git-root.ts'
8
+
9
+ /** one staged change: git's status letter (A, M, D, R, C, T, U), the path as
10
+ * `--name-only` prints it, and for a rename or a copy the path it came from */
11
+ export type StagedEntry = { status: string; path: string; from?: string }
12
+
13
+ export type Staged = {
14
+ /** HEAD resolves: false on the first commit of a repo */
15
+ head: boolean
16
+ entries: StagedEntry[]
17
+ /** every staged path, in git's order */
18
+ names: string[]
19
+ }
20
+
21
+ /** `git diff --cached --name-status -z`: a status field, then one path, or
22
+ * two for a rename or a copy. NUL separated, so no path is ever quoted */
23
+ export function parseNameStatus(out: string): StagedEntry[] {
24
+ const f = out.split('\0')
25
+ const entries: StagedEntry[] = []
26
+ for (let i = 0; i < f.length; ) {
27
+ const status = f[i++] ?? ''
28
+ if (status === '') break
29
+ const letter = status[0]!
30
+ if (letter === 'R' || letter === 'C') {
31
+ const from = f[i++] ?? ''
32
+ entries.push({ status: letter, path: f[i++] ?? '', from })
33
+ } else entries.push({ status: letter, path: f[i++] ?? '' })
34
+ }
35
+ return entries
36
+ }
37
+
38
+ export function readStaged(root: string): Staged {
39
+ if (gitRoot(root) == null) return { head: false, entries: [], names: [] }
40
+ // the env is passed on purpose: Bun's default is the environment the
41
+ // process started with, never one a caller set since
42
+ const head = Bun.spawnSync([gitBin(), 'rev-parse', '--verify', '-q', 'HEAD'], { cwd: root, env: process.env, stdout: 'ignore', stderr: 'ignore' }).exitCode === 0
43
+ const p = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-status', '-z'], { cwd: root, env: process.env, maxBuffer: 64 * 1024 * 1024 })
44
+ // an unreadable index is no answer, and every gate below would read it as
45
+ // "nothing staged" and pass
46
+ if (p.exitCode !== 0) throw new Error(`git diff --cached exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n')[0] ?? ''}`)
47
+ const entries = parseNameStatus(p.stdout.toString())
48
+ return { head, entries, names: entries.map((e) => e.path) }
49
+ }
@@ -0,0 +1,41 @@
1
+ // a commit must not drop most of the tree.
2
+ //
3
+ // the failure this exists for: a commit made from a private GIT_INDEX_FILE
4
+ // that was never filled with `git read-tree HEAD` holds only the files staged
5
+ // into it, so git records every other file as deleted and the tree comes out
6
+ // near empty. Every other gate passes, because nothing it reads is wrong.
7
+ import type { Staged } from './staged.ts'
8
+ import { gitBin } from '../core/git-bin.ts'
9
+
10
+ export const ALLOW_MASS_DELETE = 'SHIPKIT_ALLOW_MASS_DELETE'
11
+
12
+ export type ShrinkOutcome =
13
+ | { status: 'skipped'; reason: string }
14
+ | { status: 'clean' }
15
+ | { status: 'shrunk'; headFiles: number; stagedFiles: number; deleted: number }
16
+
17
+ /** the decision, with no git in it. `headFiles` is asked for only when the
18
+ * commit removes more files than it adds, so an ordinary commit pays nothing */
19
+ export function shrinkVerdict(staged: Staged, headFiles: () => number): ShrinkOutcome {
20
+ if (!staged.head) return { status: 'skipped', reason: 'no HEAD yet, nothing to shrink' }
21
+ let deleted = 0
22
+ let added = 0
23
+ for (const e of staged.entries) {
24
+ if (e.status === 'D') deleted++
25
+ else if (e.status === 'A' || e.status === 'C') added++
26
+ }
27
+ if (deleted <= added) return { status: 'clean' }
28
+ const before = headFiles()
29
+ const after = before - deleted + added
30
+ if (after * 2 >= before) return { status: 'clean' }
31
+ if (process.env[ALLOW_MASS_DELETE] === '1') return { status: 'skipped', reason: `${ALLOW_MASS_DELETE}=1` }
32
+ return { status: 'shrunk', headFiles: before, stagedFiles: after, deleted }
33
+ }
34
+
35
+ export function headFileCount(root: string): number {
36
+ const p = Bun.spawnSync([gitBin(), 'ls-tree', '-r', '-z', '--name-only', '--full-tree', 'HEAD'], { cwd: root, env: process.env, maxBuffer: 256 * 1024 * 1024 })
37
+ if (p.exitCode !== 0) throw new Error(`git ls-tree HEAD exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n')[0] ?? ''}`)
38
+ return p.stdout.toString().split('\0').filter(Boolean).length
39
+ }
40
+
41
+ export const runTreeShrink = (root: string, staged: Staged): ShrinkOutcome => shrinkVerdict(staged, () => headFileCount(root))
package/src/init-repo.ts CHANGED
@@ -17,9 +17,6 @@ import { reportToMotherhome } from './core/motherhome.ts'
17
17
  import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
18
18
  import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
19
19
  import { gitignorePolicy } from './checks/SK004-gitignore-policy.check.ts'
20
- import { vscodeHubTask } from './checks/SK017-vscode-hub-task.check.ts'
21
- import { editorErgonomics } from './scaffold/project-scaffold.ts'
22
- import { defaultHubPort } from './serve/hub-port.ts'
23
20
 
24
21
  // This lands as SOURCE in the repo being wired, so its comments answer to that
25
22
  // repo's comment style, not to this file's. Dash punctuation is banned there
@@ -105,7 +102,6 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
105
102
  // the repos that need ONE WAY IN most are the ones that already exist,
106
103
  // so a re-run REPAIRS rather than only seeding a fresh checkout
107
104
  await repairOneWayIn(root, cfg, p)
108
- await furnishEditor(root, cfg, p)
109
105
  const mh = reportToMotherhome(root, cfg)
110
106
  if (mh.note != null) p(mh.note)
111
107
  if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
@@ -139,7 +135,6 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
139
135
  const g = await gitignorePolicy.repair!(CheckCtx.open({ root, config: defineRepo({ name }) }))
140
136
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
141
137
  await repairOneWayIn(root, defineRepo({ name }), p)
142
- await furnishEditor(root, { name }, p)
143
138
  // what the gate actually runs, in ONE line: a thin floor must be seen at
144
139
  // init time rather than discovered after a bad commit slipped through.
145
140
  // The per-task resolution is diagnostics, and a `skip:` naming a script
@@ -170,43 +165,6 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
170
165
  return 0
171
166
  }
172
167
 
173
- /** the folder-open story, on the adopt path as well as the start one.
174
- *
175
- * `start` scaffolds the whole day-one shape, but only for a dir with no
176
- * commit history, so every repo that already existed when it was wired got
177
- * repo.config.ts and the scripts and no .vscode/ at all: opening the folder
178
- * started nothing, and the hub the config advertises had no way in. These two
179
- * files are the only half of that scaffold an adopted repo can be handed
180
- * unasked, because they change no output of any command (project-scaffold.ts
181
- * owns the line). Create-only, so a .vscode/ the repo brought with it wins. */
182
- async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): Promise<void> {
183
- // the least important thing init does, and it runs AFTER repo.config.ts is
184
- // on disk: an unwritable .vscode (a read-only tree, a path that is a file),
185
- // or a config with no name at all, must not abort the run before the
186
- // publish chain, and must not pass in silence either. The port resolution
187
- // is INSIDE the try for that reason: it reads a config nothing validates
188
- try {
189
- const name = repoName(root, undefined, cfg.name)
190
- const port = cfg.serve?.port ?? defaultHubPort(name)
191
- if (editorErgonomics(root, { name, port }).length === 0) {
192
- // the repo's own file stays its own, except the hub task, which
193
- // rule SK017 keeps feeding the problems pane
194
- // a file SK017 cannot read is still the repo's own: it is never
195
- // reported as missing, and it is never rewritten
196
- let hub: { changed: boolean; summary: string }
197
- try { hub = await vscodeHubTask.repair!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
198
- p('ok: .vscode/ is this repo\'s own, left untouched')
199
- return void p(`WARN .vscode/tasks.json does not parse (${e instanceof Error ? e.message : String(e)}): the hub task gets no problem matcher until it does`)
200
- }
201
- return void p(hub.changed ? `ok: ${hub.summary}` : 'ok: .vscode/ is this repo\'s own, left untouched')
202
- }
203
- p(`ok: .vscode/tasks.json written — opening the folder starts the hub on ${port}`)
204
- p(' next: add this stack\'s own watchers (dev server, css) — build/vscode-tasks.md carries the shapes')
205
- } catch (e) {
206
- p(`WARN no .vscode/tasks.json here: ${e instanceof Error ? e.message : String(e)} — opening the folder starts nothing, everything else landed`)
207
- }
208
- }
209
-
210
168
  /** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
211
169
  * (build/shipkit/one-way-in.md). Nothing here is a second implementation: rule SK001
212
170
  * routes the package.json scripts, rule SK013 writes the harness policy, and
@@ -87,7 +87,7 @@ export function renderCheckTable(input: CheckTableInput): string {
87
87
  body.push('')
88
88
  body.push(`Everything \`shipkit ci\` and \`shipkit check\` know how to run: ${builds} build step(s) the gate alone runs, ${rows.length - builds} the health pass reports too. ${shipped} ship with the toolkit, ${rows.length - shipped} belong to this repo.`)
89
89
  body.push('')
90
- body.push('- **runs in** — `always` means every `shipkit ci` runs it, so a red one says THIS change is broken. `release` means the quick gate leaves it out and `shipkit ci --full` plus the release path run it. `health` means only the health pass (`shipkit check`) runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit. `off` means this repo turned it off on purpose, and the reason is in `repo.config.ts when`.')
90
+ body.push('- **runs in** — `always` means every `shipkit ci` runs it, so a red one says THIS change is broken. `release` means the quick gate leaves it out and `shipkit ci --full` plus the release path run it. `health` means only the health pass (`shipkit check`) runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit. `commit` means a git hook runs it on every commit (`shipkit hooks list`). `off` means this repo turned it off on purpose, and the reason is in `repo.config.ts off`.')
91
91
  body.push('- **build** — `yes` means the gate alone runs it: it BUILDS (a compiler, a test suite, a bundler), so `shipkit check` leaves it out and reports everything else. A floor slot derives it from the package.json script behind it, and a `*.check.ts` declares it with `build: true`.')
92
92
  body.push('- **on failure** — `blocks` stops the run. `warns` prints and carries on. `UNDECLARED` is a defect, not a setting: the check never said and the repo set no default, so the runner treats it as fatal by guess.')
93
93
  body.push('- **cache** — `content` is cached while the files it reads are untouched. `world` is cached for its TTL, and a RED that ran is cached too, capped at one hour so the run after a fix sees the fix. `never` runs every time and somebody wrote down why. `UNDECLARED` runs every time and nobody has looked yet, which is the burn-down (`shipkit keys --burn-down`). `SHIPKIT_FRESH=1` busts all of it.')