rvlib-shipkit 0.38.0 → 1.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 (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,195 @@
1
+ // THE PROCESS IMAGE: who wrote a slot, a lease or an attach answer is a pid
2
+ // PLUS a nonce drawn once per program start. `bun --watch` restarts a program
3
+ // in the same pid (execve), so a pid alone outlives the run that took the
4
+ // record. One registry file per pid, `<pid>.json` under
5
+ // ~/.cache/shipkit/instances/, says which image owns that pid now and which
6
+ // check children it has in flight.
7
+ //
8
+ // The one constraint a reader breaks first: only a pid that is gone or runs
9
+ // ANOTHER image is a dead holder. A silent beat never is: the beat is a main
10
+ // thread timer, and a live holder busy in one long synchronous step stops
11
+ // beating while it still holds the phase. A record with NO nonce, or a pid
12
+ // with no registry file, falls back to the pid rule: an older shipkit writes
13
+ // no nonce, and a missing file must never read as a dead holder, or two
14
+ // processes run one phase at once.
15
+ import { randomBytes } from 'crypto'
16
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
17
+ import { homedir } from 'os'
18
+ import { join } from 'path'
19
+ import { pidAlive } from '../ledger/live-runs.ts'
20
+ import { descendantPids, psSnapshot } from './process-tree.ts'
21
+
22
+ export const INSTANCES_DIR_ENV = 'SHIPKIT_INSTANCES_DIR'
23
+ /** how often a running image stamps its registry file, so the housekeeping
24
+ * below never drops the file of an image that still runs */
25
+ export const INSTANCE_BEAT_MS = 5_000
26
+ /** a registry file nothing stamped for this long is dropped, whatever its pid */
27
+ const KEEP_MS = 60 * 60 * 1000
28
+ /** a long lived image (the hub) sweeps this often, not only at start */
29
+ const SWEEP_EVERY_MS = 60 * 60 * 1000
30
+ /** a `<pid>.json.tmp-*` this old is a write that died before its rename */
31
+ const TMP_KEEP_MS = 60_000
32
+
33
+ export type InstanceId = { pid: number; nonce: string }
34
+ type Registry = InstanceId & { beat: number; started: number; children: number[] }
35
+
36
+ // on globalThis: two copies of the lib in one process are one image, and two
37
+ // nonces for one pid would each call the other's records dead. The nonce key
38
+ // holds a bare string because an older copy reads it as one.
39
+ const g = globalThis as unknown as Record<symbol, unknown>
40
+ const NONCE = (g[Symbol.for('shipkit.instance.nonce')] ??= randomBytes(6).toString('hex')) as string
41
+
42
+ // one image writes one registry file, so every copy shares the children it
43
+ // lists and the timer that beats it: a copy of its own writes only its own
44
+ // children over the other's
45
+ type ImageState = {
46
+ started: number
47
+ children: Set<number>
48
+ beating: { dir: string; timer: ReturnType<typeof setInterval> } | null
49
+ swept: number
50
+ exitHook: boolean
51
+ }
52
+ const state = (g[Symbol.for('shipkit.instance.state')] ??= {
53
+ started: Date.now(),
54
+ children: new Set<number>(),
55
+ beating: null,
56
+ swept: 0,
57
+ exitHook: false,
58
+ } satisfies ImageState) as ImageState
59
+
60
+ export const thisInstance = (): InstanceId => ({ pid: process.pid, nonce: NONCE })
61
+
62
+ export const instancesDir = (): string => {
63
+ const env = process.env[INSTANCES_DIR_ENV]
64
+ return env != null && env !== '' ? env : join(homedir(), '.cache', 'shipkit', 'instances')
65
+ }
66
+
67
+ const readRegistry = (dir: string, pid: number): Registry | null => {
68
+ try {
69
+ const r = JSON.parse(readFileSync(join(dir, `${pid}.json`), 'utf8')) as Partial<Registry>
70
+ if (typeof r.nonce !== 'string' || typeof r.beat !== 'number') return null
71
+ return { pid, nonce: r.nonce, beat: r.beat, started: Number(r.started ?? r.beat), children: Array.isArray(r.children) ? r.children.filter((c) => Number.isInteger(c)) : [] }
72
+ } catch { return null }
73
+ }
74
+
75
+ /** why a holder is not a running image, or null while it is one */
76
+ export function instanceGone(h: { pid: number; nonce?: string }): string | null {
77
+ if (!pidAlive(h.pid)) return `pid ${h.pid} is not running`
78
+ if (h.nonce == null) return null
79
+ if (h.pid === process.pid) return h.nonce === NONCE ? null : `pid ${h.pid} restarted in place (this process is a later image of it)`
80
+ const r = readRegistry(instancesDir(), h.pid)
81
+ return r == null || r.nonce === h.nonce ? null : `pid ${h.pid} restarted in place (another image of it runs now)`
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // this image's registry file: written on first use, stamped while it runs,
86
+ // removed at exit, and listing the check children in flight so a later image
87
+ // of the pid can reap them
88
+
89
+ const write = (dir: string): void => {
90
+ try {
91
+ mkdirSync(dir, { recursive: true })
92
+ const path = join(dir, `${process.pid}.json`)
93
+ const tmp = `${path}.tmp-${NONCE}`
94
+ writeFileSync(tmp, JSON.stringify({ pid: process.pid, nonce: NONCE, beat: Date.now(), started: state.started, children: [...state.children] } satisfies Registry))
95
+ renameSync(tmp, path)
96
+ } catch { /* a registry nobody can write costs liveness precision, never a run */ }
97
+ }
98
+
99
+ const sweepRegistry = (dir: string, now: number): void => {
100
+ state.swept = now
101
+ try {
102
+ for (const f of readdirSync(dir)) {
103
+ const p = join(dir, f)
104
+ if (/^\d+\.json\.tmp-/.test(f)) {
105
+ try { if (now - statSync(p).mtimeMs > TMP_KEEP_MS) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
106
+ continue
107
+ }
108
+ const m = /^(\d+)\.json$/.exec(f)
109
+ if (m == null) continue
110
+ const r = readRegistry(dir, Number(m[1]))
111
+ // a pid that is gone says nothing a liveness read needs
112
+ if (r == null || !pidAlive(r.pid) || now - r.beat > KEEP_MS) rmSync(p, { force: true })
113
+ }
114
+ } catch { /* housekeeping never fails a run */ }
115
+ }
116
+
117
+ /** the file goes with the image, and only while it is still this image's:
118
+ * a later image of the pid owns what it wrote */
119
+ const dropOwnRecord = (): void => {
120
+ if (state.beating == null) return
121
+ if (readRegistry(state.beating.dir, process.pid)?.nonce === NONCE) rmSync(join(state.beating.dir, `${process.pid}.json`), { force: true })
122
+ }
123
+
124
+ /** publish this image as the owner of its pid, and keep stamping it. Every
125
+ * record writer calls it first, so a record's nonce is always one the
126
+ * registry has seen. Idempotent; the dir is read on each call so a test that
127
+ * points the env elsewhere gets its own file */
128
+ export function announceInstance(): InstanceId {
129
+ const dir = instancesDir()
130
+ if (state.beating?.dir !== dir) {
131
+ if (state.beating != null) clearInterval(state.beating.timer)
132
+ sweepRegistry(dir, Date.now())
133
+ write(dir)
134
+ const timer = setInterval(() => {
135
+ write(dir)
136
+ if (Date.now() - state.swept > SWEEP_EVERY_MS) sweepRegistry(dir, Date.now())
137
+ }, INSTANCE_BEAT_MS)
138
+ timer.unref()
139
+ state.beating = { dir, timer }
140
+ }
141
+ if (!state.exitHook) {
142
+ state.exitHook = true
143
+ process.on('exit', dropOwnRecord)
144
+ }
145
+ return thisInstance()
146
+ }
147
+
148
+ /** a child a check spawned, listed while it runs */
149
+ export function trackChild(pid: number): void {
150
+ state.children.add(pid)
151
+ announceInstance()
152
+ if (state.beating != null) write(state.beating.dir)
153
+ }
154
+
155
+ export function untrackChild(pid: number): void {
156
+ if (!state.children.delete(pid)) return
157
+ if (state.beating != null) write(state.beating.dir)
158
+ }
159
+
160
+ /** what a new image of a restarted pid finds of the previous one, and reaps */
161
+ export type Reclaimed = { previous: string | null; killed: number[] }
162
+
163
+ /** called by a long lived process (the hub) at start, BEFORE it runs
164
+ * anything: the previous image of this pid is gone, so every check child it
165
+ * listed that is still OUR child is killed with its whole tree. A child
166
+ * this image spawns for itself (the comfy panel) is never listed, so it is
167
+ * left to be adopted. Then this image takes the pid over. */
168
+ export function reclaimPreviousImage(o: { snapshot?: string } = {}): Reclaimed {
169
+ const dir = instancesDir()
170
+ const prev = readRegistry(dir, process.pid)
171
+ const out: Reclaimed = { previous: null, killed: [] }
172
+ if (prev != null && prev.nonce !== NONCE) {
173
+ out.previous = prev.nonce
174
+ const snapshot = o.snapshot ?? psSnapshot()
175
+ const ours = new Set(directChildren(snapshot))
176
+ for (const kid of prev.children) {
177
+ // a listed pid that is no longer our child was reused by someone else
178
+ if (!ours.has(kid)) continue
179
+ for (const pid of [...descendantPids(kid, snapshot), kid]) {
180
+ try { process.kill(pid, 'SIGKILL'); out.killed.push(pid) } catch { /* already gone */ }
181
+ }
182
+ }
183
+ }
184
+ announceInstance()
185
+ return out
186
+ }
187
+
188
+ const directChildren = (snapshot: string): number[] => {
189
+ const out: number[] = []
190
+ for (const line of snapshot.split('\n')) {
191
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
192
+ if (m != null && Number(m[2]) === process.pid) out.push(Number(m[1]))
193
+ }
194
+ return out
195
+ }
@@ -0,0 +1,118 @@
1
+ // DETACHED GATES: `shipkit ci --detach` starts an ordinary gate in the
2
+ // background and hands back a run id, `shipkit status [id]` reads it back.
3
+ //
4
+ // The background process OWNS NOTHING. It is the same gate a terminal runs,
5
+ // its answers land in the same ledger and its cost in the same run record;
6
+ // this module only remembers where its output went and how it ended, in
7
+ // `.shipkit/tmp/shipkit-jobs/<id>/`: `job.json` (who, what, when), `out.log` (its
8
+ // wire lines as it prints them) and `exit.json` (the verdict, once).
9
+ import { spawn } from 'child_process'
10
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
11
+ import { join } from 'path'
12
+ import { LAYOUT } from './layout.ts'
13
+ import { atomicWrite } from './locked-write.ts'
14
+ import { fmtStamp } from './wire.ts'
15
+
16
+ /** status exit code while the run has no verdict yet */
17
+ export const STILL_RUNNING = 3
18
+
19
+ type Job = { id: string; pid: number; args: string[]; startedAt: number }
20
+ type Exit = { code: number; finishedAt: number }
21
+
22
+ const JOBS = `${LAYOUT.tmp}/shipkit-jobs`
23
+ /** how long a finished run stays readable */
24
+ const KEEP_MS = 7 * 24 * 60 * 60 * 1000
25
+
26
+ const ciLine = (args: string[]): string => ['shipkit ci', ...args].join(' ')
27
+
28
+ export const jobDir =(root: string, id: string): string => join(root, JOBS, id)
29
+
30
+ const readJson = <T>(p: string): T | null => {
31
+ try { return JSON.parse(readFileSync(p, 'utf8')) as T } catch { return null }
32
+ }
33
+
34
+ const alive = (pid: number): boolean => {
35
+ try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
36
+ }
37
+
38
+ /** start `shipkit ci <args> --job <id>` detached from this process and its
39
+ * terminal, and return at once */
40
+ export function startDetached(root: string, o: { cli: string; args: string[] }): { id: string; pid: number; lines: string[] } {
41
+ sweepJobs(root)
42
+ const id = `ci-${Date.now().toString(36)}-${process.pid.toString(36)}`
43
+ const dir = jobDir(root, id)
44
+ mkdirSync(dir, { recursive: true })
45
+ const out = openSync(join(dir, 'out.log'), 'a')
46
+ const child = spawn(process.execPath, [o.cli, 'ci', ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
47
+ closeSync(out)
48
+ child.unref()
49
+ const pid = child.pid ?? 0
50
+ atomicWrite(join(dir, 'job.json'), JSON.stringify({ id, pid, args: o.args, startedAt: Date.now() } satisfies Job))
51
+ return {
52
+ id,
53
+ pid,
54
+ lines: [
55
+ `ok: gate started in the background as ${id} (pid ${pid}): ${ciLine(o.args)}`,
56
+ ` fix: shipkit status ${id} prints it, and exits ${STILL_RUNNING} until it has a verdict`,
57
+ ],
58
+ }
59
+ }
60
+
61
+ /** the detached gate records how it ended. Called by the gate itself. */
62
+ export function recordExit(root: string, id: string, code: number): void {
63
+ const dir = jobDir(root, id)
64
+ if (!existsSync(dir)) return
65
+ atomicWrite(join(dir, 'exit.json'), JSON.stringify({ code, finishedAt: Date.now() } satisfies Exit))
66
+ }
67
+
68
+ function latestJob(root: string): string | null {
69
+ let best: Job | null = null
70
+ try {
71
+ for (const name of readdirSync(join(root, JOBS))) {
72
+ const j = readJson<Job>(join(root, JOBS, name, 'job.json'))
73
+ if (j != null && (best == null || j.startedAt > best.startedAt)) best = j
74
+ }
75
+ } catch { return null }
76
+ return best?.id ?? null
77
+ }
78
+
79
+ /** the run's wire lines so far, then one line on where it stands. The code is
80
+ * the gate's own exit code once it has one, STILL_RUNNING before, 1 for a
81
+ * run that died without a verdict, 2 for an id nobody started here. */
82
+ export function jobStatus(root: string, id?: string): { code: number; lines: string[] } {
83
+ const want = id ?? latestJob(root)
84
+ if (want == null) return { code: 2, lines: ['FAIL: no detached run in this checkout yet', ' fix: shipkit ci --detach starts one'] }
85
+ const dir = jobDir(root, want)
86
+ const job = readJson<Job>(join(dir, 'job.json'))
87
+ if (job == null) return { code: 2, lines: [`FAIL: no detached run '${want}' in this checkout`, ` fix: shipkit status (no id) shows the latest one`] }
88
+ const lines = [`ok: run ${job.id}: ${ciLine(job.args)} (pid ${job.pid}, started ${fmtStamp(job.startedAt)})`]
89
+ let out = ''
90
+ try { out = readFileSync(join(dir, 'out.log'), 'utf8') } catch { /* nothing printed yet */ }
91
+ lines.push(...out.split('\n').filter((l) => l !== ''))
92
+ const exit = (): Exit | null => readJson<Exit>(join(dir, 'exit.json'))
93
+ let e = exit()
94
+ if (e == null && alive(job.pid)) {
95
+ lines.push(`skip: run ${job.id} still running (${((Date.now() - job.startedAt) / 1000).toFixed(1)}s so far), status exits ${STILL_RUNNING} until it has a verdict`)
96
+ return { code: STILL_RUNNING, lines }
97
+ }
98
+ // it may have written its verdict between the two reads
99
+ e ??= exit()
100
+ if (e == null) return { code: 1, lines: [...lines, `FAIL: run ${job.id} died without a verdict (pid ${job.pid} is gone)`, ` fix: ${ciLine(job.args)}`] }
101
+ const took = `${((e.finishedAt - job.startedAt) / 1000).toFixed(1)}s`
102
+ lines.push(e.code === 0 ? `ok: run ${job.id} finished green in ${took}` : `FAIL: run ${job.id} finished red (exit ${e.code}) in ${took}`)
103
+ return { code: e.code, lines }
104
+ }
105
+
106
+ /** finished runs older than a week go; a running one never does */
107
+ function sweepJobs(root: string): void {
108
+ const base = join(root, JOBS)
109
+ try {
110
+ for (const name of readdirSync(base)) {
111
+ const dir = join(base, name)
112
+ const j = readJson<Job>(join(dir, 'job.json'))
113
+ try { if (statSync(dir).mtimeMs > Date.now() - KEEP_MS) continue } catch { continue }
114
+ if (j != null && alive(j.pid)) continue
115
+ rmSync(dir, { recursive: true, force: true })
116
+ }
117
+ } catch { /* no jobs yet: nothing to sweep */ }
118
+ }
@@ -0,0 +1,52 @@
1
+ // Where shipkit keeps things inside a repo: ONE folder, `.shipkit/`.
2
+ //
3
+ // Every path a reader or writer needs comes from this table, so a move is one
4
+ // edit here plus the migration that carries existing repos along
5
+ // (core/migrate-layout.ts). A repo root holds the project, never the toolkit.
6
+ //
7
+ // Git policy lives in the SK004 block: `.shipkit/*` is ignored, and only the
8
+ // TRACKED children below are re-included. A folder nobody listed is local by
9
+ // default, which is the contract's default too.
10
+
11
+ export const SHIPKIT_DIR = '.shipkit'
12
+
13
+ const sk = (sub: string): string => `${SHIPKIT_DIR}/${sub}`
14
+
15
+ export const LAYOUT = {
16
+ // tracked: authored state
17
+ asks: sk('asks'),
18
+ hooks: sk('hooks'),
19
+ identity: sk('identity'),
20
+ assets: sk('assets'),
21
+ comfy: sk('comfy'),
22
+ // local: drafts, private notes, venue listings until they go live
23
+ private: sk('private'),
24
+ reflections: sk('reflections'),
25
+ social: sk('social'),
26
+ journal: sk('journal'),
27
+ domain: sk('domain'),
28
+ itch: sk('itch'),
29
+ steam: sk('steam'),
30
+ discord: sk('discord'),
31
+ // a review with no output file named by its caller (YYYYMMDD-HHMMSS-<name>.md)
32
+ reviews: sk('reviews'),
33
+ // scratch: plans, logs, leases, detached runs, screenshots
34
+ tmp: sk('tmp'),
35
+ // generated on every run
36
+ status: sk('STATUS.md'),
37
+ statusFull: sk('STATUS.full.md'),
38
+ cache: sk('cache'),
39
+ } as const
40
+
41
+ /** the children the SK004 block re-includes: everything else under `.shipkit/` stays local */
42
+ export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy] as const
43
+
44
+ /** the machine-global private folder, under $HOME: one keyword list and one
45
+ * allowlist per machine, never copied into a repo */
46
+ export const MACHINE_PRIVATE = LAYOUT.private
47
+
48
+ /** where a gate run writes its per task logs unless repo.config says `logDir` */
49
+ export const CI_LOG_DIR = `${LAYOUT.tmp}/shipkit-ci`
50
+
51
+ /** the leak guard's per-repo keyword list */
52
+ export const KEYWORD_FILE = `${LAYOUT.private}/banned-keywords.txt`
@@ -0,0 +1,207 @@
1
+ // THE MIGRATION, as code: what a repo still declares in a shape the check
2
+ // file replaced, and the mechanical rewrite of each one. Check SK016 reports
3
+ // this list and applies these rewrites.
4
+ //
5
+ // Every shape here is READ through 0.40 and refused at the first release
6
+ // after `shipkit fleet` shows no repo below it, so a repo keeps working while
7
+ // it carries them and learns what to write instead on every run.
8
+ //
9
+ // What is mechanical, and what is not, is the whole design of this module. A
10
+ // `checkDefaults` value, a `ruleExempt` entry and a task that is DATA are
11
+ // rewritten without guessing. A legacy check script, a `rules: []` entry and
12
+ // a task whose body is `logic` are not: a script prints and exits in a
13
+ // process of its own, which a check file never does (SK020), and a function
14
+ // closes over the lexical scope of the file it was written in. Those are
15
+ // LISTED by name with what they become, never half rewritten.
16
+ //
17
+ // It reads ONLY through the check context it is handed, so SK016 is cached
18
+ // on the listing of check files and their sources.
19
+ import { basename, join, relative, resolve } from 'path'
20
+ import { fallbackNames, isCheckFilePath, readLegacyCheck, type LegacyCheck } from './check-file-names.ts'
21
+ import type { ScanCtx } from './check-ctx.ts'
22
+ import type { RepoConfig } from '../repo-config.ts'
23
+ import { descriptionProblem, type TaskDef } from '../check.ts'
24
+
25
+ /** the narrow module every rewritten file imports `check` from, and the ONE
26
+ * spelling: the barrel costs 400ms of module load and discovery imports
27
+ * every one of these. A repo that INSTALLS the toolkit resolves it through
28
+ * node_modules, and a repo that hosts the source resolves the same bare
29
+ * specifier through its tsconfig `paths`, which is also what keeps one copy
30
+ * of every shared peer reachable. A nested package carrying its own tsconfig
31
+ * repeats that one mapping. */
32
+ export const CHECK_MODULE = 'rvlib-shipkit/src/check.ts'
33
+
34
+ /** the opening and closing lines of a rewritten check file. A repo that
35
+ * reaches the toolkit only through the global launcher resolves nothing, so
36
+ * it gets the plain object: discovery re-validates it through `check()`
37
+ * either way, and an import it cannot resolve takes every check down. */
38
+ export function checkWrapper(root: string): { head: string[]; open: string; close: string } {
39
+ try {
40
+ Bun.resolveSync(CHECK_MODULE, root)
41
+ return { head: [`import { check } from '${CHECK_MODULE}'`, ''], open: 'export default check({', close: '})' }
42
+ } catch {
43
+ return { head: [], open: 'export default {', close: '}' }
44
+ }
45
+ }
46
+
47
+ export type LegacyCheckFile = {
48
+ /** repo-relative path of the legacy check script */
49
+ rel: string
50
+ /** what the static reader read out of it, verbatim */
51
+ read: LegacyCheck
52
+ }
53
+
54
+ export type LegacyTask = {
55
+ id: string
56
+ /** a task that is pure DATA (`cmd` and its decorations) is rewritten into
57
+ * a file. One whose body is `logic` is a function, so it is hand work */
58
+ data: boolean
59
+ optIn: boolean
60
+ }
61
+
62
+ export type LegacyShapes = {
63
+ checkFiles: LegacyCheckFile[]
64
+ /** ids declared through `repo.config.ts rules: []` */
65
+ rules: string[]
66
+ tasks: LegacyTask[]
67
+ /** the repo sets `checkDefaults`, which lets a file forget `blocking` */
68
+ checkDefaults: boolean
69
+ /** ids exempted through `ruleExempt`, the spelling `when` replaced */
70
+ ruleExempt: string[]
71
+ /** check files still opening with the TODO description the migration
72
+ * wrote. Not a legacy shape, so not in `count`: the file loads and
73
+ * runs, and only its author can write the sentence */
74
+ placeholders: string[]
75
+ /** every shape, counted once */
76
+ count: number
77
+ }
78
+
79
+ /** every check file the repo carries, as discovery would find it, with its
80
+ * source. Listed through `c.list`, so it is what git sees: a gitignored
81
+ * check file is no declaration of this repo */
82
+ export function repoCheckSources(d: Pick<ScanCtx, 'root' | 'list' | 'read'>): { rel: string; abs: string; src: string | null }[] {
83
+ return d.list(`**/*.check.ts`)
84
+ .filter(isCheckFilePath)
85
+ .map((rel) => ({ rel, abs: join(d.root, rel), src: d.read(rel) }))
86
+ }
87
+
88
+ export function legacyShapes(d: Pick<ScanCtx, 'root' | 'list' | 'read'>, c: RepoConfig): LegacyShapes {
89
+ const files = repoCheckSources(d)
90
+ const names = fallbackNames(d.root, files.map((f) => f.abs))
91
+ const checkFiles: LegacyCheckFile[] = []
92
+ for (const f of files) {
93
+ const read = f.src == null ? null : readLegacyCheck(f.abs, names.get(f.abs)!, f.src)
94
+ if (read != null) checkFiles.push({ rel: f.rel, read })
95
+ }
96
+ const rules = (c.rules ?? []).map((r) => r.id)
97
+ const tasks = (c.tasks ?? []).map((t): LegacyTask => ({ id: t.id, data: t.logic == null && t.cmd != null, optIn: t.optIn === true }))
98
+ const ruleExempt = Object.keys(c.ruleExempt ?? {})
99
+ const checkDefaults = c.checkDefaults != null
100
+ const placeholders = files.filter((f) => f.src?.includes(PLACEHOLDER_MARK) === true).map((f) => f.rel)
101
+ return {
102
+ placeholders,
103
+ checkFiles,
104
+ rules,
105
+ tasks,
106
+ checkDefaults,
107
+ ruleExempt,
108
+ count: checkFiles.length + rules.length + tasks.length + (checkDefaults ? 1 : 0) + ruleExempt.length,
109
+ }
110
+ }
111
+
112
+ // ── what a check script becomes ──────────────────────────────────────────
113
+
114
+ /** a TS string literal, escapes and all */
115
+ const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
116
+
117
+ /** what a check file says when no line was given for it. It passes the shape,
118
+ * so the repo keeps running, and it carries the marker SK016 reports, so a
119
+ * human writes the line. */
120
+ export const descriptionPlaceholder = (): string => `${PLACEHOLDER_MARK}, in one line`
121
+
122
+ /** the words every placeholder carries, and what SK016 looks for */
123
+ export const PLACEHOLDER_MARK = 'TODO: state what must be true'
124
+
125
+ /** the hand port of one check script, named for its author. A script prints
126
+ * and exits in a process of its own, and a check file never does (SK020):
127
+ * its prints become calls on `c` inside `run`, which only a reader of that
128
+ * body can write. The declarations it carries are spelled out so the port
129
+ * drops none of them. */
130
+ export function scriptPort(read: LegacyCheck, fallbackBlocking: boolean | undefined): string {
131
+ const blocking = read.blocking ?? fallbackBlocking
132
+ const fields = [
133
+ blocking == null ? 'blocking: <true | false, it declares none>' : `blocking: ${blocking}`,
134
+ ...(read.inputs != null ? [`inputs: [${read.inputs.map(q).join(', ')}]`] : []),
135
+ ...(read.ttl != null ? [`ttl: ${q(read.ttl)}`] : []),
136
+ ...(read.never != null ? [`never: ${q(read.never)}`] : []),
137
+ ...(read.gate ? [] : [`when: 'health'`]),
138
+ ]
139
+ return `a check script, run as its own process: port it by hand into \`export default check({ description, ${fields.join(', ')}, run: (c) => … })\`, each print a call on c (c.ok, c.warn, c.fail)`
140
+ }
141
+
142
+ /** the check file a task's argv runs through bun, `bun <file>` or `bun run
143
+ * <file>`, relative, `./` prefixed or absolute, resolved against the task's
144
+ * cwd. `rest` is what the argv passes after the file: a task that passes its
145
+ * own arguments asks a different question, so it is never folded. */
146
+ export function checkFileRunBy(t: Pick<TaskDef, 'cmd' | 'cwd'>, root: string): { rel: string; rest: string[] } | null {
147
+ const cmd = t.cmd
148
+ if (cmd == null || cmd.length < 2 || basename(cmd[0]!) !== 'bun') return null
149
+ const at = cmd[1] === 'run' ? 2 : 1
150
+ const arg = cmd[at]
151
+ if (arg == null || !arg.endsWith('.check.ts')) return null
152
+ const rel = relative(root, resolve(root, t.cwd ?? '.', arg)).replace(/\\/g, '/')
153
+ if (rel.startsWith('..')) return null
154
+ return { rel, rest: cmd.slice(at + 1) }
155
+ }
156
+
157
+ /** a task that is DATA, as its own check file. `build: true` is what a task
158
+ * always was: the gate runs it and the health pass leaves it out. */
159
+ export function taskCheckSource(t: TaskDef, root: string): string {
160
+ const own = t.description != null && descriptionProblem(t.description) == null ? t.description.trim() : null
161
+ const fields = [`id: ${q(t.id)},`, `description: ${q(own ?? descriptionPlaceholder())},`, 'blocking: true,', 'build: true,', `cmd: [${t.cmd!.map(q).join(', ')}],`]
162
+ if (t.deps != null && t.deps.length > 0) fields.push(`deps: [${t.deps.map(q).join(', ')}],`)
163
+ if (t.inputs != null) fields.push(`inputs: [${t.inputs.map(q).join(', ')}],`)
164
+ if (t.ttl != null) fields.push(`ttl: ${q(t.ttl)},`)
165
+ if (t.never != null) fields.push(`never: ${q(t.never)},`)
166
+ if (t.group != null) fields.push(`group: ${q(t.group)},`)
167
+ if (t.timeoutMs != null) fields.push(`timeoutMs: ${t.timeoutMs},`)
168
+ if (t.env != null) fields.push(`env: { ${Object.entries(t.env).map(([k, v]) => `${q(k)}: ${q(v)}`).join(', ')} },`)
169
+ if (t.cwd != null) fields.push(`cwd: ${q(t.cwd)},`)
170
+ if (t.optIn === true) fields.push(`when: 'health',`)
171
+ const w = checkWrapper(root)
172
+ return [...w.head, w.open, ...fields.map((f) => ` ${f}`), w.close, ''].join('\n')
173
+ }
174
+
175
+ /** a file name for a check whose id carries characters a path should not */
176
+ export const checkFileName = (id: string): string => `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.check.ts`
177
+
178
+ // ── editing repo.config.ts ───────────────────────────────────────────────
179
+
180
+ /** a statement that starts on `i`, ending where its brackets balance and the
181
+ * line is not obviously continued. Deliberately simple: anything it cannot
182
+ * read lands as a hand pass rather than as a half rewritten config. */
183
+ function statementEnd(lines: string[], i: number): number {
184
+ let depth = 0
185
+ for (let j = i; j < lines.length; j++) {
186
+ for (const ch of lines[j]!) {
187
+ if (ch === '(' || ch === '[' || ch === '{') depth++
188
+ else if (ch === ')' || ch === ']' || ch === '}') depth--
189
+ }
190
+ if (depth <= 0) return j
191
+ }
192
+ return lines.length - 1
193
+ }
194
+
195
+ /** the config text without one top level key, its trailing comma and the
196
+ * comment block written above it. Null when the key is not there, or when it
197
+ * appears more than once: a machine that picks one of two is guessing. */
198
+ export function dropConfigKey(text: string, key: string): string | null {
199
+ const lines = text.split('\n')
200
+ const at = lines.map((l, i) => (new RegExp(`^\\s*${key}\\s*:`).test(l) ? i : -1)).filter((i) => i !== -1)
201
+ if (at.length !== 1) return null
202
+ const start = at[0]!
203
+ const end = statementEnd(lines, start)
204
+ let from = start
205
+ while (from > 0 && lines[from - 1]!.trim().startsWith('//')) from--
206
+ return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
207
+ }
@@ -0,0 +1,5 @@
1
+ // the toolkit's own package dir. Its source is keyed by the toolchain id, so a
2
+ // scan of what a repo's code reaches treats it like an install
3
+ import { join } from 'path'
4
+
5
+ export const LIB_ROOT = join(import.meta.dir, '../..')