rvlib-shipkit 1.9.0 → 2.0.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 (108) hide show
  1. package/CHANGELOG.md +34 -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/git-bootstrap.ts +1 -1
  48. package/src/home/attach.ts +3 -5
  49. package/src/home/insights.ts +6 -1
  50. package/src/home/pm-config.ts +2 -2
  51. package/src/home/run-daemon.ts +2 -2
  52. package/src/home/run-unit-action.ts +2 -2
  53. package/src/hooks/commit-message.ts +2 -14
  54. package/src/hooks/hook-gates.ts +38 -0
  55. package/src/hooks/leak-guard.ts +8 -3
  56. package/src/hooks/one-way-in-guard.ts +8 -2
  57. package/src/hooks/root-identity.ts +9 -6
  58. package/src/hooks/run-hooks.ts +72 -41
  59. package/src/hooks/staged.ts +49 -0
  60. package/src/hooks/tree-shrink.ts +41 -0
  61. package/src/init-repo.ts +0 -42
  62. package/src/ledger/check-table.ts +1 -1
  63. package/src/ledger/gate-checks.ts +45 -9
  64. package/src/ledger/ledger.ts +6 -1
  65. package/src/ledger/repo-view.ts +14 -4
  66. package/src/ledger/toolchain-identity.ts +4 -2
  67. package/src/mv/mv-md.ts +6 -1
  68. package/src/problems/extract.ts +27 -5
  69. package/src/problems/problem-line.ts +2 -3
  70. package/src/release/run-release.ts +13 -8
  71. package/src/repo-config.ts +44 -27
  72. package/src/scaffold/files/bunfig.toml +11 -0
  73. package/src/scaffold/files/vscode-settings.json +1 -2
  74. package/src/scaffold/jsonc-spans.ts +16 -0
  75. package/src/scaffold/project-scaffold.ts +43 -77
  76. package/src/serve/hub-app/check-view.tsx +4 -4
  77. package/src/serve/hub-app/check-words.ts +1 -0
  78. package/src/serve/hub-app/checks-svc.ts +1 -2
  79. package/src/serve/hub-app/home.html +0 -1
  80. package/src/serve/hub-app/hub-types.ts +0 -2
  81. package/src/serve/hub-app/index.html +0 -1
  82. package/src/serve/hub-app/project-list.css +1 -2
  83. package/src/serve/hub-app/tree-build.ts +3 -2
  84. package/src/serve/hub-app/tree-svc.ts +2 -2
  85. package/src/serve/hub-port.ts +3 -3
  86. package/src/serve/hub-runtime.ts +0 -3
  87. package/src/serve/serve-hub.ts +1 -16
  88. package/src/serve/tracked-watch.ts +1 -1
  89. package/src/start-repo.ts +15 -3
  90. package/src/suite/judge-suites.ts +29 -28
  91. package/src/suite/select.ts +2 -0
  92. package/src/suite/suite-cli.ts +6 -3
  93. package/src/suite/suite-cpu-preload.ts +7 -11
  94. package/src/suite/test-units.ts +7 -4
  95. package/src/ts/config.ts +12 -5
  96. package/src/ts/imports.ts +3 -1
  97. package/src/tui/build-steps-panel.ts +1 -1
  98. package/src/wiki/scan-md.ts +16 -9
  99. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  100. package/src/checks/SK014-prose-ste.check.ts +0 -78
  101. package/src/cli/cmd_Prose.ts +0 -74
  102. package/src/prose/prose-view.ts +0 -90
  103. package/src/prose/run-prose.ts +0 -187
  104. package/src/prose/ste.ts +0 -552
  105. package/src/scaffold/files/vscode-tasks.json +0 -24
  106. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  107. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  108. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -15,6 +15,7 @@ import { check } from '../check.ts'
15
15
  import { repoCheckSources } from '../core/legacy-shapes.ts'
16
16
  import { argvRunsTests, commandRunsTests, sourceRunsTests, type ScriptReader } from '../suite/runner-shapes.ts'
17
17
  import type { CheckCtx } from '../core/check-ctx.ts'
18
+ import { repoOff } from '../core/when.ts'
18
19
 
19
20
  /** the scripts of the package.json in `dir` (repo relative), then the root's */
20
21
  const scriptsIn = (c: CheckCtx, dir: string): ScriptReader => {
@@ -67,7 +68,7 @@ export const testsRunInUnits = check({
67
68
  }
68
69
  // a slot the package.json fills: its raw body, else its own script
69
70
  for (const id of OTHER_SLOTS) {
70
- if (defaults[id] === false || (defaults[id] as { cmd?: unknown } | undefined)?.cmd != null) continue
71
+ if (repoOff(c.config, id) != null || (defaults[id] as { cmd?: unknown } | undefined)?.cmd != null) continue
71
72
  const name = root(`${id}:raw`) != null ? `${id}:raw` : id
72
73
  const body = root(name)
73
74
  const how = body == null ? null : commandRunsTests(body, (n) => (n === name ? null : root(n)))
@@ -30,9 +30,12 @@ export const agentWikiLinks = check({
30
30
  for (const f of orphans) {
31
31
  const by = namingPage(c.root, f, linked)
32
32
  const where = by != null ? `${by}, which already names it (turn that mention into the link)` : 'agent/index.md or the page of its topic'
33
- c.fail(`agent/${f}: orphan, unreachable from agent/index.md: add a markdown link to it from ${where}; delete it only if the page is dead`)
33
+ c.fail(`agent/${f}: orphan, unreachable from agent/index.md: add a markdown link to it from ${where}; delete it only if the page is dead`, { at: { file: `agent/${f}` } })
34
+ }
35
+ for (const d of lint.dangling) {
36
+ const file = `agent/${d.from}`
37
+ c.fail(`${file}:${d.line}: dangling ref → ${d.token}, target not found on disk: fix the path or delete the pointer (a pointer out of this repo, into a house wiki or another repo, goes in backticks)`, { at: { file, line: d.line } })
34
38
  }
35
- for (const d of lint.dangling) c.fail(`agent/${d.from}: dangling ref → ${d.token}, target not found on disk: fix the path or delete the pointer (a pointer out of this repo, into a house wiki or another repo, goes in backticks)`)
36
39
  if (!c.failed) c.ok('agent/ has no orphans and no dangling refs')
37
40
  },
38
41
  })
@@ -5,28 +5,44 @@
5
5
  // keys resolved to (core/port-check-file.ts), so the check keeps running
6
6
  // where it ran, and the ci fix pass applies it before the gate reads the
7
7
  // files: a repo carried across by its first `shipkit ci`. The same holds for
8
- // the config: a retired `when` (only its `off:<why>` entries still load) and
9
- // `ruleExempt` become `off` entries in the managed block.
8
+ // the config: a retired `when` (only its `off:<why>` entries still load),
9
+ // `ruleExempt`, `hooks.disable` and a `defaults` slot set to false become
10
+ // `off` entries in the managed block.
10
11
  import { join } from 'path'
11
12
  import { check, type FixResult } from '../check.ts'
12
13
  import { unportedCheckFiles } from '../core/port-check-files.ts'
13
14
  import type { PortRun } from '../core/port-check-cli.ts'
14
15
  import type { ConfigPlan, ConfigRewrite } from '../core/config-rewrite.ts'
15
16
  import type { RepoConfig } from '../repo-config.ts'
17
+ import { legacyOffReason } from '../core/when.ts'
16
18
 
17
19
  /** the write half, run as its own process: it walks import closures on disk */
18
20
  const PORT_CLI = join(import.meta.dir, '../core/port-check-cli.ts')
19
21
  /** the config half, run as its own process: it loads repo.config.ts */
20
22
  const CONFIG_REWRITE = join(import.meta.dir, '../core/config-rewrite.ts')
21
23
 
22
- /** the retired config keys this repo still carries, and the `off` entries
23
- * they become. defineRepo already refused every `when` word but `off:<why>` */
24
- export function retiredOff(c: RepoConfig): { keys: string[]; off: Record<string, string> } {
24
+ /** the retired config spellings this repo still carries, and the `off`
25
+ * entries they become. defineRepo already refused every `when` word but
26
+ * `off:<why>`. `hooks.disable` and a `defaults` slot set to false never said
27
+ * why, so they are lifted with the comment above them, else with a reason
28
+ * that says none was recorded (core/when.ts legacyOffReason) */
29
+ export function retiredOff(c: RepoConfig): { keys: string[]; off: Record<string, string>; lift: NonNullable<ConfigPlan['lift']> } {
25
30
  const off: Record<string, string> = {}
26
31
  for (const [id, raw] of Object.entries(c.when ?? {})) off[id] = raw.trim().replace(/^off:?/, '').trim()
27
32
  for (const [id, why] of Object.entries(c.ruleExempt ?? {})) off[id] ??= why
28
- const keys = [...(c.when != null ? ['when'] : []), ...(c.ruleExempt != null ? ['ruleExempt'] : [])]
29
- return { keys, off }
33
+ const lift: NonNullable<ConfigPlan['lift']> = []
34
+ const disable = c.hooks?.disable ?? []
35
+ if (c.hooks?.disable != null)
36
+ lift.push({ parent: 'hooks', entry: 'disable', value: '\\[[^\\]\\[]*\\]', ids: disable, reason: Object.fromEntries(disable.map((id) => [id, legacyOffReason('hooks.disable', id)])) })
37
+ const slots = Object.entries(c.defaults ?? {}).filter(([, d]) => d === false).map(([id]) => id)
38
+ for (const id of slots) lift.push({ parent: 'defaults', entry: id, value: 'false', ids: [id], reason: { [id]: legacyOffReason('defaults', id) } })
39
+ const keys = [
40
+ ...(c.when != null ? ['when'] : []),
41
+ ...(c.ruleExempt != null ? ['ruleExempt'] : []),
42
+ ...(c.hooks?.disable != null ? ['hooks.disable'] : []),
43
+ ...slots.map((id) => `defaults.${id}: false`),
44
+ ]
45
+ return { keys, off, lift }
30
46
  }
31
47
 
32
48
  export const explicitCheckFiles = check({
@@ -76,6 +92,7 @@ export const explicitCheckFiles = check({
76
92
  ...(c.config.ruleExempt != null ? [{ key: 'ruleExempt', why: 'its exemptions are `off` now', phase: 'afterOff' as const }] : []),
77
93
  ],
78
94
  name: c.config.name,
95
+ lift: cfg.lift,
79
96
  }
80
97
  const r = await c.exec([process.execPath, CONFIG_REWRITE, c.root, JSON.stringify(plan)], { cwd: c.root, timeoutMs: 60_000 })
81
98
  try {
@@ -84,7 +101,7 @@ export const explicitCheckFiles = check({
84
101
  hand.push(...out.hand)
85
102
  if (out.restored != null) hand.push(`repo.config.ts was left as it was: ${out.restored}`)
86
103
  } catch {
87
- hand.push(`the repo.config.ts rewrite did not answer (exit ${r.code}): write off: ${JSON.stringify(cfg.off)} by hand and drop ${cfg.keys.join(', ')}`)
104
+ hand.push(`the repo.config.ts rewrite did not answer (exit ${r.code}): write off: ${JSON.stringify({ ...Object.assign({}, ...cfg.lift.map((l) => l.reason)), ...cfg.off })} by hand and drop ${cfg.keys.join(', ')}`)
88
105
  }
89
106
  }
90
107
  const summary = [did.length > 0 ? did.join('; ') : 'nothing ported', ...(hand.length > 0 ? [`${hand.length} by hand: ${hand.join(' | ')}`] : [])].join('. ')
@@ -0,0 +1,90 @@
1
+ // SK034 commit gates armed (build/shipkit/commit-gates.md): the leak guard and
2
+ // the message gate run only from git's hooks, so a repo whose hooksPath is
3
+ // unset, or points at a foreign dir that never calls shipkit, commits with
4
+ // both dark and nothing says so. `sk ci` arms an unset path; a foreign one is
5
+ // the repo's own, so the check names the line to chain into it.
6
+ import { readFileSync } from 'fs'
7
+ import { isAbsolute, join } from 'path'
8
+ import { nestedIn } from '../core/nesting.ts'
9
+ import { gitBin } from '../core/git-bin.ts'
10
+ import { fileStamp } from '../core/file-stamp.ts'
11
+ import { CONFIG_FILE } from '../core/check-pass.ts'
12
+ import { LAYOUT } from '../core/layout.ts'
13
+ import { check, type CheckEnv } from '../check.ts'
14
+ import { hookBinInvocation } from '../hooks/run-hooks.ts'
15
+
16
+ const HOOKS = [
17
+ { name: 'pre-commit', arg: '', guards: 'the leak guard' },
18
+ { name: 'commit-msg', arg: ' "$1"', guards: 'the message gate' },
19
+ ] as const
20
+
21
+ const readOrNull = (abs: string): string | null => {
22
+ try {
23
+ return readFileSync(abs, 'utf8')
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ /** a hook body that runs the shipkit gate: a live line (never a comment)
30
+ * whose failure is not swallowed by `|| true` */
31
+ export const chainsShipkit = (body: string, hook: string): boolean =>
32
+ body.split('\n').some((l) => !l.trim().startsWith('#') && new RegExp(`\\bhooks\\s+${hook}\\b`).test(l) && !/\|\|\s*(?:true|:)\b/.test(l))
33
+
34
+ /** the line to paste into a foreign hook: a clone with no install skips the
35
+ * gate with a note, rather than failing every commit */
36
+ export const guardedChain = (bin: string, hook: string, arg: string): string => {
37
+ const cli = bin.replace(/^bun\s+/, '')
38
+ return `if [ -f ${cli} ]; then ${bin} hooks ${hook}${arg} || exit 1; else echo "${hook}: rvlib-shipkit not installed (run bun install), shipkit gate skipped" >&2; fi`
39
+ }
40
+
41
+ const hooksDir = (root: string): string => {
42
+ const p = Bun.spawnSync([gitBin(), 'config', 'core.hooksPath'], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
43
+ return p.exitCode === 0 ? p.stdout.toString().trim() : ''
44
+ }
45
+
46
+ /** the hooksPath and the two hook files it points at. A commit moves none */
47
+ export function gatesKey(env: CheckEnv): string {
48
+ const nested = nestedIn(env.root)
49
+ if (nested != null) return `nested in ${nested}`
50
+ const dir = hooksDir(env.root)
51
+ if (dir === '') return 'hooks='
52
+ const at = (h: string): string => fileStamp(isAbsolute(dir) ? join(dir, h) : join(env.root, dir, h))
53
+ return `hooks=${dir}|${HOOKS.map((h) => `${h.name}=${at(h.name)}`).join('|')}`
54
+ }
55
+
56
+ export const commitGatesArmed = check({
57
+ id: 'SK034',
58
+ blocking: true,
59
+ title: 'commit gates armed',
60
+ description: 'git runs the shipkit pre-commit and commit-msg gates on every commit',
61
+ owner: 'build/shipkit/commit-gates.md',
62
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
63
+ // git config and hook files outside any tree id, so they are the key
64
+ cache: { by: 'files', inputs: [CONFIG_FILE], key: gatesKey },
65
+ fix: 'none',
66
+ applies: 'always',
67
+ run: async (c) => {
68
+ const nested = nestedIn(c.root)
69
+ if (nested != null) return void c.lines.push({ kind: 'skip', text: `commit gates not checked, nested in ${nested}: the parent repo owns them` })
70
+ const top = await c.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: c.root })
71
+ if (top.code !== 0) return void c.lines.push({ kind: 'skip', text: 'not a git repo: no commit to guard yet' })
72
+ const hp = await c.exec(['git', 'config', 'core.hooksPath'], { cwd: c.root })
73
+ const dir = hp.code === 0 ? hp.out.trim() : ''
74
+ if (dir === '' && process.env['CI'] != null && process.env['CI'] !== '')
75
+ return void c.lines.push({ kind: 'skip', text: 'commit gates not armed in this CI checkout: a CI run makes no commit, and the fix pass arms hooks on a machine only' })
76
+ if (dir === '') return void c.fail('the commit gates are not armed, so a commit runs no leak guard and no message gate: run `shipkit hooks install` (`shipkit ci` arms them too)')
77
+ const own = dir === LAYOUT.hooks
78
+ const bin = hookBinInvocation(c.root)
79
+ for (const h of HOOKS) {
80
+ const body = isAbsolute(dir) ? readOrNull(join(dir, h.name)) : c.read(`${dir}/${h.name}`)
81
+ if (body == null)
82
+ c.fail(`core.hooksPath=${dir} has no ${h.name}, so ${h.guards} never runs: ${own ? 'run `shipkit hooks install`' : `add one that runs \`${guardedChain(bin, h.name, h.arg)}\``}`)
83
+ else if (!chainsShipkit(body, h.name))
84
+ c.fail(`core.hooksPath=${dir}: its ${h.name} does not run the shipkit gate, so ${h.guards} never runs: add \`${guardedChain(bin, h.name, h.arg)}\` above its last line`)
85
+ }
86
+ if (!c.failed) c.ok(`git runs the shipkit commit gates (${own ? dir : `${dir}, chained to shipkit`})`)
87
+ },
88
+ })
89
+
90
+ export default commitGatesArmed
package/src/cli/cmd_Ci.ts CHANGED
@@ -14,14 +14,14 @@ export class CiCommand extends Command {
14
14
  static override usage = Command.Usage({
15
15
  category: 'Gate',
16
16
  description: 'THE gate: repairs what a machine can, then runs every check. Run this, not the parts',
17
- details: 'THE command. Repairs what a machine can (formatter, imports, repo shape), then runs every check the gate asks for, through one DAG. A check that declares blocking:false warns instead of failing. Run this instead of anything else.',
17
+ details: 'THE command. Repairs what a machine can (imports, repo shape), then runs every check the gate asks for, through one DAG. A check that declares blocking:false warns instead of failing. Run this instead of anything else.',
18
18
  examples: [
19
19
  ['full gate, mechanical fixes applied', 'sk ci'],
20
20
  ['gate without the repair pass (checks can still write)', 'sk ci --no-fix'],
21
21
  ['leave repo config files alone', 'sk ci --no-fix-rules'],
22
22
  ['stop at the first failure', 'sk ci --bail'],
23
23
  ['one check + its dep closure, the tree left alone', 'sk ci --only typecheck'],
24
- ['one check, repairing the mechanical slots first', 'sk ci --only typecheck --fix'],
24
+ ['one check, its own repair first', 'sk ci --only SK004 --fix'],
25
25
  ['every check declaring one group', 'sk ci --only wiki'],
26
26
  ['run everything, take nothing from the cache', 'sk ci --fresh'],
27
27
  ['include an opt in check', 'sk ci --with up:itch'],
@@ -32,7 +32,7 @@ export class CiCommand extends Command {
32
32
 
33
33
  bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
34
34
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
35
- fix = Option.Boolean('--fix', { description: 'repair the mechanical slots before gating. Default on for a whole gate and off for a --only run, which leaves the tree alone unless --fix is named. --no-fix skips the repair pass, the checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
35
+ fix = Option.Boolean('--fix', { description: 'repair before gating. Default on for a whole gate and off for a --only run, which leaves the tree alone unless --fix is named, and then repairs only what the named checks own. Nothing ever formats. --no-fix skips the repair pass, the checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
36
36
  fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural autofixes of the built-in checks too. Default on. --no-fix-rules leaves repo config alone' })
37
37
  frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining checks: SK005 never touches package.json, and generated paths are never untracked. STATUS.md is still refreshed on disk, but it is gitignored, so a run leaves the tree clean' })
38
38
  skip = Option.Array('--skip', [], { description: 'skip a check group (repeatable)' })
@@ -71,6 +71,20 @@ export function renderMarkdown(rows: CommandRow[]): string {
71
71
  return out.join('\n').trim()
72
72
  }
73
73
 
74
+ /** the eager form: one line per command, its positional arguments and its
75
+ * description. Flags and examples are `sk <command> --help`, so a page every
76
+ * session loads costs a line per command, not a screen */
77
+ export function renderCompact(rows: CommandRow[]): string {
78
+ const out: string[] = []
79
+ let cat = ''
80
+ for (const r of rows) {
81
+ if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}\n`) }
82
+ const sig = r.usage.replace(/^\$0/, 'sk').replace(/\s+\[(?:-|<\w+>)[^\]]*\]/g, '').trim() || `sk ${r.path}`
83
+ out.push(`- \`${sig}\` — ${r.description}`)
84
+ }
85
+ return out.join('\n').trim()
86
+ }
87
+
74
88
  export const BEGIN = '<!-- shipkit:commands -->'
75
89
  export const END = '<!-- /shipkit:commands -->'
76
90
 
@@ -89,14 +103,16 @@ export class CommandsCommand extends Command {
89
103
  examples: [
90
104
  ['markdown index', 'sk commands'],
91
105
  ['machine-readable', 'sk commands --json'],
92
- ['write into a docs page between the markers', 'sk commands --out wiki/build/shipkit-cli.md'],
93
- ['fail if that page is stale (lint mode)', 'sk commands --out wiki/build/shipkit-cli.md --check'],
106
+ ['write into a docs page between the markers', 'sk commands --out docs/commands.md'],
107
+ ['one line per command, for a page every session loads', 'sk commands --compact --out wiki/build/shipkit-cli.md'],
108
+ ['fail if that page is stale (lint mode)', 'sk commands --compact --out wiki/build/shipkit-cli.md --check'],
94
109
  ],
95
110
  })
96
111
 
97
112
  json = Option.Boolean('--json', false, { description: 'machine-readable [{path, category, description}]' })
98
113
  out = Option.String('--out', { description: 'host file carrying the shipkit:commands markers. The block between them is rewritten' })
99
114
  check = Option.Boolean('--check', false, { description: 'with --out: exit 1 if the block is stale, write nothing' })
115
+ compact = Option.Boolean('--compact', false, { description: 'one line per command: no flags, no examples (they are `sk <command> --help`)' })
100
116
 
101
117
  async execute(): Promise<number> {
102
118
  const rows = commandRows(
@@ -104,18 +120,18 @@ export class CommandsCommand extends Command {
104
120
  (this.cli.definitions as (o?: { detailed?: boolean }) => Definition[])({ detailed: true }),
105
121
  )
106
122
  if (this.out == null) {
107
- this.context.stdout.write(this.json ? `${JSON.stringify(rows)}\n` : `${renderMarkdown(rows)}\n`)
123
+ this.context.stdout.write(this.json ? `${JSON.stringify(rows)}\n` : `${(this.compact ? renderCompact : renderMarkdown)(rows)}\n`)
108
124
  return 0
109
125
  }
110
126
  const { existsSync, readFileSync, writeFileSync } = await import('fs')
111
127
  if (!existsSync(this.out)) { this.context.stdout.write(`FAIL: ${this.out} does not exist\n`); return 1 }
112
128
  const host = readFileSync(this.out, 'utf8')
113
- const next = spliceBlock(host, renderMarkdown(rows))
129
+ const next = spliceBlock(host, (this.compact ? renderCompact : renderMarkdown)(rows))
114
130
  if (next == null) { this.context.stdout.write(`FAIL: ${this.out} has no ${BEGIN} / ${END} markers\n`); return 1 }
115
131
  if (next === host) { this.context.stdout.write(`ok: ${this.out} command index current (${rows.length} commands)\n`); return 0 }
116
132
  if (this.check) {
117
133
  this.context.stdout.write(`FAIL: ${this.out} command index is STALE — the binary registers commands the page does not list\n`)
118
- this.context.stdout.write(` fix: shipkit commands --out ${this.out}\n`)
134
+ this.context.stdout.write(` fix: shipkit commands${this.compact ? ' --compact' : ''} --out ${this.out}\n`)
119
135
  return 1
120
136
  }
121
137
  writeFileSync(this.out, next)
@@ -4,9 +4,13 @@ import { Command, Option } from 'clipanion'
4
4
  import { hookBinInvocation, installHooks, listGates, preCommitChecks, runCommitMsg, runPreCommit, type GateOutcome } from '../hooks/run-hooks.ts'
5
5
  import { loadRepoConfig } from '../repo-config.ts'
6
6
 
7
+ // a gate that passed prints only what still needs a reader (a WARN, a waived
8
+ // hit), so a clean commit costs one line, not one per gate
7
9
  const report = (out: GateOutcome[], write: (s: string) => void): number => {
8
- for (const g of out) for (const l of g.lines) write(`${l}\n`)
9
- return out.every((g) => g.ok) ? 0 : 1
10
+ for (const g of out) for (const l of g.lines) if (!g.ok || !l.startsWith('ok: ')) write(`${l}\n`)
11
+ const ok = out.every((g) => g.ok)
12
+ if (ok) write(`ok: ${out.length} commit gate(s) clean\n`)
13
+ return ok ? 0 : 1
10
14
  }
11
15
 
12
16
  export class HooksInstallCommand extends Command {
@@ -8,7 +8,7 @@ export class InitCommand extends Command {
8
8
 
9
9
  static override usage = Command.Usage({
10
10
  category: 'Setup',
11
- description: 'wire the current directory as a shipkit repo, like git init',
11
+ description: 'wire this dir as a shipkit repo, like git init: a private github repo and a push unless --no-github',
12
12
  details: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. scaffolds repo.config.ts + ci/check/contracts package.json scripts + the create-only .vscode/ that starts the watchers and the hub on folder open, prints how the floor resolved, and runs the bootstrap chain around it: git init, then first commit + PRIVATE github repo + push once the scaffold is in. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
13
13
  examples: [
14
14
  ['wire the repo you are standing in', 'sk init'],
@@ -1,5 +1,4 @@
1
- // shipkit serve — the ONE hub server per repo (one vscode folder-open task,
2
- // clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
1
+ // shipkit serve — the ONE hub server per repo (clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
3
2
  import { Command, Option } from 'clipanion'
4
3
 
5
4
  export class ServeCommand extends Command {
@@ -9,7 +9,7 @@ export class StartCommand extends Command {
9
9
 
10
10
  static override usage = Command.Usage({
11
11
  category: 'Setup',
12
- description: 'create or enter ~/dev/<name> from anywhere, ready to work in',
12
+ description: 'create or enter ~/dev/<name>, ready to work in: a private github repo and a push unless --no-github',
13
13
  details: 'create (or enter) ~/dev/<name> from ANY cwd, READY to work in. On any dir with no commit history it writes the day-one shape (package.json + src/index.ts + lint/fmt/typecheck, tsconfig, .vscode tasks + settings, the verify skill, an agent/ wiki, .shipkit/reflections), installs the toolchain, wires shipkit (repo.config.ts via init), and runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. Loose files ride in that first commit, and only a repo with real history is left as it is. Writes are create-only for files, additive for package.json scripts and .gitignore. CLAUDE.md is the two-line house template on its own (HOUSE_INCLUDE in src/start-repo.ts: the base wiki include, then `@./agent/index.md`, and --include overrides it), applied only on a machine where the house index exists, and the agent route lands last whenever the repo has agent/index.md. The house lines also opt the repo into the .claude/ policy files (a pre-approved shell allowlist + the attribution silencer), so a start with no house widens no agent permissions. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
14
14
  examples: [
15
15
  ['create or enter ~/dev/pong: dir, package.json, shipkit wiring, git, first commit, private github repo, push, the two-line CLAUDE.md', 'sk start pong'],
package/src/cli.ts CHANGED
@@ -26,7 +26,6 @@ import { KeysCommand } from './cli/cmd_Keys.ts'
26
26
  import { MvCommand } from './cli/cmd_Mv.ts'
27
27
  import { NeedsCommand } from './cli/cmd_Needs.ts'
28
28
  import { PbCollectionCommand } from './cli/cmd_Pb.ts'
29
- import { ProseCommand } from './cli/cmd_Prose.ts'
30
29
  import { ReflectCommand } from './cli/cmd_Reflect.ts'
31
30
  import { ReleaseCommand } from './cli/cmd_Release.ts'
32
31
  import { ReleasesCommand } from './cli/cmd_Releases.ts'
@@ -91,7 +90,6 @@ export function buildCli(): Cli {
91
90
  cli.register(NeedsCommand)
92
91
  cli.register(FleetCommand)
93
92
  cli.register(PbCollectionCommand)
94
- cli.register(ProseCommand)
95
93
  cli.register(SmokeCommand)
96
94
  cli.register(ReflectCommand)
97
95
  cli.register(KeysCommand)
@@ -125,8 +123,8 @@ if (import.meta.main) {
125
123
  const argv = aliasArgs(process.argv.slice(2))
126
124
  const { handoff } = await import('./home/handoff.ts')
127
125
  handoff(argv)
128
- const { useOneLib } = await import('./core/one-lib.ts')
129
- useOneLib()
126
+ const { installOneLib } = await import('./core/one-lib.ts')
127
+ installOneLib()
130
128
  const { autostart } = await import('./home/autostart.ts')
131
129
  await autostart(argv)
132
130
  void buildCli().runExit(argv)
@@ -6,16 +6,14 @@
6
6
  import { availableParallelism, loadavg } from 'os'
7
7
  import type { MachineLoad } from '../suite/judge-suites.ts'
8
8
 
9
- /** an in process body, a rule or a page check. Read off a run record of 64
10
- * such checks: the median rule answers in under 50ms, 55 of them stay under
11
- * 2s at their 90th percentile, and the few past it shell out to a tool */
12
- export const IN_PROCESS_BUDGET_MS = 2_000
9
+ /** an in process body, a rule or a page check: the house bar is under 1 s
10
+ * (identity/how-we-work.md, Velocity). The median rule answers in under 50ms */
11
+ export const IN_PROCESS_BUDGET_MS = 1_000
13
12
 
14
13
  /** a subprocess (a build step, a test suite, a `cmd` check) or a world probe
15
- * (a `ttl` check, or a `never:` one, whose answer lives outside the repo).
16
- * Read off the same record: every typecheck stays under 6s at its 90th
17
- * percentile, and the quick test suites under 6s but one */
18
- export const SUBPROCESS_BUDGET_MS = 10_000
14
+ * (a `ttl` check, or a `never:` one, whose answer lives outside the repo):
15
+ * the house ceiling is 5 s, and one over it is a defect to fix */
16
+ export const SUBPROCESS_BUDGET_MS = 5_000
19
17
 
20
18
  /** what a check declares about itself that picks its default. `world`: it
21
19
  * declares `ttl` or `never`, so it reads the world rather than the repo */
@@ -0,0 +1,36 @@
1
+ // bun's own linter as the default lint slot, and bun's formatter as the
2
+ // manual `bun fmt` (never a gate step). A repo opts in by declaring the tool's
3
+ // section in bunfig.toml: the section is where its config lives.
4
+ //
5
+ // bun before 1.4.4 has neither command, and there `bun lint` falls through to
6
+ // the package.json `lint` script. A script that calls `bun lint` then calls
7
+ // itself forever, so an old bun gets a loud refusal instead of a spawn. The
8
+ // lint slot runs through core/bun-tool-cli.ts, which keeps a same-named script out.
9
+ import { fileURLToPath } from 'url'
10
+
11
+ export type BunTool = 'lint' | 'fmt'
12
+
13
+ export const BUN_TOOLS_SINCE = '1.4.4'
14
+
15
+ /** does the bun running shipkit ship `bun lint` and `bun fmt` */
16
+ export const bunHasTools = (version: string = Bun.version): boolean => Bun.semver.order(version.replace(/-.*$/, ''), BUN_TOOLS_SINCE) >= 0
17
+
18
+ const header = (tool: BunTool): RegExp => new RegExp(`^\\s*\\[${tool}(?:\\.[^\\]]+)?\\]\\s*(?:#.*)?$`)
19
+
20
+ /** does this bunfig.toml text declare `[lint]` / `[fmt]` (or a subtable of it) */
21
+ export function bunfigDeclares(bunfig: string | null, tool: BunTool): boolean {
22
+ return bunfig != null && bunfig.split('\n').some((l) => header(tool).test(l))
23
+ }
24
+
25
+ /** why this bun cannot run the tool, or null when it can */
26
+ export function bunToolRefusal(tool: BunTool, version: string = Bun.version): string | null {
27
+ return bunHasTools(version) ? null : `bunfig.toml declares [${tool}] and bun ${version} has no \`bun ${tool}\` (it ships in ${BUN_TOOLS_SINCE}): run \`bun upgrade --canary\``
28
+ }
29
+
30
+ /** the argv the lint slot runs: the built-in, through the runner that keeps a
31
+ * same-named package.json script out of its way. The repo's vendored dirs
32
+ * are never walked */
33
+ export function bunLintCmd(vendored: readonly string[] = []): string[] {
34
+ const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/core/bun-tool-cli.ts'))
35
+ return [process.execPath, cli, 'lint', '--format', 'unix', ...vendored.map((d) => `--ignore-pattern=${d}/**`)]
36
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bun
2
+ // runs `bun lint` as the BUILT-IN, whatever package.json holds.
3
+ // bun runs a script of the same name in its place, flags and all, unless the
4
+ // call says it is that script: `npm_lifecycle_event` names the tool, and
5
+ // `npm_package_json` is absent or names this manifest by its resolved path.
6
+ // A run started under another package's `bun run` inherits that package's
7
+ // path, so it is dropped here (measured, bun 1.4.4).
8
+ import { bunToolRefusal } from './bun-builtins.ts'
9
+
10
+ const [tool, ...args] = process.argv.slice(2)
11
+ if (tool !== 'lint') {
12
+ console.error(`bun-tool-cli: expected lint, got ${tool ?? 'nothing'}`)
13
+ process.exit(2)
14
+ }
15
+ const refused = bunToolRefusal(tool)
16
+ if (refused != null) {
17
+ console.error(refused)
18
+ process.exit(1)
19
+ }
20
+ const env: Record<string, string | undefined> = { ...process.env, npm_lifecycle_event: tool }
21
+ delete env.npm_package_json
22
+ const p = Bun.spawnSync([process.execPath, tool, ...args], { stdio: ['inherit', 'inherit', 'inherit'], env })
23
+ process.exit(p.exitCode ?? 1)
@@ -36,10 +36,12 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
36
36
  // used to hold moves to `<name>:raw`, which is what shipkit itself runs and
37
37
  // what an agent uses to iterate on one repro file.
38
38
  //
39
- // only these three. `imports:check` and `cycles` are shipkit's own vocabulary
40
- // (nobody reaches for them by reflex) and `fmt` is the slot shipkit's fix pass
41
- // invokes, so routing it would make the fixer call itself.
42
- export const ROUTED_SCRIPTS = ['test', 'lint', 'typecheck'] as const
39
+ // only these two. `imports:check` and `cycles` are shipkit's own vocabulary
40
+ // (nobody reaches for them by reflex), `fmt` is a manual tool no gate runs,
41
+ // and `lint` is
42
+ // `bun lint` itself: a `lint` script would shadow the built-in, which lints a
43
+ // whole repo in well under a second, with nothing for a cache to save.
44
+ export const ROUTED_SCRIPTS = ['test', 'typecheck'] as const
43
45
 
44
46
  export const RAW_SUFFIX = ':raw'
45
47
 
@@ -1,7 +1,8 @@
1
1
  // CheckCtx — the ONE parameter a check body receives, and its only door to the
2
2
  // repo. Four parts:
3
3
  //
4
- // report c.ok / note / warn / fail / unreachable, the wire lines (wire.ts)
4
+ // report c.ok / note / warn / fail / unreachable, the wire lines (wire.ts),
5
+ // a finding with its site, which an in-file directive can waive
5
6
  // where c.root, c.config, c.frozen
6
7
  // reads c.read / json / exists / isLink / list / entries, each recorded with what it saw, and
7
8
  // c.use(scan) for a scan every check of the pass shares (scan.ts)
@@ -27,7 +28,12 @@ import { normalizeInput, WHOLE_TREE } from '../ledger/git-scan.ts'
27
28
  import { shipkitRoot } from './git-root.ts'
28
29
  import type { RepoConfig } from '../repo-config.ts'
29
30
  import type { Scan } from '../scan.ts'
30
- import type { Line } from './wire.ts'
31
+ import { siteText, type Line, type Site } from './wire.ts'
32
+ import { DIRECTIVE_MARK, parseDirectives, reaches, spell, type Directive } from './waivers.ts'
33
+
34
+ /** what `c.warn` and `c.fail` take after the message: the fix pointer alone,
35
+ * or the pointer and the site the finding sits at */
36
+ export type FindingMore = string | { fix?: string; at?: Site }
31
37
 
32
38
  // thrown by t.fetch on 429: no answer, so the body runner (check.ts
33
39
  // runCheckBody) reports it as unreachable, never as a FAIL. It knows it by
@@ -56,6 +62,16 @@ export class CheckCtx {
56
62
  /** why the reads cannot describe this answer (it reached the world), null
57
63
  * while they can */
58
64
  untracked: string | null = null
65
+ /** the check this context reports for, set by the body runner. A
66
+ * directive waives a site only when it names this id */
67
+ checkId: string | null = null
68
+ /** the text of every repo file read here that carries a directive mark,
69
+ * by path: what a waiver and the unused directive scan parse */
70
+ readonly marked = new Map<string, string>()
71
+ private readonly parsed = new Map<string, Directive[]>()
72
+ private readonly siteFiles = new Set<string>()
73
+ private readonly waived: { text: string; at: Site; by: string; reason: string }[] = []
74
+ private readonly usedDirectives = new Set<string>()
59
75
 
60
76
  constructor(
61
77
  /** the runner pass this body runs in. Absent for a context that only
@@ -73,19 +89,94 @@ export class CheckCtx {
73
89
  note(msg: string): void { this.lines.push({ kind: 'note', text: msg }) }
74
90
  /** a WARN that came back from something we could not reach — no answer, so
75
91
  * nothing to remember */
76
- unreachable(msg: string, fix?: string): void {
92
+ unreachable(msg: string, more?: FindingMore): void {
77
93
  this.unmeasured = true
78
- this.warn(msg, fix)
94
+ this.warn(msg, more)
79
95
  }
80
- warn(msg: string, fix?: string): void {
96
+ /** a defect that does not stop you. `more` is the fix pointer, or
97
+ * `{ fix, at }` with the site, which a directive in that file can waive */
98
+ warn(msg: string, more?: FindingMore): void {
99
+ const f = this.finding(msg, more)
100
+ if (f == null) return
81
101
  this.warned = true
82
- this.lines.push({ kind: 'warn', text: msg })
83
- if (fix != null) this.lines.push({ kind: 'fix-warn', text: fix })
102
+ this.lines.push({ kind: 'warn', text: f.text, ...(f.at == null ? {} : { at: f.at }) })
103
+ if (f.fix != null) this.lines.push({ kind: 'fix-warn', text: f.fix })
84
104
  }
85
- fail(msg: string, fix?: string): void {
105
+ /** broken. `more` is the fix pointer, or `{ fix, at }` with the site,
106
+ * which a directive in that file can waive */
107
+ fail(msg: string, more?: FindingMore): void {
108
+ const f = this.finding(msg, more)
109
+ if (f == null) return
86
110
  this.failed = true
87
- this.lines.push({ kind: 'fail', text: msg })
88
- if (fix != null) this.lines.push({ kind: 'fix-fail', text: fix })
111
+ this.lines.push({ kind: 'fail', text: f.text, ...(f.at == null ? {} : { at: f.at }) })
112
+ if (f.fix != null) this.lines.push({ kind: 'fix-fail', text: f.fix })
113
+ }
114
+
115
+ // ── waivers (core/waivers.ts owns the grammar) ────────────────────────
116
+ /** the finding as it prints, or null when a directive waived its site.
117
+ * A message that does not name its file gets the site in front */
118
+ private finding(msg: string, more: FindingMore | undefined): { text: string; fix?: string; at?: Site } | null {
119
+ const { fix, at } = typeof more === 'object' ? more : { fix: more, at: undefined }
120
+ if (at == null) return { text: msg, fix }
121
+ const text = msg.includes(at.file) ? msg : `${siteText(at)}: ${msg}`
122
+ const by = this.waiverFor(at)
123
+ if (by == null) return { text, fix, at }
124
+ this.waived.push({ text, at, by: `${at.file}:${by.line}`, reason: by.reason ?? '' })
125
+ return null
126
+ }
127
+ /** the directive that waives this site for this check. The file is read
128
+ * through `c`, so it joins the answer's key: a directive added or
129
+ * removed afterwards asks the question again */
130
+ private waiverFor(at: Site): Directive | null {
131
+ if (this.checkId == null || this.pass == null || isAbsolute(at.file)) return null
132
+ let rel: string
133
+ try { rel = this.rel(at.file) } catch { return null }
134
+ this.siteFiles.add(rel)
135
+ this.read(rel)
136
+ for (const d of this.directivesIn(rel)) {
137
+ if (d.reason == null || !d.ids.includes(this.checkId) || !reaches(d, at.line)) continue
138
+ this.usedDirectives.add(`${rel}:${d.line}`)
139
+ return d
140
+ }
141
+ return null
142
+ }
143
+ private directivesIn(file: string): Directive[] {
144
+ const text = this.marked.get(file)
145
+ if (text == null) return []
146
+ let ds = this.parsed.get(file)
147
+ if (ds == null) this.parsed.set(file, (ds = this.pass?.directives(file, text) ?? parseDirectives(file, text)))
148
+ return ds
149
+ }
150
+ /** after the body, once: count the waived sites, and WARN on every
151
+ * directive naming this check, in a file it read, that waived nothing
152
+ * or gave no reason. A directive for a retired id, or in a file no
153
+ * check reads any more, is nobody's to report */
154
+ settleWaivers(): void {
155
+ const id = this.checkId
156
+ if (id == null) return
157
+ const loud = (at: Site, text: string, fix: string): void => {
158
+ this.warned = true
159
+ this.lines.push({ kind: 'warn', text: `${siteText(at)}: ${text}`, at }, { kind: 'fix-warn', text: fix })
160
+ }
161
+ // a site, not a finding: one line can hold several findings
162
+ const n = new Set(this.waived.map((w) => siteText(w.at))).size
163
+ if (n > 0) this.lines.push({ kind: 'skip', text: `${n === 1 ? '1 site waived in its file' : `${n} sites waived in their files`} (--verbose lists them)`, waiver: 'count' })
164
+ for (const w of this.waived) this.lines.push({ kind: 'skip', text: `waived by ${w.by} (${w.reason}): ${w.text}`, at: w.at, waiver: 'site' })
165
+ for (const file of this.marked.keys())
166
+ for (const d of this.directivesIn(file)) {
167
+ if (!d.ids.includes(id)) continue
168
+ const at = { file, line: d.line }
169
+ if (d.reason == null) loud(at, `\`${spell(d, id)}\` gives no reason after \` -- \`, so it waives nothing`, `say why the site is fine: \`${spell(d, id)} -- <reason>\``)
170
+ else if (!this.usedDirectives.has(`${file}:${d.line}`))
171
+ loud(at, `\`${spell(d, id)}\` waives nothing ${d.scope === 'file' ? 'in this file' : `on line ${d.scope === 'line' ? d.line : d.line + 1}`}`, 'delete the directive, or move it onto the site it was written for')
172
+ }
173
+ }
174
+ /** every file whose directives this answer depends on: the files of its
175
+ * findings, and the files carrying a directive that names it */
176
+ waiverFiles(): string[] {
177
+ const id = this.checkId
178
+ const named = id == null ? [] : [...this.marked.keys()].filter((f) => this.directivesIn(f).some((d) => d.ids.includes(id)))
179
+ return [...new Set([...this.siteFiles, ...named])].sort()
89
180
  }
90
181
 
91
182
  // ── where ─────────────────────────────────────────────────────────────
@@ -99,6 +190,7 @@ export class CheckCtx {
99
190
  * it and none that did not */
100
191
  get config(): RepoConfig {
101
192
  const p = this.repo('config')
193
+ if (!p.recordsConfig) return p.config
102
194
  const deps = p.configReads()
103
195
  for (const [f, oid] of deps.files) this.reads.file(f, oid)
104
196
  if (deps.untracked != null && this.untracked == null) {
@@ -150,6 +242,7 @@ export class CheckCtx {
150
242
  return null
151
243
  }
152
244
  this.reads.file(p, blobOid(bytes))
245
+ if (!isAbsolute(p) && bytes.includes(DIRECTIVE_MARK)) this.marked.set(p, bytes.toString('utf8'))
153
246
  return bytes
154
247
  }
155
248
  /** a repo-relative json file, parsed. null when absent, and a parse error
@@ -206,6 +299,7 @@ export class CheckCtx {
206
299
  const s = pass.scan(scan, () => new CheckCtx(pass))
207
300
  const value = await s.value
208
301
  this.reads.merge(s.ctx.reads)
302
+ for (const [f, text] of s.ctx.marked) this.marked.set(f, text)
209
303
  if (s.ctx.untracked != null) this.untrack(s.ctx.untracked)
210
304
  return value
211
305
  }