rvlib-shipkit 1.1.1 → 1.3.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 (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -12,6 +12,7 @@ import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
12
12
  import { poolLines } from './slots.ts'
13
13
  import { runFixPass } from './run-fixers.ts'
14
14
  import { formatLine } from './wire.ts'
15
+ import { outcomeCounts } from './outcome-words.ts'
15
16
  import { writeStatus } from './write-status.ts'
16
17
  import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, type TestTier } from '../ledger/gate-checks.ts'
17
18
  import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
@@ -53,6 +54,8 @@ export type CiOpts = {
53
54
  * own panel, and running them twice per refresh proves the same thing
54
55
  * twice. */
55
56
  buildOnly?: boolean
57
+ /** colour the wire lines (run-check.ts CheckOpts.color) */
58
+ color?: boolean
56
59
  /** line sink — commands pass their context stream; defaults to console */
57
60
  print?: (line: string) => void
58
61
  /** per-CHECK completion tap, handed straight to the runner. The hub passes
@@ -83,6 +86,10 @@ export type CiOpts = {
83
86
  tests?: TestTier
84
87
  /** who started this run, as the run record files it */
85
88
  invokedBy?: InvokedBy
89
+ /** print every line, the ones nobody acts on included: the clean fix
90
+ * pass, the pool, the tier, the cache declarations, the parked checks,
91
+ * the cache counts. STATUS.md always holds them all */
92
+ verbose?: boolean
86
93
  }
87
94
 
88
95
  export type { TestTier } from '../ledger/gate-checks.ts'
@@ -105,10 +112,26 @@ function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
105
112
  return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
106
113
  }
107
114
 
115
+ /** `ok: 12 checks green`, or which ones warned. A whole gate adds its tier
116
+ * and where its answers came from */
117
+ export function verdictLine(results: CheckResult[], whole: { tier: TestTier; cache: { hit: number; miss: number; attached: number; uncacheable: number } | null } | null): string | null {
118
+ const ran = results.filter((r) => r.status !== 'skip').length
119
+ if (ran === 0) return null
120
+ const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory)).map((r) => r.id)
121
+ const head = warned.length === 0 ? `${ran} check${ran === 1 ? '' : 's'} green` : `${ran} check${ran === 1 ? '' : 's'} passed, ${warned.length} with a warning: ${warned.join(', ')}`
122
+ if (whole == null) return `ok: ${head}`
123
+ const c = whole.cache
124
+ const cache = c == null ? '' : `; ${outcomeCounts({ cached: c.hit, ran: c.miss, attached: c.attached, neverCached: c.uncacheable })}`
125
+ return `ok: ${head}; ${whole.tier} tier${cache}`
126
+ }
127
+
108
128
  export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
109
129
  const print = o.print ?? ((l: string) => console.log(l))
110
130
  const collected: string[] = []
111
131
  const sink = (l: string): void => { collected.push(l); print(l) }
132
+ // what the reader never acts on: kept for STATUS.md and --verbose, never
133
+ // printed on a default run, where every line costs a reader tokens
134
+ const detail = (l: string): void => { collected.push(l); if (o.verbose === true) print(l) }
112
135
  let c = o.config ?? (await loadRepoConfig(root))
113
136
  // mechanical failures are not information, so they never cost a round trip
114
137
  let fixFailed = false
@@ -121,7 +144,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
121
144
  // back as a test failure.
122
145
  const narrowed = (o.onlyIds ?? []).length > 0
123
146
  if (o.fix !== false) {
124
- const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })
147
+ const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail })
125
148
  fixFailed = pass.failed
126
149
  // a fixer that rewrote the config or moved a check file leaves the
127
150
  // config above describing a repo that is gone, so the gate reads the
@@ -132,20 +155,22 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
132
155
  return 1
133
156
  }
134
157
  }
135
- } else sink('skip: fix pass (--no-fix)')
158
+ } else detail('skip: fix pass (--no-fix)')
136
159
  const tier: TestTier = o.tests ?? envTier()
137
- // announced BEFORE the run, so a log that died mid-way still says which
138
- // tier it was asking for
139
- sink(tier === 'full'
160
+ // announced BEFORE the run (under --verbose, and in every release log),
161
+ // so a log that died mid-way still says which tier it was asking for
162
+ detail(tier === 'full'
140
163
  ? `ok: test tier FULL — every suite, the slow ones included (${FULL_TESTS_ENV}=1)`
141
164
  : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
142
- for (const l of poolLines()) sink(l)
165
+ for (const l of poolLines()) (l.startsWith('WARN') ? sink : detail)(l)
143
166
  // a floor slot nothing could fill is VISIBLE — a thin gate must never look
144
167
  // like a full one (the fresh-repo floor gap)
145
168
  const floor = await allTasks(c, root)
146
- for (const n of floor.notes ?? []) sink(n)
169
+ // a narrowed run says nothing about the slots it never asked about
170
+ const asked = (id: string): boolean => !narrowed || (o.onlyIds ?? []).includes(id)
171
+ for (const n of floor.notes ?? []) (narrowed ? detail : sink)(n)
147
172
  for (const u of floor.unfilled) {
148
- sink(`skip: ${u.id} (${u.reason})`)
173
+ (asked(u.id) ? sink : detail)(`skip: ${u.id} (${u.reason})`)
149
174
  // asked for BY NAME and unfillable: this is where an agent lands after
150
175
  // the harness refused it the raw runner, so the line says what to add
151
176
  // rather than leaving it with `unknown check id` and no next move
@@ -187,6 +212,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
187
212
  buildOnly: o.buildOnly,
188
213
  bail: o.bail,
189
214
  fresh: o.fresh,
215
+ color: o.color,
190
216
  tier,
191
217
  }
192
218
  try {
@@ -207,7 +233,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
207
233
  // visible as a shrinking undeclared one.
208
234
  const decided = reg.checks - reg.burnDown.length - reg.declared
209
235
  if (reg.burnDown.length > 0 || decided > 0)
210
- sink(`skip: cache declarations — ${reg.declared}/${reg.checks} declared, ${decided} never cached on purpose, ${reg.burnDown.length} undeclared, so those run every time (shipkit keys --burn-down)`)
236
+ detail(`skip: cache declarations — ${reg.declared}/${reg.checks} declared, ${decided} never cached on purpose, ${reg.burnDown.length} undeclared, so those run every time (shipkit keys --burn-down)`)
211
237
  if (ledger.layer.failure != null)
212
238
  sink(`WARN the git scan failed, so NOTHING comes from the cache and nothing is stored this run — ${ledger.layer.failure}`)
213
239
  } catch (e) {
@@ -222,13 +248,14 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
222
248
  onCheck: o.onCheck,
223
249
  reportHome: false,
224
250
  print: sink,
251
+ detail,
225
252
  })
226
253
  // what the suite ACTUALLY ran, from its own headline. A flag that was set
227
254
  // proves the intent; the count proves the tests
228
255
  // proves the intent; the count proves the tests. Every test check counts,
229
256
  // one per unit
230
257
  const counts = run.results.map((r) => /Ran (\d+) tests?/.exec(r.lastLine)?.[1]).filter((n) => n != null).map(Number)
231
- if (counts.length > 0) sink(`ok: test tier ${tier}: ${counts.reduce((a, b) => a + b, 0)} tests ran${counts.length > 1 ? ` in ${counts.length} test checks` : ''}`)
258
+ if (counts.length > 0) detail(`ok: test tier ${tier}: ${counts.reduce((a, b) => a + b, 0)} tests ran${counts.length > 1 ? ` in ${counts.length} test checks` : ''}`)
232
259
  for (const u of run.undeclared) {
233
260
  sink(`FAIL: ${u} does not declare whether it blocks`)
234
261
  sink(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
@@ -245,16 +272,23 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
245
272
  advisoryFatal = true
246
273
  sink(`FAIL: ${run.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${run.advisoryFailures.join(', ')}`)
247
274
  sink(' fix: shipkit check')
248
- } else sink(`WARN ${run.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${run.advisoryFailures.join(', ')}`)
275
+ } else detail(`WARN ${run.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${run.advisoryFailures.join(', ')}`)
249
276
  }
250
277
  // the post-run re-hash, then the store. NOTHING was written while the gate
251
278
  // ran: a check whose inputs moved under it answered about a tree that no
252
279
  // longer exists, and this is where that result is thrown away instead of
253
280
  // stored under a key that describes the file as it was before the edit.
254
- if (ledger != null) for (const l of ledger.header(ledger.commit())) sink(formatLine(l, false))
281
+ // the counts line folds into the verdict; what else it says (a store
282
+ // that failed, answers thrown away) is printed as it is
283
+ if (ledger != null) for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
255
284
  // one write for the whole gate, after everything has finished
256
285
  for (const l of runLog.flush()) sink(formatLine(l, false))
257
286
  const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
287
+ // THE VERDICT, one line and only on a pass: a red run already said so in
288
+ // its FAIL lines. The whole gate adds the tier and the cache counts, which
289
+ // a narrowed run asked no question about
290
+ const verdict = code === 0 && !run.interrupted ? verdictLine(run.results, scope.fullGate ? { tier, cache: ledger?.counts ?? null } : null) : null
291
+ if (verdict != null) sink(verdict)
258
292
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
259
293
  // and never fires under frozen: a bump/release gate must not re-dirty the
260
294
  // tree it just proved clean
@@ -17,6 +17,9 @@ export type FixOpts = {
17
17
  * not wire-shaped, which the frozen output contract forbids */
18
18
  quiet?: boolean
19
19
  print?: (line: string) => void
20
+ /** where a fix that changed nothing reports, since the run after it says
21
+ * the same about that check. Default: print */
22
+ detail?: (line: string) => void
20
23
  /** the config as it stands on disk now, when the caller holds one: a
21
24
  * second load of a rewritten repo.config.ts can hit the module cache */
22
25
  config?: RepoConfig
@@ -75,7 +78,8 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
75
78
  // it is an ok, not a warning. It carries a frozen wire prefix like
76
79
  // every other line the fix pass emits: `shipkit ci` promises its whole
77
80
  // output is parseable, and a bare line breaks that for every reader.
78
- print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
81
+ const say = f.changed ? print : o.detail ?? print
82
+ say(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
79
83
  // a fixer may rewrite repo.config.ts, and the re-run below and every
80
84
  // fixer after it must read what is on disk now, never the config this
81
85
  // pass loaded before the rewrite
@@ -89,7 +93,8 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
89
93
  await runCheckBody(rule, after)
90
94
  // the fix already ran: advertising it again on surviving findings
91
95
  // would send an agent in a loop
92
- for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix \S+$/, '') }, color))
96
+ // inside the ci fix pass the run below reports this check's end state
97
+ for (const l of after.lines) (o.quiet === true ? o.detail ?? print : say)(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix \S+$/, '') }, color))
93
98
  if (after.failed) exit = 1
94
99
  }
95
100
  const unfixable = all.filter((r) => r.fix == null).length
@@ -140,6 +140,9 @@ export type FixPassOpts = {
140
140
  * mutate the tree they are about to ship) */
141
141
  armHooks?: boolean
142
142
  print: (line: string) => void
143
+ /** where the lines nobody acts on go (a clean pass, an unfilled slot).
144
+ * Default: print. `shipkit ci` shows them only under --verbose */
145
+ detail?: (line: string) => void
143
146
  }
144
147
 
145
148
  export type FixPassResult = {
@@ -220,12 +223,13 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
220
223
  if (o.armHooks === true && !verify) {
221
224
  const armed = armHooks(root, config)
222
225
  if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail}), commit the .shipkit/hooks/ dir`)
223
- else if (armed.state === 'foreign' || armed.state === 'not-root') o.print(`skip: hooks (${armed.detail})`)
226
+ else if (armed.state === 'foreign' || armed.state === 'not-root') (o.detail ?? o.print)(`skip: hooks (${armed.detail})`)
224
227
  else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
225
228
  }
229
+ const detail = o.detail ?? o.print
226
230
  const { slots, unfilled, disabled } = resolveFixers(root, config)
227
- for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
228
- for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
231
+ for (const u of unfilled) detail(`skip: ${u.id} (${u.reason})`)
232
+ for (const d of disabled) detail(`skip: ${d.id} (${d.reason})`)
229
233
  const before = treeState(root)
230
234
 
231
235
  for (const s of slots) {
@@ -236,7 +240,7 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
236
240
  o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
237
241
  }
238
242
  // structural repo-config repair is never implicit, and never in verify mode
239
- if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, config })
243
+ if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, detail: o.detail, config })
240
244
 
241
245
  const after = treeState(root)
242
246
  const reshaped = shapeStamp(root) !== shapeBefore
@@ -257,11 +261,11 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
257
261
  }
258
262
  const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
259
263
  if (before == null) {
260
- o.print(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
264
+ detail(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
261
265
  return { ran: true, touched, failed: false, reshaped }
262
266
  }
263
267
  if (touched.length === 0) {
264
- o.print(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
268
+ detail(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
265
269
  return { ran: true, touched, failed: false, reshaped }
266
270
  }
267
271
  if (verify) {
package/src/core/slots.ts CHANGED
@@ -30,6 +30,11 @@ export const poolDir = (): string => {
30
30
  return env != null && env !== '' ? env : join(homedir(), '.cache', 'shipkit', 'slots')
31
31
  }
32
32
 
33
+ /** the TURN the background gates take, one at a time on the whole machine:
34
+ * every batch the plan runs, for every repo. A gate someone asked for
35
+ * (a terminal, a button) never takes it, so it never waits behind one */
36
+ export const autoGatePool = (): SlotPool => ({ dir: `${poolDir()}-auto-gate`, size: 1 })
37
+
33
38
  /** this process's pool, or null when it runs inside a check that holds one */
34
39
  export function machinePool(): SlotPool | null {
35
40
  return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap() }
@@ -0,0 +1,17 @@
1
+ // where a statement of a config file ends, read as text. Pure, so the page
2
+ // can import it too.
3
+
4
+ /** a statement that starts on `i`, ending where its brackets balance and the
5
+ * line is not obviously continued. Deliberately simple: anything it cannot
6
+ * read lands as a hand pass rather than as a half rewritten config. */
7
+ export function statementEnd(lines: string[], i: number): number {
8
+ let depth = 0
9
+ for (let j = i; j < lines.length; j++) {
10
+ for (const ch of lines[j]!) {
11
+ if (ch === '(' || ch === '[' || ch === '{') depth++
12
+ else if (ch === ')' || ch === ']' || ch === '}') depth--
13
+ }
14
+ if (depth <= 0) return j
15
+ }
16
+ return lines.length - 1
17
+ }
package/src/core/wire.ts CHANGED
@@ -49,6 +49,17 @@ export function parseDuration(d: string | number): number {
49
49
  return Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3600_000, d: 86400_000 }[m[2] as 'ms' | 's' | 'm' | 'h' | 'd']
50
50
  }
51
51
 
52
+ /** `ttl: 'forever'`: a DECIDED fact (a repo's visibility, an account that
53
+ * exists). It moves only when someone decides to move it, and that decision
54
+ * is the declared value in its key, so a clean answer never expires */
55
+ export const FOREVER = 'forever'
56
+
57
+ /** what a forever answer keeps for: no clock ends it, no sweep drops it */
58
+ export const FOREVER_MS = Number.MAX_SAFE_INTEGER
59
+
60
+ /** how long a clean answer keeps, from a declared ttl. 0 = none declared */
61
+ export const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === '' ? 0 : ttl === FOREVER ? FOREVER_MS : parseDuration(ttl))
62
+
52
63
  /** absolute local stamp (2026-08-05 09:12:34), never a relative age: a
53
64
  * relative render changes every run and turns committed STATUS.md
54
65
  * checkpoints into noise diffs */
@@ -12,6 +12,7 @@
12
12
  // `shipkit check`, so the fix travels through that one code path.
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
+ import { isArchived } from '../core/archived.ts'
15
16
  import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
16
17
  import { isBreaking } from '../core/update-policy.ts'
17
18
 
@@ -84,7 +85,7 @@ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[
84
85
  continue
85
86
  }
86
87
  const pkg = readPkg(dir)
87
- if (pkg == null) continue
88
+ if (pkg == null || isArchived(dir)) continue
88
89
  const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
89
90
  const names = Object.keys(all).filter((n) => n.startsWith(prefix))
90
91
  if (names.length === 0) continue
@@ -0,0 +1,135 @@
1
+ // the activity page's shapes and its few pure rules, apart from the git
2
+ // reader so the page bundle takes them without node's modules. The words
3
+ // here are what the page and `shipkit cross-repo-activity` both print.
4
+ import type { CheckState } from '../serve/project-gutter.ts'
5
+
6
+ /** the windows the page offers, in minutes */
7
+ export const WINDOW_MINUTES = [10, 30, 60, 180, 720, 1440, 4320, 10080] as const
8
+ export const DEFAULT_MINUTES = 30
9
+ /** the most commits the daemon lists per repo, and the most uncommitted paths it names */
10
+ export const MAX_COMMITS = 100
11
+ export const MAX_FILES = 80
12
+
13
+ export type FileKind = 'staged' | 'modified' | 'untracked'
14
+
15
+ export type ActivityCommit = {
16
+ sha: string
17
+ /** committer time, epoch ms */
18
+ at: number
19
+ title: string
20
+ author: string
21
+ /** the branch it sits on when it is not on the checked out one, else null */
22
+ branch: string | null
23
+ /** a remote holds it; false on a branch with no upstream */
24
+ pushed: boolean
25
+ }
26
+
27
+ export type ActivityRow = {
28
+ name: string
29
+ /** the checked out branch; null when detached (see `detached`) */
30
+ branch: string | null
31
+ /** the short sha HEAD sits on when detached */
32
+ detached: string | null
33
+ /** the upstream as configured, null when the branch has none */
34
+ upstream: string | null
35
+ /** the upstream is configured but no longer exists */
36
+ gone: boolean
37
+ /** commits ahead of and behind the upstream, null without one */
38
+ ahead: number | null
39
+ behind: number | null
40
+ counts: Record<FileKind, number>
41
+ /** the uncommitted paths, at most MAX_FILES; a path both staged and
42
+ * modified is listed once, as modified */
43
+ files: { kind: FileKind; path: string }[]
44
+ /** in the window, newest first */
45
+ commits: ActivityCommit[]
46
+ /** HEAD's latest commit when nothing was committed in the window, shown
47
+ * grey; null when the window holds a commit, or the repo has none */
48
+ last: ActivityCommit | null
49
+ /** its checks as its run record last saw them, only the ids it declares
50
+ * today when that is known; null when shipkit never ran there */
51
+ status: CheckState
52
+ /** the checks behind a red or orange status, failing first: what the
53
+ * status column lists on hover and on click */
54
+ failing?: StatusCheck[]
55
+ /** why git could not answer, else null */
56
+ error: string | null
57
+ }
58
+
59
+ /** one failing or warning check as the run record last saw it */
60
+ export type StatusCheck = {
61
+ id: string
62
+ /** its title as the repo declares it, else its id */
63
+ title: string
64
+ status: 'fail' | 'warn'
65
+ /** when that outcome was recorded, epoch ms */
66
+ at: number | null
67
+ /** the first line it printed about the problem, '' when it printed none */
68
+ line: string
69
+ }
70
+
71
+ export type ActivityAnswer = { now: number; minutes: number; rows: ActivityRow[]; ms: number }
72
+
73
+ export type ActivityRepo = { name: string; path: string }
74
+
75
+ /** the window a request names, when it is one the page offers */
76
+ export function windowMinutes(v: string | null): number {
77
+ const n = Number(v)
78
+ return (WINDOW_MINUTES as readonly number[]).includes(n) ? n : DEFAULT_MINUTES
79
+ }
80
+
81
+ /** every house repo ships from main: shown green, any other orange, a
82
+ * leftover master included so the repo still to rename stands out */
83
+ export const isMainBranch = (b: string | null): boolean => b === 'main'
84
+
85
+ /** nothing committed in the window and nothing uncommitted: it sits below the busy repos */
86
+ export const idleRow = (r: ActivityRow): boolean =>
87
+ r.error == null && r.commits.length === 0 && r.counts.staged + r.counts.modified + r.counts.untracked === 0
88
+
89
+ /** the most commits a repo shows: a number from CAP_MIN to CAP_MAX */
90
+ export const CAP_MIN = 1
91
+ export const CAP_MAX = 15
92
+ export const DEFAULT_CAP = 3
93
+
94
+ /** a window's label on the toggle: `10m`, `3h`, `3d` */
95
+ export const windowWords = (m: number): string => (m < 60 ? `${m}m` : m < 4320 ? `${m / 60}h` : `${m / 1440}d`)
96
+
97
+ /** a typed cap, as a whole number in CAP_MIN..CAP_MAX; the default when it is no number */
98
+ export function clampCap(v: unknown): number {
99
+ const n = Math.round(Number(v))
100
+ return Number.isFinite(n) && String(v).trim() !== '' ? Math.min(CAP_MAX, Math.max(CAP_MIN, n)) : DEFAULT_CAP
101
+ }
102
+
103
+ /** an age in one short unit: `now`, `4m`, `2h`, `3d` */
104
+ export function ageShort(at: number, now: number): string {
105
+ const s = Math.max(0, Math.round((now - at) / 1000))
106
+ if (s < 60) return 'now'
107
+ if (s < 3600) return `${Math.floor(s / 60)}m`
108
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
109
+ return `${Math.floor(s / 86400)}d`
110
+ }
111
+
112
+ /** the uncommitted work in one line: `3 modified, 1 untracked`; '' when clean */
113
+ export function dirtWords(r: ActivityRow): string {
114
+ const c = r.counts
115
+ return [c.staged > 0 ? `${c.staged} staged` : '', c.modified > 0 ? `${c.modified} modified` : '', c.untracked > 0 ? `${c.untracked} untracked` : '']
116
+ .filter((x) => x !== '')
117
+ .join(', ')
118
+ }
119
+
120
+ /** where the branch stands against its upstream, for the branch's hover */
121
+ export function upstreamWords(r: ActivityRow): string {
122
+ if (r.upstream == null) return 'no upstream: nothing on this branch is pushed'
123
+ if (r.gone) return `${r.upstream} is gone`
124
+ const a = r.ahead ?? 0
125
+ const b = r.behind ?? 0
126
+ if (a === 0 && b === 0) return `even with ${r.upstream}`
127
+ return [a > 0 ? `${a} unpushed` : '', b > 0 ? `${b} behind` : ''].filter((x) => x !== '').join(', ') + ` against ${r.upstream}`
128
+ }
129
+
130
+ /** the uncommitted paths, one per line, for the hover */
131
+ export const filesWords = (r: ActivityRow): string => {
132
+ const mark = { staged: 'S', modified: 'M', untracked: '?' } as const
133
+ const shown = r.files.map((f) => `${mark[f.kind]} ${f.path}`)
134
+ return (r.files.length >= MAX_FILES ? [...shown, '…'] : shown).join('\n')
135
+ }