rvlib-shipkit 1.4.0 → 1.6.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 (199) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/assets/gen/comfyts-gen.ts +12 -8
  5. package/src/assets/server/studio-server.ts +14 -2
  6. package/src/check.ts +73 -103
  7. package/src/checks/SK001-package-json-shape.check.ts +11 -6
  8. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  9. package/src/checks/SK003-claude-md-shape.check.ts +7 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  11. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  12. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  13. package/src/checks/SK007-github-visibility.check.ts +4 -2
  14. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  15. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  16. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  17. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  18. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  19. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  20. package/src/checks/SK014-prose-ste.check.ts +4 -0
  21. package/src/checks/SK015-nested-repo.check.ts +4 -2
  22. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  23. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  24. package/src/checks/SK018-test-files-run.check.ts +6 -2
  25. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  26. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  27. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  28. package/src/checks/SK024-identity-colors.check.ts +5 -3
  29. package/src/checks/SK025-release-drift.check.ts +4 -5
  30. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  31. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  32. package/src/checks/SK028-readme.check.ts +5 -1
  33. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  34. package/src/checks/SK030-cache-audit.check.ts +34 -0
  35. package/src/checks/SK031-trace-audit.check.ts +39 -0
  36. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  37. package/src/cli/cmd_Asks.ts +5 -5
  38. package/src/cli/cmd_Assets.ts +17 -13
  39. package/src/cli/cmd_Bump.ts +12 -11
  40. package/src/cli/cmd_CacheAudit.ts +43 -0
  41. package/src/cli/cmd_Check.ts +8 -7
  42. package/src/cli/cmd_Checks.ts +7 -6
  43. package/src/cli/cmd_Ci.ts +12 -11
  44. package/src/cli/cmd_Commands.ts +16 -13
  45. package/src/cli/cmd_ConfigKeys.ts +7 -6
  46. package/src/cli/cmd_Contracts.ts +3 -3
  47. package/src/cli/cmd_CrossRepoActivity.ts +8 -6
  48. package/src/cli/cmd_Fleet.ts +9 -7
  49. package/src/cli/cmd_GlobalProcesses.ts +120 -7
  50. package/src/cli/cmd_Home.ts +26 -19
  51. package/src/cli/cmd_Hooks.ts +6 -5
  52. package/src/cli/cmd_Init.ts +5 -4
  53. package/src/cli/cmd_Keys.ts +20 -8
  54. package/src/cli/cmd_Mv.ts +7 -6
  55. package/src/cli/cmd_Needs.ts +4 -3
  56. package/src/cli/cmd_Overview.ts +5 -4
  57. package/src/cli/cmd_Pb.ts +2 -2
  58. package/src/cli/cmd_Planned.ts +6 -5
  59. package/src/cli/cmd_Prose.ts +5 -5
  60. package/src/cli/cmd_Reflect.ts +7 -7
  61. package/src/cli/cmd_Release.ts +6 -5
  62. package/src/cli/cmd_Releases.ts +5 -4
  63. package/src/cli/cmd_Serve.ts +6 -4
  64. package/src/cli/cmd_Smoke.ts +5 -4
  65. package/src/cli/cmd_Start.ts +7 -6
  66. package/src/cli/cmd_Status.ts +4 -3
  67. package/src/cli/cmd_TraceAudit.ts +62 -0
  68. package/src/cli/cmd_Tui.ts +3 -2
  69. package/src/cli/cmd_When.ts +71 -0
  70. package/src/cli/cmd_Wiki.ts +4 -4
  71. package/src/cli.ts +11 -2
  72. package/src/collections/checks.ts +2 -0
  73. package/src/collections/collection-types.ts +12 -4
  74. package/src/collections/providers.ts +5 -3
  75. package/src/core/app-icon.ts +154 -0
  76. package/src/core/audit-report.ts +21 -0
  77. package/src/core/cache-audit.ts +72 -0
  78. package/src/core/canonical-scripts.ts +2 -2
  79. package/src/core/check-ctx.ts +14 -5
  80. package/src/core/check-runner.ts +43 -23
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +23 -2
  83. package/src/core/floor-resolve.ts +5 -7
  84. package/src/core/git-root.ts +13 -0
  85. package/src/core/ignored-block.ts +94 -0
  86. package/src/core/ignored-inputs.ts +47 -0
  87. package/src/core/jobs.ts +9 -2
  88. package/src/core/legacy-shapes.ts +39 -10
  89. package/src/core/one-lib.ts +59 -4
  90. package/src/core/one-way-in-policy.ts +2 -2
  91. package/src/core/outcome-words.ts +18 -2
  92. package/src/core/port-check-cli.ts +52 -0
  93. package/src/core/port-check-file.ts +295 -0
  94. package/src/core/port-check-files.ts +31 -0
  95. package/src/core/read-set.ts +97 -9
  96. package/src/core/retention.ts +0 -14
  97. package/src/core/run-check.ts +12 -9
  98. package/src/core/run-ci.ts +14 -5
  99. package/src/core/run-fix.ts +30 -23
  100. package/src/core/run-fixers.ts +48 -5
  101. package/src/core/runs-matrix.ts +179 -0
  102. package/src/core/runs.ts +242 -0
  103. package/src/core/trace-audit.ts +120 -0
  104. package/src/core/update-policy.ts +20 -0
  105. package/src/core/when-block.ts +29 -30
  106. package/src/core/when.ts +41 -103
  107. package/src/core/wire.ts +10 -14
  108. package/src/fleet/scan-fleet.ts +20 -4
  109. package/src/fleet/update-fleet.ts +6 -6
  110. package/src/home/gui-app.ts +24 -27
  111. package/src/home/home-launchd.ts +20 -4
  112. package/src/home/home-routes.ts +12 -1
  113. package/src/home/home-start.ts +11 -5
  114. package/src/home/hubs.ts +5 -5
  115. package/src/home/open-app.ts +2 -2
  116. package/src/home/pm.ts +8 -0
  117. package/src/home/run-pm.ts +1 -0
  118. package/src/home/serve-web.ts +2 -2
  119. package/src/home/supervise.ts +24 -1
  120. package/src/home/via-hub.ts +14 -0
  121. package/src/hooks/run-hooks.ts +48 -4
  122. package/src/init-repo.ts +6 -6
  123. package/src/ledger/db.ts +2 -13
  124. package/src/ledger/gate-checks.ts +133 -61
  125. package/src/ledger/git-key.ts +153 -31
  126. package/src/ledger/git-scan.ts +5 -2
  127. package/src/ledger/ledger.ts +136 -21
  128. package/src/ledger/store.ts +14 -0
  129. package/src/ledger/toolchain-identity.ts +248 -45
  130. package/src/plan/repo-source.ts +18 -39
  131. package/src/problems/extract.ts +3 -0
  132. package/src/release/run-release.ts +5 -0
  133. package/src/repo-config.ts +46 -54
  134. package/src/rules/rule.ts +2 -2
  135. package/src/scaffold/files/agent-coding.md +1 -1
  136. package/src/scaffold/files/agent-index.md +1 -1
  137. package/src/scaffold/files/verify-skill.md +2 -2
  138. package/src/serve/hub-app/activity-st.ts +5 -4
  139. package/src/serve/hub-app/activity-view.tsx +1 -1
  140. package/src/serve/hub-app/admin-svc.ts +36 -3
  141. package/src/serve/hub-app/admin-view.tsx +46 -12
  142. package/src/serve/hub-app/check-view.tsx +66 -46
  143. package/src/serve/hub-app/check-words.ts +1 -11
  144. package/src/serve/hub-app/checks-svc.ts +14 -35
  145. package/src/serve/hub-app/col-card.tsx +1 -44
  146. package/src/serve/hub-app/col-tables-view.tsx +84 -11
  147. package/src/serve/hub-app/col-tables.ts +27 -0
  148. package/src/serve/hub-app/cols-svc.ts +5 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +6 -0
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +14 -2
  160. package/src/serve/hub-app/home-strip.css +43 -12
  161. package/src/serve/hub-app/home-strip.tsx +115 -64
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +32 -8
  164. package/src/serve/hub-app/hub-types.ts +5 -2
  165. package/src/serve/hub-app/hub.css +89 -63
  166. package/src/serve/hub-app/icons.tsx +11 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +15 -4
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +18 -22
  171. package/src/serve/hub-app/plan-view.tsx +51 -0
  172. package/src/serve/hub-app/planned-st.ts +9 -4
  173. package/src/serve/hub-app/processes-view.tsx +45 -0
  174. package/src/serve/hub-app/project-list.css +4 -1
  175. package/src/serve/hub-app/project-list.tsx +6 -3
  176. package/src/serve/hub-app/projects-svc.ts +25 -10
  177. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  178. package/src/serve/hub-app/reach.ts +36 -0
  179. package/src/serve/hub-app/readme-svc.ts +3 -2
  180. package/src/serve/hub-app/repo-sections.ts +131 -0
  181. package/src/serve/hub-app/repo-st.ts +70 -14
  182. package/src/serve/hub-app/sidebar.tsx +17 -42
  183. package/src/serve/hub-app/timeline-svc.ts +3 -2
  184. package/src/serve/hub-app/tree-build.ts +151 -155
  185. package/src/serve/hub-app/tree-row.tsx +55 -12
  186. package/src/serve/hub-app/tree-svc.ts +97 -56
  187. package/src/serve/hub-app/when-view.tsx +89 -0
  188. package/src/serve/hub-app/when.css +17 -0
  189. package/src/serve/hub-runtime.ts +7 -18
  190. package/src/serve/project-gutter-route.ts +22 -1
  191. package/src/serve/run-serve.ts +1 -1
  192. package/src/serve/serve-hub.ts +4 -14
  193. package/src/suite/judge-suites.ts +6 -0
  194. package/src/trace/interpose.c +655 -0
  195. package/src/trace/read-trace.ts +485 -0
  196. package/src/trace/tracer.ts +282 -0
  197. package/src/serve/hub-app/processes-svc.ts +0 -63
  198. package/src/serve/hub-app/repo-sections.tsx +0 -61
  199. package/src/serve/repo-processes.ts +0 -89
@@ -7,7 +7,7 @@ import { join } from 'path'
7
7
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
8
8
  import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
9
9
  import { ALLOW_MAJOR_ENV, breakingAdvice } from '../core/update-policy.ts'
10
- import { installedVersion, isBehind, updateRefusal, type FleetRepo } from './scan-fleet.ts'
10
+ import { isBehind, judgedVersion, updateRefusal, type FleetRepo } from './scan-fleet.ts'
11
11
 
12
12
 
13
13
  export const short = (name: string): string => name.replace(/^rvlib-/, '')
@@ -64,15 +64,15 @@ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Prom
64
64
  const env: Record<string, string> = Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] != null && e[0] !== 'SHIPKIT_SELF_UPDATED' && e[0] !== ALLOW_MAJOR_ENV))
65
65
  if (o.major) env[ALLOW_MAJOR_ENV] = '1'
66
66
  const res = await spawnWithTimeout(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
67
- // judge by what node_modules carries now, never by the exit code or a
68
- // printed line: the code covers every rule in that repo, and the line
69
- // format belongs to whatever version that repo runs
67
+ // judge by what node_modules carries now (the pin, for a linked dep),
68
+ // never by the exit code or a printed line: the code covers every rule
69
+ // in that repo, and the line format belongs to whatever version that repo runs
70
70
  const stuck = wanted.filter((d) => {
71
- const now = installedVersion(r.dir, d.name)
71
+ const now = judgedVersion(r.dir, d.name)
72
72
  return now == null || isBehind(now, d.latest!)
73
73
  })
74
74
  if (stuck.length === 0) {
75
- w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)} ${d.installed} → ${installedVersion(r.dir, d.name)}`).join(', ')}`)
75
+ w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)}${d.linkedInstall ? ' pin' : ''} ${d.at} → ${judgedVersion(r.dir, d.name)}`).join(', ')}`)
76
76
  continue
77
77
  }
78
78
  failed++
@@ -4,12 +4,15 @@
4
4
  // and iconed after it, in its repo's `.shipkit/wrapper/`. The bundle's
5
5
  // Info.plist carries what differs (name, bundle id, start url), so a fix to
6
6
  // the window (find, the inspector, zoom) lands in every app at its next
7
- // build. Rebuilt only when the source, the icon or the start url changed (a
8
- // stamp in the bundle). No compiler, or not macOS: the caller falls back.
7
+ // build. The icon is core/app-icon.ts: the repo's mark, its margin cut, on
8
+ // a plate in the repo's colour. Rebuilt only when the source, the icon, the
9
+ // colours or the start url changed (a stamp in the bundle). No compiler, or not macOS: the caller falls back.
9
10
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
10
11
  import { homedir } from 'os'
11
12
  import { dirname, join } from 'path'
12
13
  import { fileURLToPath } from 'url'
14
+ import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
15
+ import { identityOfName, type Identity } from '../core/identity-color.ts'
13
16
  import { LAYOUT } from '../core/layout.ts'
14
17
 
15
18
  const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
@@ -17,15 +20,6 @@ const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.u
17
20
  export const BUNDLE_ID = 'dev.shipkit.gui'
18
21
  export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
19
22
 
20
- /** the icon the app wears: the repo's own mark, largest first. Only a
21
- * png can become an icns */
22
- export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
23
-
24
- export function appIconOf(root: string): string | null {
25
- for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
26
- return null
27
- }
28
-
29
23
  /** what one app bundle is: the rest comes from the shared source */
30
24
  export type AppSpec = {
31
25
  /** CFBundleName: the dock, cmd-tab and the app menu say it */
@@ -34,6 +28,8 @@ export type AppSpec = {
34
28
  /** the page a launch with no url opens; null opens the home */
35
29
  url: string | null
36
30
  icon: string | null
31
+ /** the repo's colours: the plate behind the icon's mark */
32
+ identity: Identity
37
33
  /** where the bundle lands */
38
34
  app: string
39
35
  version: string
@@ -52,7 +48,7 @@ function atsOf(url: string | null): string {
52
48
  return `<dict><key>NSAllowsLocalNetworking</key><true/>${exception}</dict>`
53
49
  }
54
50
 
55
- export function infoPlist(o: Omit<AppSpec, 'app' | 'icon'>): string {
51
+ export function infoPlist(o: Omit<AppSpec, 'app' | 'icon' | 'identity'>): string {
56
52
  return `<?xml version="1.0" encoding="UTF-8"?>
57
53
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
58
54
  <plist version="1.0">
@@ -79,6 +75,9 @@ const runSync: Run = (cmd) => {
79
75
  return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
80
76
  }
81
77
 
78
+ /** bumped when the icon drawing changes, so every built app redraws its icon */
79
+ const ICON_DRAWING = 'plate-1'
80
+
82
81
  /** the stamp a built bundle carries: what it was built from. The version is
83
82
  * left out, so a shipkit release rebuilds nothing */
84
83
  export function stampOf(source: string, icon: string | null, identity = ''): string {
@@ -89,11 +88,11 @@ export function stampOf(source: string, icon: string | null, identity = ''): str
89
88
 
90
89
  /** one app, built or rebuilt when stale. Throws with the step that failed.
91
90
  * `rebuilt` tells the caller a running copy is now the old one */
92
- export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): { app: string; rebuilt: boolean } {
91
+ export async function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): Promise<{ app: string; rebuilt: boolean }> {
93
92
  const { app, icon } = spec
94
93
  const run = o.run ?? runSync
95
94
  const source = o.source ?? SOURCE
96
- const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url]))
95
+ const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url, spec.identity, ICON_DRAWING]))
97
96
  const stampFile = join(app, 'Contents', 'Resources', 'stamp')
98
97
  if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
99
98
  const tmp = `${app}.building-${process.pid}`
@@ -108,12 +107,10 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
108
107
  if (icon != null) {
109
108
  const set = join(res, 'AppIcon.iconset')
110
109
  mkdirSync(set, { recursive: true })
111
- for (const n of [16, 32, 128, 256, 512]) {
112
- for (const [scale, px] of [['', n], ['@2x', n * 2]] as const) {
113
- const r = run(['sips', '-s', 'format', 'png', '-z', String(px), String(px), icon, '--out', join(set, `icon_${n}x${n}${scale}.png`)])
114
- if (r.code !== 0) fail(`sips could not size the icon ${icon}: ${r.err.slice(0, 200)}`)
115
- }
116
- }
110
+ const files = [16, 32, 128, 256, 512].flatMap((n) => [[`icon_${n}x${n}.png`, n], [`icon_${n}x${n}@2x.png`, n * 2]] as const)
111
+ const pngs = await appIconPngs(readFileSync(icon), spec.identity, files.map(([, px]) => px))
112
+ .catch((e: Error) => fail(`could not draw the icon from ${icon}: ${e.message}`))
113
+ files.forEach(([file], i) => writeFileSync(join(set, file), pngs[i]!))
117
114
  const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
118
115
  rmSync(set, { recursive: true, force: true })
119
116
  if (r.code !== 0) fail(`iconutil could not build the icon: ${r.err.slice(0, 200)}`)
@@ -128,15 +125,15 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
128
125
  }
129
126
 
130
127
  /** the shipkit app: the shared source on the home, the house repo's icon */
131
- export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): { app: string; rebuilt: boolean } {
132
- return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
128
+ export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): Promise<{ app: string; rebuilt: boolean }> {
129
+ return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), identity: o.root == null ? identityOfName('shipkit') : identityOfRoot(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
133
130
  }
134
131
 
135
132
  /** what a global process gives its app */
136
133
  export type WrapperOf = { id: string; label: string; url: string | null; icon: string | null; root: string }
137
134
 
138
- /** the image icons sips reads; an emoji or an svg leaves the repo's mark */
139
- const RASTER = /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i
135
+ /** the images the icon renderer reads; an emoji or an svg leaves the repo's mark */
136
+ const RASTER = /\.(png|jpe?g|gif|webp)$/i
140
137
 
141
138
  export const wrapperBundleId = (id: string): string => `dev.shipkit.app.${id.replace(/[^A-Za-z0-9-]+/g, '-')}`
142
139
  export const wrapperPath = (root: string, id: string): string => join(root, LAYOUT.wrapper, `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.app`)
@@ -147,12 +144,12 @@ export const wrapperPath = (root: string, id: string): string => join(root, LAYO
147
144
  export function wrapperSpec(d: WrapperOf, version: string): AppSpec {
148
145
  if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
149
146
  const declared = d.icon != null && RASTER.test(d.icon) && existsSync(join(d.root, d.icon)) ? join(d.root, d.icon) : null
150
- return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), app: wrapperPath(d.root, d.id), version }
147
+ return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), identity: identityOfRoot(d.root), app: wrapperPath(d.root, d.id), version }
151
148
  }
152
149
 
153
- export const ensureWrapperApp = (d: WrapperOf, o: { version: string; run?: Run }): { app: string; rebuilt: boolean; bundleId: string } => {
150
+ export const ensureWrapperApp = async (d: WrapperOf, o: { version: string; run?: Run }): Promise<{ app: string; rebuilt: boolean; bundleId: string }> => {
154
151
  const spec = wrapperSpec(d, o.version)
155
- return { ...ensureApp(spec, { run: o.run }), bundleId: spec.bundleId }
152
+ return { ...(await ensureApp(spec, { run: o.run })), bundleId: spec.bundleId }
156
153
  }
157
154
 
158
155
  /** quits a running copy of an app, and launches nothing when none runs */
@@ -22,7 +22,10 @@ const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;'
22
22
  * every command, so any edit would restart pm. pm,
23
23
  * the daemon and the web each watch their own code and restart in place on
24
24
  * it (lib-watch.ts). PATH is the installing shell's, since launchd starts
25
- * with almost none and the daemon needs bun, git, ps, lsof and ssh */
25
+ * with almost none and the daemon needs bun, git, ps, lsof and ssh.
26
+ * ProcessType Interactive: with none, launchd throttles the agent's disk
27
+ * reads under load, every child inherits it, and a busy disk left the
28
+ * daemon's boot reads minutes long while its port was already open */
26
29
  export function renderPlist(o: { bun: string; cli: string; root: string; path: string; log: string }): string {
27
30
  const args = [o.bun, o.cli, 'pm'].map((a) => `\t\t<string>${esc(a)}</string>`).join('\n')
28
31
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -42,6 +45,8 @@ ${args}
42
45
  \t\t<key>PATH</key>
43
46
  \t\t<string>${esc(o.path)}</string>
44
47
  \t</dict>
48
+ \t<key>ProcessType</key>
49
+ \t<string>Interactive</string>
45
50
  \t<key>RunAtLoad</key>
46
51
  \t<true/>
47
52
  \t<key>KeepAlive</key>
@@ -73,7 +78,13 @@ const launchctl: Launchctl = async (args) => {
73
78
 
74
79
  const domain = (): string => `gui/${process.getuid?.() ?? 501}`
75
80
 
76
- export async function installHome(o: { root: string; cli: string; print: (l: string) => void; run?: Launchctl }): Promise<number> {
81
+ /** `bootout` returns before launchd lets go of the label, and a bootstrap in
82
+ * that window fails (exit 5, EIO) with the old agent already gone: retried
83
+ * this many times, BOOTSTRAP_STEP_MS apart, before the install fails */
84
+ export const BOOTSTRAP_TRIES = 20
85
+ const BOOTSTRAP_STEP_MS = 250
86
+
87
+ export async function installHome(o: { root: string; cli: string; print: (l: string) => void; run?: Launchctl; sleep?: (ms: number) => Promise<void> }): Promise<number> {
77
88
  if (process.platform !== 'darwin') {
78
89
  o.print(`FAIL: the pm LaunchAgent is macOS only. Run \`shipkit pm\` under your own supervisor on ${process.platform}`)
79
90
  return 1
@@ -89,9 +100,14 @@ export async function installHome(o: { root: string; cli: string; print: (l: str
89
100
  await run(['bootout', `${domain()}/${LABEL}`])
90
101
  await run(['bootout', `${domain()}/${LEGACY_LABEL}`])
91
102
  rmSync(plistPath(homedir(), LEGACY_LABEL), { force: true })
92
- const r = await run(['bootstrap', domain(), file])
103
+ const sleep = o.sleep ?? Bun.sleep
104
+ let r = await run(['bootstrap', domain(), file])
105
+ for (let i = 1; i < BOOTSTRAP_TRIES && r.code !== 0; i++) {
106
+ await sleep(BOOTSTRAP_STEP_MS)
107
+ r = await run(['bootstrap', domain(), file])
108
+ }
93
109
  if (r.code !== 0) {
94
- o.print(`FAIL: launchctl bootstrap refused ${file} (exit ${r.code}): ${r.out}`)
110
+ o.print(`FAIL: launchctl bootstrap refused ${file} (exit ${r.code}, ${BOOTSTRAP_TRIES} tries): ${r.out}. pm is not loaded now: run \`shipkit pm install\` again`)
95
111
  return 1
96
112
  }
97
113
  o.print(`ok: pm runs now and at every login, from ${o.root}, and runs the daemon and the web`)
@@ -16,8 +16,11 @@ import { tailLines } from './supervise.ts'
16
16
  import { Hubs, type WorkerView } from './hubs.ts'
17
17
  import type { Planner, PlanRow } from '../plan/planner.ts'
18
18
  import type { Widgets, WidgetView } from './widgets.ts'
19
- import { ActivityCache, windowMinutes } from './activity.ts'
19
+ import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
20
20
  import { HouseRuns } from '../serve/house-runs.ts'
21
+
22
+ /** how far back a repo's git page lists its commits */
23
+ export const GIT_PAGE_MS = 7 * 24 * 3600_000
21
24
  import type { HouseView } from '../ledger/house-runs.ts'
22
25
 
23
26
  /** how often the daemon looks at every repo */
@@ -173,6 +176,14 @@ export function homeRoutes(d: HomeDeps) {
173
176
  const q = new URL(req.url).searchParams
174
177
  return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
175
178
  }
179
+ // one repo's git for its git page: a week of commits, and HEAD's last
180
+ // one past that, whatever window the activity page shows
181
+ if (path === '/api/home/git.json') {
182
+ if (repos.length === 0) await refresh()
183
+ const r = find(new URL(req.url).searchParams.get('repo'))
184
+ if (r == null) return new Response('not a house repo', { status: 404 })
185
+ return Response.json(await repoActivity(r, Date.now() - GIT_PAGE_MS, (n) => d.plan?.declared(n) ?? null, (n) => d.plan?.titles(n) ?? null))
186
+ }
176
187
  if (path === '/api/house-runs.json') {
177
188
  if (repos.length === 0) await refresh()
178
189
  const view: HouseView = { ...house.view(), held: d.plan?.held() ?? [] }
@@ -6,7 +6,11 @@ import { homedir } from 'os'
6
6
  import { dirname, join, resolve } from 'path'
7
7
  import { gitRoot } from '../core/git-root.ts'
8
8
  import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
9
- import { BUNDLE_ID, canBuildGui, ensureGuiApp, ensureWrapperApp, quitCmd, type WrapperOf } from './gui-app.ts'
9
+ import type { WrapperOf } from './gui-app.ts'
10
+
11
+ // the app builder draws icons with canvas: loaded when a window opens, never
12
+ // at the startup of every command that imports this module
13
+ const gui = (): Promise<typeof import('./gui-app.ts')> => import('./gui-app.ts')
10
14
  import { installedHouseRoot, LABEL, plistPath } from './home-launchd.ts'
11
15
  import { childLog, daemonPort, daemonUp, homeDir, homePort, homeUp, pmLog } from './home-paths.ts'
12
16
 
@@ -88,10 +92,11 @@ export function windowCmd(url: string, o: { chrome?: string; platform?: string }
88
92
  /** one more home window: the native app where it can be built (its own
89
93
  * dock icon, the house mark), else a Chrome app window. `warn` hears why the
90
94
  * native one was not used */
91
- export function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): string[] {
95
+ export async function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): Promise<string[]> {
96
+ const { BUNDLE_ID, canBuildGui, ensureGuiApp, quitCmd } = await gui()
92
97
  if (canBuildGui()) {
93
98
  try {
94
- const { app, rebuilt } = ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
99
+ const { app, rebuilt } = await ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
95
100
  // a running copy is the old build: it quits, and the new one opens the window
96
101
  if (rebuilt) Bun.spawnSync(quitCmd(BUNDLE_ID), { stdout: 'ignore', stderr: 'ignore' })
97
102
  const cmd = ['open', '-a', app, url]
@@ -108,11 +113,12 @@ export function openWindow(url: string, o: { root?: string | null; version?: str
108
113
  * `.shipkit/wrapper/`, then opened with no url, so a running copy comes to
109
114
  * the front instead of stacking windows. Where no app can be built, a
110
115
  * Chrome app window on its url */
111
- export function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): string[] {
116
+ export async function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): Promise<string[]> {
112
117
  if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
118
+ const { canBuildGui, ensureWrapperApp, quitCmd } = await gui()
113
119
  if (canBuildGui()) {
114
120
  try {
115
- const { app, rebuilt, bundleId } = ensureWrapperApp(d, { version: o.version })
121
+ const { app, rebuilt, bundleId } = await ensureWrapperApp(d, { version: o.version })
116
122
  if (rebuilt) Bun.spawnSync(quitCmd(bundleId), { stdout: 'ignore', stderr: 'ignore' })
117
123
  const cmd = ['open', '-a', app]
118
124
  Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'ignore' })
package/src/home/hubs.ts CHANGED
@@ -132,7 +132,7 @@ export class Hubs {
132
132
  const rt = await this.o.load(e.repo, io, { fresh: fresh || e.loads > 0 })
133
133
  // a stop by hand came while it loaded, and wins
134
134
  if (e.held) {
135
- rt.stop({ procs: true })
135
+ rt.stop()
136
136
  this.set(e, 'down', 'stopped by hand')
137
137
  return
138
138
  }
@@ -158,7 +158,7 @@ export class Hubs {
158
158
  const names = new Set(repos.map((r) => r.name))
159
159
  for (const [name, e] of this.entries) {
160
160
  if (names.has(name)) continue
161
- e.rt?.stop({ procs: true })
161
+ e.rt?.stop()
162
162
  this.entries.delete(name)
163
163
  }
164
164
  for (const r of repos) {
@@ -179,7 +179,7 @@ export class Hubs {
179
179
  stop(repo: WiredRepo): WorkerView {
180
180
  const e = this.entry(repo)
181
181
  e.held = true
182
- e.rt?.stop({ procs: true })
182
+ e.rt?.stop()
183
183
  e.rt = null
184
184
  this.set(e, 'down', 'stopped by hand')
185
185
  return this.view(e)
@@ -190,7 +190,7 @@ export class Hubs {
190
190
  const e = this.entry(repo)
191
191
  await e.loading
192
192
  e.held = false
193
- e.rt?.stop({ procs: true })
193
+ e.rt?.stop()
194
194
  e.rt = null
195
195
  await this.schedule(e, true)
196
196
  return this.view(e)
@@ -279,7 +279,7 @@ export class Hubs {
279
279
  return repos.map((r) => this.view(this.entry(r)))
280
280
  }
281
281
 
282
- /** every hub stops, its processes left running for the next home to adopt */
282
+ /** every hub stops */
283
283
  stopAll(): void {
284
284
  for (const e of this.entries.values()) e.rt?.stop()
285
285
  }
@@ -13,10 +13,10 @@ if (import.meta.main) {
13
13
  try {
14
14
  if (first === '--process' && second != null) {
15
15
  const d = JSON.parse(second) as WrapperOf
16
- const cmd = openProcessApp(d, { version: LIB_VERSION, warn })
16
+ const cmd = await openProcessApp(d, { version: LIB_VERSION, warn })
17
17
  console.log(`shipkit: opened ${d.id} as its own app: ${cmd.join(' ')}`)
18
18
  } else if (first != null && first !== '--process') {
19
- const cmd = openWindow(first, { version: LIB_VERSION, warn })
19
+ const cmd = await openWindow(first, { version: LIB_VERSION, warn })
20
20
  console.log(`shipkit: opened ${first} as an app window: ${cmd.join(' ')}`)
21
21
  } else {
22
22
  console.error('usage: bun open-app.ts <url> | --process <json>')
package/src/home/pm.ts CHANGED
@@ -27,6 +27,10 @@ export type ChildView = ProcView & { tail: string[] }
27
27
  /** where pm, the daemon and the web listen */
28
28
  export type CorePorts = { pm: number; daemon: number; web: number }
29
29
 
30
+ /** a child whose pid lives but has not answered for this long is hung and
31
+ * gets killed: a healthy daemon answers within about 20 s of its start */
32
+ export const CHILD_HANG_MS = 90_000
33
+
30
34
  /** how many log lines each of shipkit's own processes hands the page */
31
35
  const CORE_TAIL = 20
32
36
 
@@ -61,6 +65,8 @@ export type PmDeps = {
61
65
  /** runs a stop or restart of the web once its answer is out: the web
62
66
  * proxies that very request (default: 200 ms later) */
63
67
  later?: (f: () => void) => void
68
+ /** where pm says what it did on its own, a hung child killed */
69
+ say?: (line: string) => void
64
70
  }
65
71
 
66
72
  const WEB_ANSWER_MS = 200
@@ -79,6 +85,8 @@ export class Pm {
79
85
  probe: () => d.probe(role),
80
86
  // a crash loop while its code is being edited heals itself
81
87
  neverGiveUp: true,
88
+ hangMs: CHILD_HANG_MS,
89
+ say: d.say,
82
90
  }, d.deps ?? realDeps)]))
83
91
  }
84
92
 
@@ -100,6 +100,7 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
100
100
  cmd: (role) => [process.execPath, CLI, role],
101
101
  probe: roleAnswers,
102
102
  resources: new Resources(c.processes, dir),
103
+ say: io.printErr,
103
104
  })
104
105
  let server: Bun.Server<unknown>
105
106
  try {
@@ -51,8 +51,8 @@ export const REPO_ANSWER_MS = 30_000
51
51
  export const LIST_EVERY_MS = 30_000
52
52
  export const LIST_RETRY_MS = 1_000
53
53
 
54
- /** the one home answer that can wait on git: the activity page's first read of a window */
55
- export const readsGit = (path: string): boolean => path === '/api/home/activity.json'
54
+ /** the home answers that can wait on git: the activity page's first read of a window, and a repo's git page */
55
+ export const readsGit = (path: string): boolean => path === '/api/home/activity.json' || path === '/api/home/git.json'
56
56
 
57
57
  /** a run or a check run streams for minutes, and a silent one is never cut */
58
58
  export const streams = (path: string): boolean => /\/api\/(run|check\/run)$/.test(path)
@@ -29,6 +29,12 @@ export type ProcSpec = {
29
29
  /** never left down after CRASH_LIMIT crashes: it keeps retrying at the
30
30
  * longest backoff, so a code fix brings it back with no hand */
31
31
  neverGiveUp?: boolean
32
+ /** a pid that lives but has not answered its probe for this long is hung
33
+ * (a main thread stuck in one synchronous step still holds its port):
34
+ * killed, then started again after the crash backoff. None = never */
35
+ hangMs?: number
36
+ /** a line for every act the supervisor takes on its own */
37
+ say?: (line: string) => void
32
38
  }
33
39
 
34
40
  export type ProcView = {
@@ -137,6 +143,8 @@ export class Proc {
137
143
  private retryAt: number | null = null
138
144
  private killAt: number | null = null
139
145
  private probing = false
146
+ /** why the kill in flight was a hang kill, for the crash note */
147
+ private hung: string | null = null
140
148
  /** a restart: start again once the stop landed */
141
149
  private again = false
142
150
  /** it answers its probe with no pid of ours behind it */
@@ -275,6 +283,8 @@ export class Proc {
275
283
  const now = this.d.now()
276
284
  if (this.pid != null && !this.d.alive(this.pid)) {
277
285
  this.gone()
286
+ const hung = this.hung
287
+ this.hung = null
278
288
  if (this.again) {
279
289
  this.again = false
280
290
  this.want = 'up'
@@ -287,7 +297,7 @@ export class Proc {
287
297
  this.set('crashed', `exited ${this.crashes.length} times in ${Math.round(CRASH_WINDOW_MS / 60_000)} min, left down: see the log`)
288
298
  } else {
289
299
  this.retryAt = now + BACKOFF_MS[Math.min(this.crashes.length - 1, BACKOFF_MS.length - 1)]!
290
- this.set('crashed', `exited, restart in ${Math.round((this.retryAt - now) / 1000)}s`)
300
+ this.set('crashed', `${hung != null ? `${hung}, killed` : 'exited'}, restart in ${Math.round((this.retryAt - now) / 1000)}s`)
291
301
  }
292
302
  }
293
303
  }
@@ -318,6 +328,19 @@ export class Proc {
318
328
  } finally {
319
329
  this.probing = false
320
330
  }
331
+ this.killIfHung(now)
332
+ }
333
+
334
+ /** a pid that lives and still has not answered, hangMs after it last
335
+ * changed status, is stopped: the crash path starts it again */
336
+ private killIfHung(now: number): void {
337
+ const hang = this.spec.hangMs
338
+ if (hang == null || this.status !== 'starting' || this.pid == null || this.want !== 'up' || now - this.since < hang) return
339
+ this.hung = `did not answer its probe for ${Math.round((now - this.since) / 1000)}s`
340
+ this.spec.say?.(`WARN ${this.spec.id} (pid ${this.pid}) ${this.hung}: killed, it starts again`)
341
+ this.d.kill(this.pid, 'SIGTERM')
342
+ this.killAt = now + KILL_AFTER_MS
343
+ this.set('stopping', `${this.hung}, killing it`)
321
344
  }
322
345
 
323
346
  view(): ProcView {
@@ -17,6 +17,18 @@ import { daemonUp, daemonUrl, hubBase, WORKER_ENV } from './home-paths.ts'
17
17
  import { homeWanted, startHome } from './home-start.ts'
18
18
  import { RUN_EXIT, RUN_JOB } from './home-words.ts'
19
19
  import { followJob, type FollowOpts } from '../core/job-follow.ts'
20
+ import { TRACE_ENV } from '../trace/tracer.ts'
21
+ import { FRESH_ENV } from '../ledger/store.ts'
22
+ import { LEDGER_ENV } from '../ledger/ledger.ts'
23
+ import { PARALLEL_ENV } from '../core/parallel.ts'
24
+
25
+ /** the env switches a gate reads, which a terminal may set for one run: the
26
+ * job the home starts runs with the daemon's env, so they travel with it */
27
+ export const FORWARDED_ENV = [TRACE_ENV, FRESH_ENV, LEDGER_ENV, PARALLEL_ENV] as const
28
+
29
+ /** the forwarded switches this env sets */
30
+ export const forwardedEnv = (env: Env = process.env): Record<string, string> =>
31
+ Object.fromEntries(FORWARDED_ENV.filter((k) => env[k] != null && env[k] !== '').map((k) => [k, env[k]!]))
20
32
 
21
33
  type Env = Record<string, string | undefined>
22
34
  const set = (v: string | undefined): boolean => v != null && v !== ''
@@ -46,10 +58,12 @@ export async function runViaHub(root: string, kind: 'ci' | 'check', opts: Record
46
58
  }
47
59
  // what the terminal knows and the hub does not: its tier and whether it is a terminal
48
60
  const full = process.env.SHIPKIT_TEST_FULL === '1'
61
+ const env = forwardedEnv()
49
62
  const sent = {
50
63
  ...opts,
51
64
  ...(full ? (kind === 'ci' ? { tests: opts.tests ?? 'full' } : { tier: opts.tier ?? 'full' }) : {}),
52
65
  color: process.stdout.isTTY === true && process.env.NO_COLOR == null,
66
+ ...(Object.keys(env).length > 0 ? { env } : {}),
53
67
  }
54
68
  // the home waits for a hub still loading before it answers
55
69
  const post = (): Promise<Response> => fetch(`${daemonUrl()}${hubBase(name)}/api/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind, opts: sent }) })
@@ -14,6 +14,11 @@ import { gitRoot } from '../core/git-root.ts'
14
14
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
15
15
  import type { RepoConfig } from '../repo-config.ts'
16
16
  import { gitBin } from '../core/git-bin.ts'
17
+ import { discoverChecks } from '../core/discover-checks.ts'
18
+ import { CheckCtx } from '../core/check-ctx.ts'
19
+ import { runCheckBody, type CheckDef } from '../check.ts'
20
+ import { repoOff } from '../core/when.ts'
21
+ import { formatLine } from '../core/wire.ts'
17
22
 
18
23
  export type HooksConfig = {
19
24
  /** repo-specific commands run in the pre-commit gate, after the built-ins.
@@ -210,7 +215,7 @@ export function blockedStaged(staged: string[], config: RepoConfig): string[] {
210
215
  const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
211
216
 
212
217
  /** the pre-commit gate: built-in leak guard, then the repo's own commands.
213
- * A commit takes ~1s (build/shipkit.md): the gate times itself, and going
218
+ * A commit takes ~1s (build/shipkit/commit-gates.md): the gate times itself, and going
214
219
  * over budget WARNs naming the fix, so drift back to slow commits is loud. */
215
220
  export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
216
221
  const started = performance.now()
@@ -273,6 +278,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
273
278
  ],
274
279
  })
275
280
  }
281
+ out.push(...(await runPreCommitChecks(root, config)))
276
282
  for (const c of h.preCommit ?? []) {
277
283
  const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
278
284
  out.push({
@@ -282,15 +288,52 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
282
288
  })
283
289
  }
284
290
  const elapsed = performance.now() - started
285
- if (elapsed > 1500)
291
+ if (elapsed > HOOK_BUDGET_MS)
286
292
  out.push({
287
293
  id: 'hook-budget',
288
294
  ok: true,
289
- lines: [`WARN pre-commit took ${(elapsed / 1000).toFixed(1)}s (budget ~1s) — move slow preCommit commands to ci tasks (build/shipkit.md)`],
295
+ lines: [`WARN pre-commit took ${(elapsed / 1000).toFixed(1)}s (budget ${HOOK_BUDGET_MS / 1000}s) — move slow preCommit commands to ci tasks, or a check off onPreCommit (build/shipkit/runs.md)`],
290
296
  })
291
297
  return out
292
298
  }
293
299
 
300
+ /** the whole hook, boot included, past which it warns */
301
+ export const HOOK_BUDGET_MS = 1_000
302
+
303
+ /** the checks that declare `onPreCommit` and apply here, with their budget */
304
+ export type PreCommitCheck = { id: string; title: string; owner: string; budgetMs: number; run: NonNullable<CheckDef['run']>; repair: unknown }
305
+
306
+ export async function preCommitChecks(root: string, config: RepoConfig): Promise<PreCommitCheck[]> {
307
+ const out: PreCommitCheck[] = []
308
+ for (const u of await discoverChecks(root)) {
309
+ const p = u.def.runs.onPreCommit
310
+ if (p === false || u.def.run == null) continue
311
+ if (repoOff(config, u.id) != null) continue
312
+ if (typeof u.def.applies === 'function') {
313
+ try { if (u.def.applies({ root, config }) != null) continue } catch (e) { console.error(`WARN ${u.id} applies threw, so it applies: ${(e as Error).message}`) }
314
+ }
315
+ out.push({ id: u.id, title: u.title, owner: u.owner, budgetMs: p.budgetMs, run: u.def.run, repair: u.def.repair })
316
+ }
317
+ return out
318
+ }
319
+
320
+ /** every `onPreCommit` check, run RAW on the working tree: no ledger, no
321
+ * cache, no repair. A failure refuses the commit, a pass over its budget
322
+ * warns by name and never blocks on time */
323
+ async function runPreCommitChecks(root: string, config: RepoConfig): Promise<GateOutcome[]> {
324
+ const out: GateOutcome[] = []
325
+ for (const k of await preCommitChecks(root, config)) {
326
+ const c = CheckCtx.open({ root, config })
327
+ const t = performance.now()
328
+ await runCheckBody({ id: k.id, title: k.title, owner: k.owner, run: k.run, fix: k.repair }, c)
329
+ const ms = performance.now() - t
330
+ const lines = c.failed ? c.lines.map((l) => formatLine(l, false)) : [`ok: ${k.id} (${Math.round(ms)}ms)`]
331
+ if (ms > k.budgetMs) lines.push(`WARN ${k.id} took ${Math.round(ms)}ms at commit, over its ${k.budgetMs}ms onPreCommit budget — make it cheaper, or move it off onPreCommit`)
332
+ out.push({ id: k.id, ok: !c.failed, lines })
333
+ }
334
+ return out
335
+ }
336
+
294
337
  /** the commit-msg gate: the shared message predicate, then the repo's own. */
295
338
  export async function runCommitMsg(root: string, config: RepoConfig, messageFile: string): Promise<GateOutcome[]> {
296
339
  const h = config.hooks ?? {}
@@ -339,7 +382,7 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
339
382
  }
340
383
 
341
384
  /** what blocks a commit here, the enumeration hand-rolled hooks never had. */
342
- export function listGates(config: RepoConfig, root = process.cwd()): string[] {
385
+ export function listGates(config: RepoConfig, root = process.cwd(), checks: { id: string; budgetMs: number }[] = []): string[] {
343
386
  const h = config.hooks ?? {}
344
387
  const off = new Set(h.disable ?? [])
345
388
  const kw = h.keywordFile ?? KEYWORD_FILE
@@ -354,6 +397,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
354
397
  `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
355
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)` : ''}`] : []),
356
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`,
400
+ ...checks.map((c) => `pre-commit ${c.id} — the check, onPreCommit, budget ${c.budgetMs}ms`),
357
401
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
358
402
  off.has('commit-message')
359
403
  ? `commit-msg commit-message (DISABLED by config) — dash punctuation + French; ai-attribution stays on, never disabled`
package/src/init-repo.ts CHANGED
@@ -100,7 +100,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
100
100
  // its .gitignore policy block (same fixer as `check --fix SK004`),
101
101
  // with the repo's own contributed lines honored
102
102
  const cfg = await loadRepoConfig(root)
103
- const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: cfg }))
103
+ const g = await gitignorePolicy.repair!(CheckCtx.open({ root, config: cfg }))
104
104
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
105
105
  // the repos that need ONE WAY IN most are the ones that already exist,
106
106
  // so a re-run REPAIRS rather than only seeding a fresh checkout
@@ -136,7 +136,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
136
136
  }
137
137
  // the .gitignore policy block (contract git policy) — same fixer as
138
138
  // `shipkit check --fix SK004`, one code path
139
- const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: defineRepo({ name }) }))
139
+ const g = await gitignorePolicy.repair!(CheckCtx.open({ root, config: defineRepo({ name }) }))
140
140
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
141
141
  await repairOneWayIn(root, defineRepo({ name }), p)
142
142
  await furnishEditor(root, { name }, p)
@@ -194,7 +194,7 @@ async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?
194
194
  // a file SK017 cannot read is still the repo's own: it is never
195
195
  // reported as missing, and it is never rewritten
196
196
  let hub: { changed: boolean; summary: string }
197
- try { hub = await vscodeHubTask.fix!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
197
+ try { hub = await vscodeHubTask.repair!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
198
198
  p('ok: .vscode/ is this repo\'s own, left untouched')
199
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
200
  }
@@ -208,14 +208,14 @@ async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?
208
208
  }
209
209
 
210
210
  /** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
211
- * (build/shipkit.md). Nothing here is a second implementation: rule SK001
211
+ * (build/shipkit/one-way-in.md). Nothing here is a second implementation: rule SK001
212
212
  * routes the package.json scripts, rule SK013 writes the harness policy, and
213
213
  * init just calls them so a wired repo is born enforced and a re-run repairs
214
214
  * one that was not. The deny is a RESTRICTION, which is why it lands
215
215
  * unconditionally where the scaffold's allow list stays opt in. */
216
216
  async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
217
- const pkg = await packageJsonShape.fix!(CheckCtx.open({ root, config }))
217
+ const pkg = await packageJsonShape.repair!(CheckCtx.open({ root, config }))
218
218
  if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
219
- const policy = await oneWayInPolicy.fix!(CheckCtx.open({ root, config }))
219
+ const policy = await oneWayInPolicy.repair!(CheckCtx.open({ root, config }))
220
220
  p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
221
221
  }