rvlib-shipkit 1.2.0 → 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 (138) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +20 -6
  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 +6 -2
  18. package/src/checks/SK024-identity-colors.check.ts +5 -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_Ci.ts +5 -0
  25. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  26. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  27. package/src/cli/cmd_Keys.ts +17 -3
  28. package/src/cli/cmd_Overview.ts +98 -0
  29. package/src/cli/cmd_Planned.ts +117 -0
  30. package/src/cli.ts +9 -1
  31. package/src/collections/checks.ts +3 -2
  32. package/src/collections/providers.ts +1 -1
  33. package/src/collections/test-time.ts +10 -1
  34. package/src/core/agent-spawn.ts +46 -0
  35. package/src/core/archived.ts +82 -0
  36. package/src/core/canonical-scripts.ts +12 -3
  37. package/src/core/check-runner.ts +40 -12
  38. package/src/core/discover-checks.ts +2 -0
  39. package/src/core/file-stamp.ts +20 -0
  40. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  41. package/src/core/identity-color.ts +1 -1
  42. package/src/core/repo-facts.ts +14 -0
  43. package/src/core/retention.ts +14 -5
  44. package/src/core/retired-checks.ts +7 -0
  45. package/src/core/run-check.ts +6 -2
  46. package/src/core/run-ci.ts +43 -12
  47. package/src/core/run-fix.ts +7 -2
  48. package/src/core/run-fixers.ts +10 -6
  49. package/src/core/wire.ts +11 -0
  50. package/src/fleet/scan-fleet.ts +2 -1
  51. package/src/home/activity-shape.ts +135 -0
  52. package/src/home/activity.ts +249 -0
  53. package/src/home/global-units.ts +3 -2
  54. package/src/home/home-routes.ts +32 -5
  55. package/src/home/pm.ts +37 -4
  56. package/src/home/process-rows.ts +205 -0
  57. package/src/home/resources.ts +35 -8
  58. package/src/home/run-daemon.ts +6 -6
  59. package/src/home/run-pm.ts +1 -0
  60. package/src/home/serve-web.ts +6 -3
  61. package/src/home/supervise.ts +16 -1
  62. package/src/home/widget-text.ts +119 -0
  63. package/src/hooks/run-hooks.ts +20 -3
  64. package/src/ledger/db.ts +1 -1
  65. package/src/ledger/gate-checks.ts +17 -3
  66. package/src/ledger/house-runs.ts +43 -0
  67. package/src/ledger/live-timeline.ts +4 -19
  68. package/src/ledger/run-log.ts +39 -10
  69. package/src/ledger/store.ts +19 -2
  70. package/src/ledger/world-cache.ts +51 -4
  71. package/src/plan/cadence.ts +21 -5
  72. package/src/plan/catch-up.ts +37 -0
  73. package/src/plan/plan-timeline.ts +99 -0
  74. package/src/plan/planner.ts +138 -13
  75. package/src/plan/repo-source.ts +120 -8
  76. package/src/release/run-release.ts +9 -2
  77. package/src/release/unit-resolve.ts +8 -12
  78. package/src/repo-config.ts +11 -3
  79. package/src/serve/house-runs.ts +53 -0
  80. package/src/serve/hub-app/activity-st.ts +168 -0
  81. package/src/serve/hub-app/activity-view.tsx +218 -0
  82. package/src/serve/hub-app/activity.css +78 -0
  83. package/src/serve/hub-app/admin-svc.ts +41 -0
  84. package/src/serve/hub-app/admin-view.tsx +42 -0
  85. package/src/serve/hub-app/chart-view.ts +12 -5
  86. package/src/serve/hub-app/check-words.ts +2 -0
  87. package/src/serve/hub-app/controls.css +32 -0
  88. package/src/serve/hub-app/cycle-button.tsx +36 -0
  89. package/src/serve/hub-app/dock-anim.ts +25 -0
  90. package/src/serve/hub-app/dock-st.ts +329 -0
  91. package/src/serve/hub-app/dock-view.tsx +68 -0
  92. package/src/serve/hub-app/dock.css +35 -0
  93. package/src/serve/hub-app/hash-route.ts +7 -1
  94. package/src/serve/hub-app/header-bar.tsx +79 -104
  95. package/src/serve/hub-app/home-st.ts +49 -12
  96. package/src/serve/hub-app/home-strip.css +65 -22
  97. package/src/serve/hub-app/home-strip.tsx +228 -97
  98. package/src/serve/hub-app/home.html +4 -0
  99. package/src/serve/hub-app/hub-app.tsx +96 -160
  100. package/src/serve/hub-app/hub.css +77 -37
  101. package/src/serve/hub-app/icons.tsx +23 -3
  102. package/src/serve/hub-app/index.html +4 -0
  103. package/src/serve/hub-app/keys.ts +42 -7
  104. package/src/serve/hub-app/live-svc.ts +24 -64
  105. package/src/serve/hub-app/machine-st.ts +64 -1
  106. package/src/serve/hub-app/md.ts +38 -5
  107. package/src/serve/hub-app/next-run.ts +1 -1
  108. package/src/serve/hub-app/palette-st.ts +11 -7
  109. package/src/serve/hub-app/palette-view.tsx +47 -0
  110. package/src/serve/hub-app/pill-row.tsx +65 -0
  111. package/src/serve/hub-app/planned-st.ts +21 -85
  112. package/src/serve/hub-app/planned-view.tsx +12 -7
  113. package/src/serve/hub-app/planned.css +5 -2
  114. package/src/serve/hub-app/project-list.css +43 -21
  115. package/src/serve/hub-app/project-list.tsx +122 -23
  116. package/src/serve/hub-app/projects-svc.ts +120 -12
  117. package/src/serve/hub-app/readme-svc.ts +38 -0
  118. package/src/serve/hub-app/readme-view.tsx +19 -0
  119. package/src/serve/hub-app/readme.css +11 -0
  120. package/src/serve/hub-app/repo-sections.tsx +61 -0
  121. package/src/serve/hub-app/repo-st.ts +25 -6
  122. package/src/serve/hub-app/runs-tabs.ts +1 -1
  123. package/src/serve/hub-app/sidebar.tsx +84 -0
  124. package/src/serve/hub-app/tests-view.tsx +31 -4
  125. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  126. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  127. package/src/serve/hub-app/timeline-svc.ts +2 -2
  128. package/src/serve/hub-app/timeline-view.tsx +20 -3
  129. package/src/serve/hub-runtime.ts +17 -9
  130. package/src/serve/other-repos.ts +8 -45
  131. package/src/serve/project-gutter-route.ts +100 -20
  132. package/src/serve/project-gutter.ts +33 -11
  133. package/src/serve/readme-route.ts +57 -0
  134. package/src/serve/serve-hub.ts +13 -8
  135. package/src/serve/tracked-watch.ts +33 -1
  136. package/src/shipkit-files.ts +20 -4
  137. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  138. package/src/checks/contracts.check.ts +0 -20
@@ -8,7 +8,8 @@
8
8
  import { createHash } from 'crypto'
9
9
  import { Command, Option } from 'clipanion'
10
10
  import { loadRepoConfig } from '../repo-config.ts'
11
- import { envTier, FULL_TESTS_ENV, gateChecks } from '../ledger/gate-checks.ts'
11
+ import { envTier, FULL_TESTS_ENV, gateInventory } from '../ledger/gate-checks.ts'
12
+ import { planGap, plannable } from '../plan/repo-source.ts'
12
13
  import { KeyLayer } from '../ledger/git-key.ts'
13
14
  import { Ledger } from '../ledger/ledger.ts'
14
15
  import { gateBinary } from '../ledger/toolchain-identity.ts'
@@ -42,14 +43,22 @@ export class KeysCommand extends Command {
42
43
  // every task's env, which is in the key, so a surface that renders keys
43
44
  // without it renders a tier nobody asked for
44
45
  const tier = this.full ? 'full' : envTier()
45
- const decls = await gateChecks(root, c, { tier })
46
+ const rows = await gateInventory(root, c, { tier })
47
+ const decls = rows.map((r) => r.decl)
48
+ // the plan's side of the burn-down: a check nothing keys and that
49
+ // declares no cadence is re-asked on a guess. A built-in there is a
50
+ // toolkit bug, a repo check rides the default range until it declares
51
+ const planGaps = rows.filter(plannable).flatMap((r) => {
52
+ const gap = planGap(r)
53
+ return gap == null ? [] : [{ id: r.decl.id, from: r.from, gap }]
54
+ })
46
55
  const layer = KeyLayer.open(root, decls, { cliEntry: gateBinary() })
47
56
  const keys = layer.keys()
48
57
  const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
49
58
 
50
59
  if (this.json) {
51
60
  const drift = this.rescan ? layer.rescan() : null
52
- out(JSON.stringify({ tier, checks: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
61
+ out(JSON.stringify({ tier, checks: keys, burnDown: layer.registration.burnDown, planGaps, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
53
62
  return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
54
63
  }
55
64
 
@@ -115,6 +124,11 @@ export class KeysCommand extends Command {
115
124
  const decided = new Set(layer.decls.filter((d) => d.never != null && d.never !== '').map((d) => d.id))
116
125
  const shown = this.burnDown ? keys.filter((k) => k.population === 'none' && !decided.has(k.id)) : keys
117
126
  for (const k of shown) out(`${k.population.padEnd(7)} ${k.id.padEnd(28)} ${k.key ?? `— ${k.reason ?? ''}`}`)
127
+ if (planGaps.length > 0) out('')
128
+ for (const g of planGaps)
129
+ out(g.from === 'repo'
130
+ ? `WARN ${g.id}: ${g.gap}, so the plan runs it every 20h..28h. Declare inputs, a cacheKey, ttl or every`
131
+ : `FAIL: built-in ${g.id}: ${g.gap}. A toolkit bug: the plan waits for a save until it declares one`)
118
132
 
119
133
  const content = keys.filter((k) => k.population === 'content').length
120
134
  const reads = keys.filter((k) => k.population === 'reads').length
@@ -0,0 +1,98 @@
1
+ // shipkit overview: the overview page in the console, top to bottom as the
2
+ // page: the monitor widgets as the daemon last rendered them, their html read
3
+ // as text by widget-text.ts, then the processes through
4
+ // globalProcessesAnswer (the rows `shipkit gp` prints: pm, the daemon and the
5
+ // web first, then the global processes). Nothing renders a widget here: the
6
+ // daemon holds the render, and while it is down the monitor is unknown.
7
+ import { Command, Option } from 'clipanion'
8
+ import { daemonUrl } from '../home/home-paths.ts'
9
+ import type { WidgetsAnswer } from '../home/home-routes.ts'
10
+ import type { ChildView, PmAnswer } from '../home/pm.ts'
11
+ import { splitWidgetHead, widgetText } from '../home/widget-text.ts'
12
+ import { askDaemon, askPm, globalProcessesAnswer, localProcessDeps, renderGlobalProcesses, type GlobalProcessesAnswer, type GlobalProcessesDeps } from './cmd_GlobalProcesses.ts'
13
+
14
+ export type OverviewAnswer = {
15
+ now: number
16
+ /** pm as it answered: its own pid and version, its children; null when it did not */
17
+ pm: { pid: number; since: number | null; version: string; children: ChildView[] } | null
18
+ /** the daemon's identity, asked of the daemon itself; null when it does not answer */
19
+ daemon: { version: string } | null
20
+ /** the global processes, exactly as `shipkit global-processes --json` */
21
+ processes: GlobalProcessesAnswer
22
+ /** the daemon's widgets route payload; null when the daemon does not answer */
23
+ widgets: WidgetsAnswer | null
24
+ }
25
+
26
+ export type OverviewDeps = Pick<GlobalProcessesDeps, 'declared' | 'order'> & {
27
+ pm: () => Promise<PmAnswer | null>
28
+ daemon: () => Promise<{ version: string } | null>
29
+ widgets: () => Promise<WidgetsAnswer | null>
30
+ now: number
31
+ }
32
+
33
+ async function getJson<T>(url: string): Promise<T | null> {
34
+ try {
35
+ const r = await fetch(url, { signal: AbortSignal.timeout(3_000) })
36
+ return r.ok ? ((await r.json()) as T) : null
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ /** the daemon's widgets route payload, or null */
43
+ export const askWidgets = (url = daemonUrl()): Promise<WidgetsAnswer | null> => getJson<WidgetsAnswer>(`${url}/api/home/widgets.json`)
44
+
45
+ export async function overviewAnswer(d: OverviewDeps): Promise<OverviewAnswer> {
46
+ const [pm, daemon, widgets] = await Promise.all([d.pm(), d.daemon(), d.widgets()])
47
+ const processes = await globalProcessesAnswer({ pm: async () => pm, daemon: async () => daemon, declared: d.declared, order: d.order, now: d.now })
48
+ return { now: d.now, pm: pm == null ? null : { pid: pm.pid, since: pm.since ?? null, version: pm.version, children: pm.children }, daemon, processes, widgets }
49
+ }
50
+
51
+ const indent = (lines: string[], by = ' '): string[] => lines.map((l) => by + l)
52
+
53
+ /** the page as text: the monitor, then the processes (shipkit's own first),
54
+ * each section saying what is unknown and why when pm or the daemon does not
55
+ * answer */
56
+ export function renderOverview(a: OverviewAnswer): string[] {
57
+ const out: string[] = ['monitor']
58
+ if (a.widgets == null) out.push(` the daemon does not answer on ${daemonUrl()}, and it renders the widgets: the monitor is unknown`)
59
+ else {
60
+ if (a.widgets.widgets.length === 0 && a.widgets.errors.length === 0) out.push(' no widget declared: a repo declares one in a <name>.shipkit.ts file, as export default widget({...})')
61
+ for (const x of a.widgets.widgets) {
62
+ const { head, body } = splitWidgetHead(x.html ?? '')
63
+ out.push(` ${x.title}${head == null ? '' : ` · ${widgetText(head).join(' ')}`}`)
64
+ if (x.error != null) out.push(` WARN ${x.error}`)
65
+ if (x.html == null) out.push(' not rendered yet')
66
+ else out.push(...indent(widgetText(body), ' '))
67
+ }
68
+ for (const e of a.widgets.errors) out.push(` WARN ${e}`)
69
+ }
70
+ out.push('processes', ...indent(renderGlobalProcesses(a.processes)))
71
+ if (a.daemon == null) out.push(` the daemon does not answer on ${daemonUrl()}`)
72
+ return out
73
+ }
74
+
75
+ export class OverviewCommand extends Command {
76
+ static override paths = [['overview']]
77
+
78
+ static override usage = Command.Usage({
79
+ category: 'Home',
80
+ description: 'the overview page in the console: every monitor widget as text, from the html the daemon last rendered, then the processes as `shipkit gp` prints them (pm, the daemon and the web with pid, uptime and version, then every global process). When pm or the daemon does not answer, it says which and prints what is still known. Always exits 0',
81
+ examples: [
82
+ ['the overview', 'shipkit overview'],
83
+ ['the answers the page reads, as json', 'shipkit overview --json'],
84
+ ],
85
+ })
86
+
87
+ json = Option.Boolean('--json', false, { description: 'the answers as json: { now, pm, daemon, processes, widgets }, processes as `shipkit gp --json`, widgets the daemon route\'s payload' })
88
+
89
+ async execute(): Promise<number> {
90
+ const a = await overviewAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), widgets: () => askWidgets(), now: Date.now() })
91
+ if (this.json) {
92
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
93
+ return 0
94
+ }
95
+ for (const l of renderOverview(a)) this.context.stdout.write(`${l}\n`)
96
+ return 0
97
+ }
98
+ }
@@ -0,0 +1,117 @@
1
+ // shipkit planned: the planned page in the console. The plan lives in the
2
+ // daemon (its marks, the running batch, the catch up spread all exist only
3
+ // there), so this asks the daemon's route, the one the page polls, and only
4
+ // renders it: the order and every word come from plan-timeline.ts, the page's
5
+ // own functions. Nothing is computed from disk: a plan rebuilt here would load
6
+ // every repo's checks and still not know what the daemon's plan holds.
7
+ import { Command, Option } from 'clipanion'
8
+ import { daemonUrl } from '../home/home-paths.ts'
9
+ import type { PlannedAnswer } from '../home/home-routes.ts'
10
+ import { markOf, spanShort, timeline, titleWords, waitingRows, waitingWords, whenWords, type Section } from '../plan/plan-timeline.ts'
11
+ import type { PlanRow } from '../plan/planner.ts'
12
+
13
+ /** the daemon's planned answer, or null when it does not answer */
14
+ export async function askPlanned(url = daemonUrl()): Promise<PlannedAnswer | null> {
15
+ try {
16
+ const r = await fetch(`${url}/api/home/planned.json`, { signal: AbortSignal.timeout(8_000) })
17
+ if (!r.ok) return null
18
+ return (await r.json()) as PlannedAnswer
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ /** the answer for one repo: its rows and its load error only */
25
+ export function onlyRepo(a: PlannedAnswer, repo: string): PlannedAnswer {
26
+ return { ...a, rows: a.rows.filter((r) => r.repo === repo), errors: a.errors.filter((e) => e.repo === repo) }
27
+ }
28
+
29
+ const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
30
+
31
+ const lastWords = (r: PlanRow, now: number): string =>
32
+ r.last == null ? 'never ran' : `last ${r.last.status} ${spanShort(Math.max(0, now - r.last.at))} ago${r.last.streak > 1 ? ` (${r.last.streak} in a row)` : ''}`
33
+
34
+ const SECTION_WORDS: Record<Section, string> = { now: 'now', next: 'next' }
35
+
36
+ /** the page's one list as text: its three sections in its order, a line per
37
+ * row (when, repo, check and title, mark, last result) and the rule under
38
+ * it; the checks that only wait for a save are counted, or listed with `all` */
39
+ export function renderPlanned(a: PlannedAnswer, o: { all?: boolean } = {}): string[] {
40
+ const out: string[] = [...(a.held ?? [])]
41
+ const list = timeline(a.rows)
42
+ const idle = a.rows.filter((r) => r.state === 'idle')
43
+ const w = {
44
+ when: Math.max(0, ...list.map((x) => whenWords(x.row, a.now).length)),
45
+ what: Math.max(0, ...list.map((x) => `${x.row.repo} ${x.row.check}`.length)),
46
+ }
47
+ let prev: Section | null = null
48
+ for (const { section, row } of list) {
49
+ if (section !== prev) {
50
+ out.push(SECTION_WORDS[section])
51
+ prev = section
52
+ }
53
+ const m = markOf(row)
54
+ const title = titleWords(row.title, row.check)
55
+ const cells = [` ${pad(whenWords(row, a.now), w.when)}`, pad(`${row.repo} ${row.check}`, w.what), m.words, lastWords(row, a.now)]
56
+ out.push(cells.filter((c) => c !== '').join(' '))
57
+ out.push(` ${title == null ? '' : `${title} · `}${row.rule}`)
58
+ if (row.error != null) out.push(` error: ${row.error}`)
59
+ }
60
+ if (list.length === 0) out.push(a.rows.length === 0 ? 'nothing is planned' : 'nothing has a time of its own')
61
+ const waiting = waitingRows(a.rows)
62
+ if (waiting.length > 0) {
63
+ out.push(`${waitingWords(a.rows)}${o.all === true ? '' : ' (--all lists them)'}`)
64
+ if (o.all === true) for (const r of waiting) out.push(` ${r.repo} ${r.check} ${lastWords(r, a.now)} · ${r.rule}`)
65
+ }
66
+ if (o.all === true && idle.length > 0) {
67
+ out.push('on a save only')
68
+ for (const r of [...idle].sort((x, y) => x.repo.localeCompare(y.repo) || x.check.localeCompare(y.check))) out.push(` ${r.repo} ${r.check} ${lastWords(r, a.now)} · ${r.rule}`)
69
+ }
70
+ for (const e of a.errors) out.push(`WARN ${e.repo}: ${e.error}`)
71
+ const n = { now: list.filter((x) => x.section === 'now').length, next: list.filter((x) => x.section === 'next').length }
72
+ out.push(`${n.now} now, ${n.next} next, ${waiting.length} waiting, ${idle.length} on a save only${o.all === true || idle.length === 0 ? '' : ' (--all lists them)'}`)
73
+ return out
74
+ }
75
+
76
+ export class PlannedCommand extends Command {
77
+ static override paths = [['planned']]
78
+
79
+ static override usage = Command.Usage({
80
+ category: 'Home',
81
+ description: 'the planned page in the console: every check the machine runs on its own time, running now, then next, each with its repo, check and title, when, the rule that planned it and its last result. Asks the daemon, which holds the plan. With the daemon down it says so and exits 0',
82
+ examples: [
83
+ ['the plan, as the page lists it', 'shipkit planned'],
84
+ ['one repo only', 'shipkit planned --repo my-app'],
85
+ ['also the checks that only a save runs', 'shipkit planned --all'],
86
+ ['the route\'s exact payload', 'shipkit planned --json'],
87
+ ],
88
+ })
89
+
90
+ json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, rows, errors, running, held }' })
91
+ repo = Option.String('--repo', { required: false, description: 'only this repo\'s rows' })
92
+ all = Option.Boolean('--all', false, { description: 'also list the checks that only a save runs (the page leaves them out)' })
93
+
94
+ async execute(): Promise<number> {
95
+ const got = await askPlanned()
96
+ if (got == null) {
97
+ const why = `the daemon does not answer on ${daemonUrl()}, and the plan lives there: no plan is known (\`shipkit pm\` starts it)`
98
+ if (this.json) {
99
+ this.context.stdout.write('null\n')
100
+ this.context.stderr.write(`${why}\n`)
101
+ } else this.context.stdout.write(`${why}\n`)
102
+ return 0
103
+ }
104
+ const a = this.repo == null ? got : onlyRepo(got, this.repo)
105
+ if (this.json) {
106
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
107
+ return 0
108
+ }
109
+ if (this.repo != null && a.rows.length === 0 && a.errors.length === 0) {
110
+ const known = [...new Set(got.rows.map((r) => r.repo))].sort()
111
+ this.context.stdout.write(`no planned check in ${this.repo}; the plan holds ${known.join(', ') || 'no repo'}\n`)
112
+ return 0
113
+ }
114
+ for (const l of renderPlanned(a, { all: this.all })) this.context.stdout.write(`${l}\n`)
115
+ return 0
116
+ }
117
+ }
package/src/cli.ts CHANGED
@@ -9,7 +9,11 @@ import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenComma
9
9
  import { BumpCommand } from './cli/cmd_Bump.ts'
10
10
  import { CheckCommand } from './cli/cmd_Check.ts'
11
11
  import { CommandsCommand } from './cli/cmd_Commands.ts'
12
+ import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
12
13
  import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
14
+ import { GlobalProcessesCommand } from './cli/cmd_GlobalProcesses.ts'
15
+ import { PlannedCommand } from './cli/cmd_Planned.ts'
16
+ import { OverviewCommand } from './cli/cmd_Overview.ts'
13
17
  import { CiCommand } from './cli/cmd_Ci.ts'
14
18
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
15
19
  import { FleetCommand } from './cli/cmd_Fleet.ts'
@@ -86,6 +90,10 @@ export function buildCli(): Cli {
86
90
  cli.register(SmokeCommand)
87
91
  cli.register(ReflectCommand)
88
92
  cli.register(KeysCommand)
93
+ cli.register(GlobalProcessesCommand)
94
+ cli.register(CrossRepoActivityCommand)
95
+ cli.register(PlannedCommand)
96
+ cli.register(OverviewCommand)
89
97
  cli.register(ChecksCommand)
90
98
  cli.register(UnitsAliasCommand)
91
99
  cli.register(CommandsCommand)
@@ -100,7 +108,7 @@ export function buildCli(): Cli {
100
108
  // this one (one-lib.ts), so the machine's version runs in every repo. Then pm
101
109
  // is started when it is down (autostart.ts), before the command runs
102
110
  if (import.meta.main) {
103
- const argv = process.argv.slice(2)
111
+ const argv = aliasArgs(process.argv.slice(2))
104
112
  const { handoff } = await import('./home/handoff.ts')
105
113
  handoff(argv)
106
114
  const { useOneLib } = await import('./core/one-lib.ts')
@@ -18,7 +18,7 @@ import { homedir } from 'os'
18
18
  import { isAbsolute, join, relative, resolve } from 'path'
19
19
  import { rawName, routesToShipkit } from '../core/canonical-scripts.ts'
20
20
  import { FLOOR_IDS } from '../core/floor.ts'
21
- import { formatLine, parseDuration } from '../core/wire.ts'
21
+ import { FOREVER_MS, formatLine, ttlMsOf } from '../core/wire.ts'
22
22
  import { gateInventory, type CheckRow } from '../ledger/gate-checks.ts'
23
23
  import { readLastLines, readRuns, runsPath, type LastLine, type RunRow } from '../ledger/run-log.ts'
24
24
  import { checkStatsFor, type CheckStat } from '../ledger/check-stats.ts'
@@ -166,7 +166,8 @@ function instanceOf(root: string, c: RepoConfig, row: CheckRow, stat: CheckStat
166
166
  export function keepsUntil(ttl: string | undefined, stat: Pick<CheckStat, 'measuredAt' | 'measuredStatus'> | undefined): number | null {
167
167
  if (ttl == null || ttl === '' || stat?.measuredAt == null) return null
168
168
  let ms = 0
169
- try { ms = parseDuration(ttl) } catch { return null }
169
+ try { ms = ttlMsOf(ttl) } catch { return null }
170
+ if (ms >= FOREVER_MS && stat.measuredStatus === 'pass') return null
170
171
  if (ms <= 0) return null
171
172
  return stat.measuredAt + (stat.measuredStatus === 'pass' ? ms : Math.min(ms, NEGATIVE_TTL_CAP_MS))
172
173
  }
@@ -31,7 +31,7 @@ import { testsCollection } from './tests.ts'
31
31
  import { checksCollection } from './checks.ts'
32
32
  import { LAYOUT } from '../core/layout.ts'
33
33
  import { isBudgetLine } from '../core/budget.ts'
34
- import { GH_LOGIN_CRED } from '../checks/SK021-github-login.check.ts'
34
+ import { GH_LOGIN_CRED } from '../core/gh-login.ts'
35
35
 
36
36
  const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? LAYOUT.asks)
37
37
  // read path must not mint an asks/ dir (listAsks mkdirs on read)
@@ -127,9 +127,15 @@ export type TestCheckRow = {
127
127
  shape: Shape | null
128
128
  }
129
129
 
130
+ /** one file of any test check, named with the check that runs it */
131
+ export type TestFileRow = FileTime & { check: string }
132
+
130
133
  export type TestsRootView = {
131
134
  /** slowest wall first, the never ran ones last */
132
135
  checks: TestCheckRow[]
136
+ /** every file of every test check, slowest first, the never ran ones
137
+ * last: each check's own list, merged */
138
+ files: TestFileRow[]
133
139
  failing: FailingFile[]
134
140
  }
135
141
 
@@ -270,8 +276,10 @@ function lastRunOf(checkId: string, at: number, files: FileTime[], sumMs: number
270
276
 
271
277
  export function shapeTestsRoot(o: TestTimeInput): TestsRootView {
272
278
  const ids = [...new Set(o.facts.filter((f) => f.standing === 'runs' && f.checkId != null).map((f) => f.checkId!))]
279
+ const files: TestFileRow[] = []
273
280
  const checks = ids.map((checkId): TestCheckRow => {
274
281
  const v = shapeTestCheck(checkId, o)
282
+ for (const f of v.files) files.push({ ...f, check: checkId })
275
283
  const top = v.files.find((f) => f.ms != null)
276
284
  return {
277
285
  checkId,
@@ -284,5 +292,6 @@ export function shapeTestsRoot(o: TestTimeInput): TestsRootView {
284
292
  shape: v.last?.shape ?? null,
285
293
  }
286
294
  }).sort((a, b) => (b.wallMs ?? -1) - (a.wallMs ?? -1) || a.checkId.localeCompare(b.checkId))
287
- return { checks, failing: failingOf(o.facts) }
295
+ files.sort((a, b) => (b.ms ?? -1) - (a.ms ?? -1) || a.file.localeCompare(b.file))
296
+ return { checks, files, failing: failingOf(o.facts) }
288
297
  }
@@ -0,0 +1,46 @@
1
+ // AGENT SPAWN, found statically: code that starts a Claude or agent process on
2
+ // its own (a spawn or exec of `claude`, `claude -p`, the agent sdk). No tool
3
+ // here starts an agent unless its owner asks in person, so the sweep tests
4
+ // (tests/no-agent-spawn.test.ts, and the house repo's own) fail on any hit.
5
+ //
6
+ // a string that only MENTIONS claude (a url, `~/.claude/projects`, a help
7
+ // line naming the product) is no spawn, and comments are skipped.
8
+
9
+ export type AgentSpawn = { line: number; what: string }
10
+
11
+ const SHAPES: { re: RegExp; what: string }[] = [
12
+ // the sdks that run an agent loop in process or spawn the cli
13
+ { re: /(['"`])@anthropic-ai\/(?:claude-agent-sdk|claude-code)(?:\/[^'"`]*)?\1/, what: 'imports the agent sdk' },
14
+ // an argv whose command is claude: spawn(['claude', …]), c.exec(['claude'])
15
+ { re: /\[\s*(['"`])(?:[^'"`\s]*\/)?claude\1/, what: 'an argv that runs claude' },
16
+ // spawn('claude', …), execSync('claude -p …'), execFile('claude')
17
+ { re: /\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execa|execaSync|fork)\s*\(\s*(['"`])(?:[^'"`\s]*\/)?claude(?:\s[^'"`]*)?\1/, what: 'a process call that runs claude' },
18
+ // the bun shell: $`claude -p …`
19
+ { re: /\$\s*`\s*(?:[^`\s]*\/)?claude(?:\s|`)/, what: 'a bun shell line that runs claude' },
20
+ // the headless form, wherever the string goes
21
+ { re: /(['"`])(?:[^'"`\s]*\/)?claude\s+(?:-p|--print)\b/, what: 'a claude -p command line' },
22
+ ]
23
+
24
+ /** the first agent spawn in one source text, 1-based line, comments skipped */
25
+ export function agentSpawnIn(src: string): AgentSpawn | null {
26
+ const lines = src.split('\n')
27
+ let inBlock = false
28
+ for (let i = 0; i < lines.length; i++) {
29
+ let l = lines[i]!
30
+ if (inBlock) {
31
+ const end = l.indexOf('*/')
32
+ if (end < 0) continue
33
+ l = l.slice(end + 2)
34
+ inBlock = false
35
+ }
36
+ l = l.replace(/\/\*.*?\*\//g, '')
37
+ const open = l.indexOf('/*')
38
+ if (open >= 0) {
39
+ inBlock = true
40
+ l = l.slice(0, open)
41
+ }
42
+ l = l.replace(/(^|\s)\/\/.*$/, '$1')
43
+ for (const s of SHAPES) if (s.re.test(l)) return { line: i + 1, what: s.what }
44
+ }
45
+ return null
46
+ }
@@ -0,0 +1,82 @@
1
+ // `archived: true` in repo.config.ts: the repo stays known and listed, and
2
+ // nothing runs there (no hub load, so no watch, no plan rows, no gates, no
3
+ // processes, no widgets, no identity fill). Read and written as TEXT, never by
4
+ // loading the config: the daemon and the page rows read many configs per poll,
5
+ // and the toggle moves that one key and no other byte.
6
+ import { readFileSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { atomicWrite } from './locked-write.ts'
9
+ import { configDepths } from './identity-color.ts'
10
+
11
+ const KEY_RE = /(?:^|[{,])\s*(['"]?)archived\1\s*:\s*(true|false)\b/gm
12
+
13
+ /** every top level `archived` key of `defineRepo({…})` with its value and the
14
+ * offsets of the whole `archived: <value>` token */
15
+ function archivedKeys(text: string): { on: boolean; start: number; end: number }[] {
16
+ const depth = configDepths(text)
17
+ const out: { on: boolean; start: number; end: number }[] = []
18
+ for (const m of text.matchAll(KEY_RE)) {
19
+ const lead = /^[{,]/.test(m[0]) ? 1 : 0
20
+ const sep = lead === 1 ? m.index : m.index - 1
21
+ if (sep < 0 || depth[sep] !== 1) continue
22
+ const start = m.index + lead + /^[{,]?(\s*)/.exec(m[0])![1]!.length
23
+ out.push({ on: m[2] === 'true', start, end: m.index + m[0].length })
24
+ }
25
+ return out
26
+ }
27
+
28
+ /** does the config text archive the repo */
29
+ export const archivedOfText = (text: string): boolean => archivedKeys(text).some((k) => k.on)
30
+
31
+ /** the config text with the repo archived or not. On: the key reads true, a
32
+ * missing one lands as the first key of `defineRepo({`. Off: the key goes,
33
+ * so a round trip gives the text back byte for byte */
34
+ export function setArchivedText(text: string, on: boolean): string {
35
+ const keys = archivedKeys(text)
36
+ if (keys.length > 1) throw new Error('repo.config.ts declares `archived` more than once, set it by hand')
37
+ const k = keys[0]
38
+ if (on) {
39
+ if (k != null) return k.on ? text : `${text.slice(0, k.start)}archived: true${text.slice(k.end)}`
40
+ const lines = text.split('\n')
41
+ const open = lines.findIndex((l) => /defineRepo\(\s*\{/.test(l))
42
+ if (open === -1) throw new Error('repo.config.ts has no `defineRepo({` to write `archived` into, set it by hand')
43
+ const line = lines[open]!
44
+ if (/defineRepo\(\s*\{\s*$/.test(line)) {
45
+ const next = lines.slice(open + 1).find((l) => l.trim() !== '' && !/^\s*}/.test(l))
46
+ const pad = next != null ? /^(\s*)/.exec(next)![1]! : `${/^(\s*)/.exec(line)![1]!} `
47
+ return [...lines.slice(0, open + 1), `${pad}archived: true,`, ...lines.slice(open + 1)].join('\n')
48
+ }
49
+ lines[open] = line.replace(/defineRepo\(\s*\{\s*/, (m) => `${m}archived: true, `)
50
+ return lines.join('\n')
51
+ }
52
+ if (k == null) return text
53
+ // a line of its own goes whole, an inline key goes with its comma
54
+ const lineStart = text.lastIndexOf('\n', k.start - 1) + 1
55
+ const lineEnd = text.indexOf('\n', k.end)
56
+ const rest = text.slice(k.end, lineEnd === -1 ? text.length : lineEnd)
57
+ if (text.slice(lineStart, k.start).trim() === '' && /^\s*,?\s*$/.test(rest)) return text.slice(0, lineStart) + text.slice(lineEnd === -1 ? text.length : lineEnd + 1)
58
+ const after = /^\s*,\s*/.exec(text.slice(k.end))
59
+ if (after != null) return text.slice(0, k.start) + text.slice(k.end + after[0].length)
60
+ const before = /,\s*$/.exec(text.slice(0, k.start))
61
+ return before != null ? text.slice(0, k.start - before[0].length) + text.slice(k.end) : text.slice(0, k.start) + text.slice(k.end)
62
+ }
63
+
64
+ const CONFIG_FILE = 'repo.config.ts'
65
+
66
+ /** is this repo archived; false when it has no readable config */
67
+ export function isArchived(root: string): boolean {
68
+ try {
69
+ return archivedOfText(readFileSync(join(root, CONFIG_FILE), 'utf8'))
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ /** archive the repo or bring it back: only that key of its config moves */
76
+ export function writeArchived(root: string, on: boolean): boolean {
77
+ const path = join(root, CONFIG_FILE)
78
+ const text = readFileSync(path, 'utf8')
79
+ const out = setArchivedText(text, on)
80
+ if (out !== text) atomicWrite(path, out)
81
+ return on
82
+ }
@@ -65,12 +65,21 @@ export function routesToShipkit(body: string): boolean {
65
65
  return /\bshipkit\b\S*(?:\s+\S+)*?\s+ci(?:\s|$)/.test(body.split(/&&|\|\||;/)[0] ?? body)
66
66
  }
67
67
 
68
- // recover the consumption style from existing scripts: any shipkit command
69
- // already running `bun <path>/cli.ts <cmd>` fixes the style for new bodies
68
+ /** is this path shipkit's own cli: a file named exactly `cli.ts` under a
69
+ * directory whose name says shipkit. A repo's own cli (`src/cli.ts`,
70
+ * `src/cli/comfy-ts-cli.ts`) is not, and taking it for shipkit writes
71
+ * scripts that call a command that cli does not have */
72
+ export function isShipkitCli(path: string): boolean {
73
+ const parts = path.split('/')
74
+ return parts.at(-1) === 'cli.ts' && parts.slice(0, -1).some((d) => /shipkit/.test(d))
75
+ }
76
+
77
+ // recover the consumption style from existing scripts: any script already
78
+ // running shipkit's cli by path fixes the style for new bodies
70
79
  export function inferBin(scripts: Record<string, string>): string | undefined {
71
80
  for (const body of Object.values(scripts)) {
72
81
  const m = body.match(/^bun (?:--watch )?(\S+cli\.ts) /)
73
- if (m != null && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
82
+ if (m != null && isShipkitCli(m[1]!) && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
74
83
  }
75
84
  return undefined
76
85
  }
@@ -39,7 +39,7 @@ import { parallelCap } from './parallel.ts'
39
39
  import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
40
40
  import { budgetLine, machineLoad } from './budget.ts'
41
41
  import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
42
- import { formatLine, parseWireOutput, type Line } from './wire.ts'
42
+ import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
43
43
  import { offLine, type WhenDecl } from './when.ts'
44
44
  import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
45
45
  import { Ledger, stampCached } from '../ledger/ledger.ts'
@@ -47,7 +47,7 @@ import { attachedStamp } from './outcome-words.ts'
47
47
  import type { RunLog, RunStatus } from '../ledger/run-log.ts'
48
48
  import { liveStateOf } from '../ledger/live-runs.ts'
49
49
  import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
50
- import { rememberWorld, replayWorld, type WorldCache } from '../ledger/world-cache.ts'
50
+ import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
51
51
  import { extractProblems } from '../problems/extract.ts'
52
52
  import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
53
53
 
@@ -191,6 +191,9 @@ export type RunChecksOpts = {
191
191
  * gate the whole repo, since the check's own gate already ran. */
192
192
  exclude?: string[]
193
193
  print?: (line: string) => void
194
+ /** where the green summary goes when the caller prints its own verdict
195
+ * (`shipkit ci` does). Default: print */
196
+ detail?: (line: string) => void
194
197
  color?: boolean
195
198
  onCheck?: (e: CheckEvent) => void
196
199
  }
@@ -227,11 +230,21 @@ function closure(checks: RunnableCheck[], ids: string[]): Set<string> {
227
230
  return keep
228
231
  }
229
232
 
233
+ /** how many tests a suite ran, read from the headline it prints last
234
+ * (`Ran 920 tests across 12 files.`), null when it printed none */
235
+ export function testsRan(lastLine: string): number | null {
236
+ const m = /Ran (\d+) tests?/.exec(lastLine)
237
+ return m == null ? null : Number(m[1])
238
+ }
239
+
230
240
  export type Selection = {
231
241
  /** what this run measures */
232
242
  selected: RunnableCheck[]
233
243
  /** turned off by name, with the reason: never run, always reported */
234
244
  off: RunnableCheck[]
245
+ /** off, and outside what `--only` asked about: reported where the caller
246
+ * keeps what nobody acts on */
247
+ offElsewhere?: RunnableCheck[]
235
248
  /** not asked about by this run, and why */
236
249
  parked: { id: string; text: string }[]
237
250
  /** asked for, and it never said whether it blocks */
@@ -337,6 +350,8 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
337
350
  if (o.onlyIds != null && o.onlyIds.length > 0) {
338
351
  const keep = closure(o.checks, onlyIds)
339
352
  selected = selected.filter((u) => keep.has(u.id))
353
+ // a narrowed run says nothing about the checks it never asked about
354
+ return { selected, off: off.filter((u) => keep.has(u.id)), offElsewhere: off.filter((u) => !keep.has(u.id)), parked, undeclared, errors }
340
355
  }
341
356
  return { selected, off, parked, undeclared, errors }
342
357
  }
@@ -445,9 +460,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
445
460
 
446
461
  // OFF, in ONE place: skipped by name, never run, never stored, and the
447
462
  // reason printed where the verdict would have been
448
- for (const u of sel.off) {
463
+ for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
449
464
  const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
450
- emit(line)
465
+ if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
451
466
  report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
452
467
  }
453
468
  // `--only` that leaves nothing to run asked a question nobody answers, and
@@ -499,10 +514,15 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
499
514
  * or that a different shipkit measured it. */
500
515
  const worldKey = (u: RunnableCheck): string => {
501
516
  let code = ''
502
- try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
503
- // a check that writes answers a different question when frozen
504
- const frozen = u.writes === true ? `\0frozen=${process.env['SHIPKIT_FROZEN'] === '1'}` : ''
505
- return `tc=${o.ledger?.layer.toolchainId ?? ''}\0code=${code}\0${u.rotation ?? ''}${frozen}`
517
+ if (u.ttlMs < FOREVER_MS) try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
518
+ return worldKeyOf({
519
+ ttlMs: u.ttlMs,
520
+ toolchain: u.ttlMs < FOREVER_MS ? o.ledger?.layer.toolchainId ?? '' : '',
521
+ code,
522
+ rotation: u.rotation ?? '',
523
+ // a check that writes answers a different question when frozen
524
+ frozen: u.writes === true ? process.env['SHIPKIT_FROZEN'] === '1' : null,
525
+ })
506
526
  }
507
527
 
508
528
  // a non-blocking failure becomes a WARNING here, at the source: printing
@@ -532,10 +552,18 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
532
552
  // baked into a remembered line comes back on a replay as if this run
533
553
  // had measured it (ledger/ledger.ts rule 5)
534
554
  const head = u.lines[0]
555
+ // a suite's count rides on its own line: `test 920 tests (1.4s)`
556
+ const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
557
+ const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
558
+ const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
535
559
  const shown = u.synthetic && !u.replayed && head != null
536
- ? [{ ...head, text: `${u.id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
537
- : u.lines
538
- for (const l of shown) emit(l)
560
+ ? [{ ...head, text: `${id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
561
+ : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
562
+ // a caller printing its own verdict wants one line per green check and
563
+ // only the warnings of a warned one; a failure prints whole
564
+ const quiet = (l: Line, i: number): boolean =>
565
+ o.detail != null && (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false)
566
+ for (const [i, l] of shown.entries()) if (quiet(l, i)) o.detail!(formatLine(l, color)); else emit(l)
539
567
  if (u.status === 'fail' && !u.advisory && u.logPath != null)
540
568
  emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
541
569
  report(u, { id: u.id, status: u.status, advisory: u.advisory, replayed: u.replayed, ms: u.ms, exitCode: u.exitCode, lines: u.lines, problems: u.problems, lastLine: u.lastLine, note: u.note, logPath: u.logPath }, finishedAt, u.attached)
@@ -842,7 +870,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
842
870
  const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory))
843
871
  const ran = results.filter((r) => r.status !== 'skip').length
844
872
  if (blocked.length === 0 && !interrupted && results.length > 0)
845
- emit({ kind: 'ok', text: warned.length === 0 ? `all ${ran} check(s) green` : `all ${ran} check(s) passed, ${warned.length} with a warning: ${warned.map((r) => r.id).join(', ')}` })
873
+ (o.detail ?? print)(formatLine({ kind: 'ok', text: warned.length === 0 ? `all ${ran} check(s) green` : `all ${ran} check(s) passed, ${warned.length} with a warning: ${warned.map((r) => r.id).join(', ')}` }, color))
846
874
  return {
847
875
  ok: blocked.length === 0 && !interrupted,
848
876
  interrupted,