rvlib-shipkit 1.4.0 → 1.5.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 (161) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/check.ts +68 -95
  5. package/src/checks/SK001-package-json-shape.check.ts +6 -2
  6. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  7. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  8. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  9. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  10. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  11. package/src/checks/SK007-github-visibility.check.ts +4 -2
  12. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  13. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  14. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  15. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  16. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  17. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  18. package/src/checks/SK014-prose-ste.check.ts +4 -0
  19. package/src/checks/SK015-nested-repo.check.ts +4 -2
  20. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  21. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  22. package/src/checks/SK018-test-files-run.check.ts +6 -2
  23. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  24. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  25. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  26. package/src/checks/SK024-identity-colors.check.ts +5 -3
  27. package/src/checks/SK025-release-drift.check.ts +4 -5
  28. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  29. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  30. package/src/checks/SK028-readme.check.ts +5 -1
  31. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  32. package/src/cli/cmd_Asks.ts +5 -5
  33. package/src/cli/cmd_Assets.ts +7 -7
  34. package/src/cli/cmd_Bump.ts +12 -11
  35. package/src/cli/cmd_Check.ts +8 -7
  36. package/src/cli/cmd_Checks.ts +7 -6
  37. package/src/cli/cmd_Ci.ts +12 -11
  38. package/src/cli/cmd_Commands.ts +16 -13
  39. package/src/cli/cmd_ConfigKeys.ts +7 -6
  40. package/src/cli/cmd_Contracts.ts +3 -3
  41. package/src/cli/cmd_CrossRepoActivity.ts +6 -5
  42. package/src/cli/cmd_Fleet.ts +9 -7
  43. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  44. package/src/cli/cmd_Home.ts +23 -17
  45. package/src/cli/cmd_Hooks.ts +6 -5
  46. package/src/cli/cmd_Init.ts +5 -4
  47. package/src/cli/cmd_Keys.ts +8 -7
  48. package/src/cli/cmd_Mv.ts +7 -6
  49. package/src/cli/cmd_Needs.ts +4 -3
  50. package/src/cli/cmd_Overview.ts +4 -3
  51. package/src/cli/cmd_Pb.ts +2 -2
  52. package/src/cli/cmd_Planned.ts +6 -5
  53. package/src/cli/cmd_Prose.ts +5 -5
  54. package/src/cli/cmd_Reflect.ts +7 -7
  55. package/src/cli/cmd_Release.ts +6 -5
  56. package/src/cli/cmd_Releases.ts +5 -4
  57. package/src/cli/cmd_Serve.ts +4 -3
  58. package/src/cli/cmd_Smoke.ts +5 -4
  59. package/src/cli/cmd_Start.ts +7 -6
  60. package/src/cli/cmd_Status.ts +4 -3
  61. package/src/cli/cmd_Tui.ts +3 -2
  62. package/src/cli/cmd_When.ts +71 -0
  63. package/src/cli/cmd_Wiki.ts +4 -4
  64. package/src/cli.ts +7 -2
  65. package/src/collections/checks.ts +2 -0
  66. package/src/collections/collection-types.ts +9 -4
  67. package/src/core/canonical-scripts.ts +1 -1
  68. package/src/core/check-runner.ts +37 -20
  69. package/src/core/config-rewrite.ts +37 -24
  70. package/src/core/discover-checks.ts +23 -2
  71. package/src/core/legacy-shapes.ts +39 -10
  72. package/src/core/one-way-in-policy.ts +2 -2
  73. package/src/core/outcome-words.ts +18 -2
  74. package/src/core/port-check-cli.ts +52 -0
  75. package/src/core/port-check-file.ts +295 -0
  76. package/src/core/port-check-files.ts +31 -0
  77. package/src/core/retention.ts +0 -14
  78. package/src/core/run-check.ts +12 -9
  79. package/src/core/run-ci.ts +8 -4
  80. package/src/core/run-fix.ts +30 -23
  81. package/src/core/run-fixers.ts +42 -5
  82. package/src/core/runs-matrix.ts +179 -0
  83. package/src/core/runs.ts +242 -0
  84. package/src/core/update-policy.ts +20 -0
  85. package/src/core/when-block.ts +29 -30
  86. package/src/core/when.ts +41 -103
  87. package/src/core/wire.ts +10 -14
  88. package/src/fleet/scan-fleet.ts +20 -4
  89. package/src/fleet/update-fleet.ts +6 -6
  90. package/src/home/home-launchd.ts +20 -4
  91. package/src/home/home-routes.ts +12 -1
  92. package/src/home/hubs.ts +5 -5
  93. package/src/home/pm.ts +8 -0
  94. package/src/home/run-pm.ts +1 -0
  95. package/src/home/serve-web.ts +2 -2
  96. package/src/home/supervise.ts +24 -1
  97. package/src/hooks/run-hooks.ts +48 -4
  98. package/src/init-repo.ts +6 -6
  99. package/src/ledger/gate-checks.ts +113 -59
  100. package/src/ledger/git-key.ts +5 -1
  101. package/src/ledger/ledger.ts +5 -5
  102. package/src/ledger/toolchain-identity.ts +45 -27
  103. package/src/plan/repo-source.ts +18 -39
  104. package/src/release/run-release.ts +5 -0
  105. package/src/repo-config.ts +30 -55
  106. package/src/rules/rule.ts +2 -2
  107. package/src/scaffold/files/agent-coding.md +1 -1
  108. package/src/scaffold/files/agent-index.md +1 -1
  109. package/src/scaffold/files/verify-skill.md +2 -2
  110. package/src/serve/hub-app/activity-st.ts +5 -4
  111. package/src/serve/hub-app/activity-view.tsx +1 -1
  112. package/src/serve/hub-app/admin-view.tsx +13 -11
  113. package/src/serve/hub-app/check-view.tsx +66 -46
  114. package/src/serve/hub-app/check-words.ts +1 -11
  115. package/src/serve/hub-app/checks-svc.ts +14 -35
  116. package/src/serve/hub-app/col-tables-view.tsx +13 -6
  117. package/src/serve/hub-app/cols-svc.ts +5 -4
  118. package/src/serve/hub-app/dock-st.ts +3 -2
  119. package/src/serve/hub-app/dock-view.tsx +36 -0
  120. package/src/serve/hub-app/dock.css +6 -0
  121. package/src/serve/hub-app/float-cards.tsx +30 -0
  122. package/src/serve/hub-app/floating.tsx +41 -4
  123. package/src/serve/hub-app/git-st.ts +42 -0
  124. package/src/serve/hub-app/git-view.tsx +61 -0
  125. package/src/serve/hub-app/hash-route.ts +45 -1
  126. package/src/serve/hub-app/header-bar.tsx +62 -72
  127. package/src/serve/hub-app/health-svc.ts +3 -2
  128. package/src/serve/hub-app/home-st.ts +14 -2
  129. package/src/serve/hub-app/home-strip.css +43 -8
  130. package/src/serve/hub-app/home-strip.tsx +111 -47
  131. package/src/serve/hub-app/home.html +1 -0
  132. package/src/serve/hub-app/hub-app.tsx +14 -4
  133. package/src/serve/hub-app/hub-types.ts +5 -2
  134. package/src/serve/hub-app/hub.css +65 -43
  135. package/src/serve/hub-app/icons.tsx +11 -0
  136. package/src/serve/hub-app/index.html +1 -0
  137. package/src/serve/hub-app/keys.ts +5 -2
  138. package/src/serve/hub-app/live-svc.ts +3 -2
  139. package/src/serve/hub-app/machine-st.ts +18 -22
  140. package/src/serve/hub-app/plan-view.tsx +51 -0
  141. package/src/serve/hub-app/planned-st.ts +9 -4
  142. package/src/serve/hub-app/processes-view.tsx +38 -0
  143. package/src/serve/hub-app/projects-svc.ts +15 -10
  144. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  145. package/src/serve/hub-app/reach.ts +36 -0
  146. package/src/serve/hub-app/readme-svc.ts +3 -2
  147. package/src/serve/hub-app/repo-sections.ts +117 -0
  148. package/src/serve/hub-app/repo-st.ts +46 -9
  149. package/src/serve/hub-app/sidebar.tsx +12 -37
  150. package/src/serve/hub-app/timeline-svc.ts +3 -2
  151. package/src/serve/hub-app/tree-build.ts +149 -155
  152. package/src/serve/hub-app/tree-row.tsx +39 -9
  153. package/src/serve/hub-app/tree-svc.ts +70 -49
  154. package/src/serve/hub-app/when-view.tsx +89 -0
  155. package/src/serve/hub-app/when.css +17 -0
  156. package/src/serve/hub-runtime.ts +3 -16
  157. package/src/serve/run-serve.ts +1 -1
  158. package/src/serve/serve-hub.ts +4 -14
  159. package/src/serve/hub-app/processes-svc.ts +0 -63
  160. package/src/serve/hub-app/repo-sections.tsx +0 -61
  161. package/src/serve/repo-processes.ts +0 -89
@@ -0,0 +1,71 @@
1
+ // shipkit when — what runs when: the legend (every context, every value) and
2
+ // one row per check with its declared value on each axis. The same data the
3
+ // hub's when page draws (core/runs-matrix.ts), so the terminal and the page
4
+ // cannot disagree.
5
+ import { Command, Option } from 'clipanion'
6
+ import { loadRepoConfig } from '../repo-config.ts'
7
+ import { gateInventory } from '../ledger/gate-checks.ts'
8
+ import { legendMarkdown, MATRIX_AXES, matrixLines, RUNS_LEGEND, runsCellsOf, type MatrixAxis } from '../core/runs-matrix.ts'
9
+ import { spliceMarkedBlock } from '../core/doc-block.ts'
10
+
11
+ export const LEGEND_BEGIN = '<!-- shipkit:when-legend -->'
12
+ export const LEGEND_END = '<!-- /shipkit:when-legend -->'
13
+
14
+ export class WhenCommand extends Command {
15
+ static override paths = [['when']]
16
+
17
+ static override usage = Command.Usage({
18
+ category: 'Health',
19
+ description: 'what runs when: every check on every context, with the legend',
20
+ details: 'Every check on every context (commit, ci, full, release, health, save, clock, fix) and how its answer is cached, after the legend of every axis and value. Reports, never gates.',
21
+ examples: [
22
+ ['the legend, then every check', 'sk when'],
23
+ ['only what the quick gate runs', 'sk when --only onCi'],
24
+ ['the matrix without the legend', 'sk when --no-legend'],
25
+ ['machine-readable', 'sk when --json'],
26
+ ['write the legend into a docs page between its markers', 'sk when --out wiki/build/shipkit/runs.md'],
27
+ ['fail if that page is stale (lint mode)', 'sk when --out wiki/build/shipkit/runs.md --check'],
28
+ ],
29
+ })
30
+
31
+ only = Option.String('--only', { required: false, description: `only the rows one context runs: ${MATRIX_AXES.join(', ')}` })
32
+ legend = Option.Boolean('--legend', true, { description: 'print the legend first. Default on, --no-legend drops it' })
33
+ out = Option.String('--out', { required: false, description: 'host file carrying the shipkit:when-legend markers. The legend between them is rewritten' })
34
+ check = Option.Boolean('--check', false, { description: 'with --out: exit 1 if the legend is stale, write nothing' })
35
+ json = Option.Boolean('--json', false, { description: 'machine-readable { legend, rows: [{ id, title, from, blocking, build, runs: { cells, cache, off, derivedWhy } }] }' })
36
+
37
+ async execute(): Promise<number> {
38
+ if (this.out != null) return await this.writeLegend(this.out)
39
+ const root = process.cwd()
40
+ const c = await loadRepoConfig(root)
41
+ if (this.only != null && !(MATRIX_AXES as readonly string[]).includes(this.only)) {
42
+ this.context.stdout.write(`FAIL: --only takes one of ${MATRIX_AXES.join(', ')}, got '${this.only}'\n`)
43
+ return 1
44
+ }
45
+ const rows = (await gateInventory(root, c)).map((r) => ({ id: r.decl.id, title: r.title, from: r.from, blocking: r.blocking, build: r.build, runs: runsCellsOf(r) }))
46
+ if (this.json) {
47
+ this.context.stdout.write(`${JSON.stringify({ legend: RUNS_LEGEND, rows })}\n`)
48
+ return 0
49
+ }
50
+ for (const l of matrixLines(rows, { legend: this.legend, only: this.only as MatrixAxis | undefined })) this.context.stdout.write(`${l}\n`)
51
+ return 0
52
+ }
53
+
54
+ /** the legend only depends on the binary, never on the repo */
55
+ private async writeLegend(path: string): Promise<number> {
56
+ const { existsSync, readFileSync, writeFileSync } = await import('fs')
57
+ if (!existsSync(path)) { this.context.stdout.write(`FAIL: ${path} does not exist\n`); return 1 }
58
+ const host = readFileSync(path, 'utf8')
59
+ const next = spliceMarkedBlock(host, legendMarkdown(), LEGEND_BEGIN, LEGEND_END)
60
+ if (next == null) { this.context.stdout.write(`FAIL: ${path} has no ${LEGEND_BEGIN} / ${LEGEND_END} markers\n`); return 1 }
61
+ if (next === host) { this.context.stdout.write(`ok: ${path} legend current\n`); return 0 }
62
+ if (this.check) {
63
+ this.context.stdout.write(`FAIL: ${path} legend is STALE, the binary's axes or values differ from the page\n`)
64
+ this.context.stdout.write(` fix: shipkit when --out ${path}\n`)
65
+ return 1
66
+ }
67
+ writeFileSync(path, next)
68
+ this.context.stdout.write(`ok: ${path} legend rewritten\n`)
69
+ return 0
70
+ }
71
+ }
@@ -10,8 +10,8 @@ export class WikiServeCommand extends Command {
10
10
  category: 'Wiki',
11
11
  description: 'live md link-graph server — reload = rescan',
12
12
  examples: [
13
- ['serve the cwd wiki', 'shipkit wiki serve'],
14
- ['serve a subtree on a port', 'shipkit wiki serve --root agent --index process.md --port 6162'],
13
+ ['serve the cwd wiki', 'sk wiki serve'],
14
+ ['serve a subtree on a port', 'sk wiki serve --root agent --index process.md --port 6162'],
15
15
  ],
16
16
  })
17
17
 
@@ -38,8 +38,8 @@ export class WikiLintCommand extends Command {
38
38
  category: 'Wiki',
39
39
  description: 'fail on orphan pages and dangling refs (exit 1)',
40
40
  examples: [
41
- ['lint the cwd wiki', 'shipkit wiki lint'],
42
- ['lint a subtree', 'shipkit wiki lint --root agent --index process.md'],
41
+ ['lint the cwd wiki', 'sk wiki lint'],
42
+ ['lint a subtree', 'sk wiki lint --root agent --index process.md'],
43
43
  ],
44
44
  })
45
45
 
package/src/cli.ts CHANGED
@@ -11,7 +11,7 @@ import { CheckCommand } from './cli/cmd_Check.ts'
11
11
  import { CommandsCommand } from './cli/cmd_Commands.ts'
12
12
  import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
13
13
  import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
14
- import { GlobalProcessesCommand } from './cli/cmd_GlobalProcesses.ts'
14
+ import { GlobalProcessesCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
15
15
  import { PlannedCommand } from './cli/cmd_Planned.ts'
16
16
  import { OverviewCommand } from './cli/cmd_Overview.ts'
17
17
  import { CiCommand } from './cli/cmd_Ci.ts'
@@ -29,6 +29,7 @@ import { ReflectCommand } from './cli/cmd_Reflect.ts'
29
29
  import { ReleaseCommand } from './cli/cmd_Release.ts'
30
30
  import { ReleasesCommand } from './cli/cmd_Releases.ts'
31
31
  import { ChecksCommand, UnitsAliasCommand } from './cli/cmd_Checks.ts'
32
+ import { WhenCommand } from './cli/cmd_When.ts'
32
33
  import { ServeCommand } from './cli/cmd_Serve.ts'
33
34
  import { SmokeCommand } from './cli/cmd_Smoke.ts'
34
35
  import { StatusCommand } from './cli/cmd_Status.ts'
@@ -43,7 +44,7 @@ import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
43
44
  export function buildCli(): Cli {
44
45
  const cli = new Cli({
45
46
  binaryLabel: 'shipkit — repo toolkit',
46
- binaryName: 'shipkit',
47
+ binaryName: 'sk',
47
48
  binaryVersion: LIB_VERSION,
48
49
  enableCapture: true, // console output pipes into the command streams (testable)
49
50
  enableColors: true,
@@ -91,10 +92,14 @@ export function buildCli(): Cli {
91
92
  cli.register(ReflectCommand)
92
93
  cli.register(KeysCommand)
93
94
  cli.register(GlobalProcessesCommand)
95
+ cli.register(GlobalProcessStartCommand)
96
+ cli.register(GlobalProcessStopCommand)
97
+ cli.register(GlobalProcessRestartCommand)
94
98
  cli.register(CrossRepoActivityCommand)
95
99
  cli.register(PlannedCommand)
96
100
  cli.register(OverviewCommand)
97
101
  cli.register(ChecksCommand)
102
+ cli.register(WhenCommand)
98
103
  cli.register(UnitsAliasCommand)
99
104
  cli.register(CommandsCommand)
100
105
  cli.register(ConfigKeysCommand)
@@ -20,6 +20,7 @@ import { rawName, routesToShipkit } from '../core/canonical-scripts.ts'
20
20
  import { FLOOR_IDS } from '../core/floor.ts'
21
21
  import { FOREVER_MS, formatLine, ttlMsOf } from '../core/wire.ts'
22
22
  import { gateInventory, type CheckRow } from '../ledger/gate-checks.ts'
23
+ import { runsCellsOf } from '../core/runs-matrix.ts'
23
24
  import { readLastLines, readRuns, runsPath, type LastLine, type RunRow } from '../ledger/run-log.ts'
24
25
  import { checkStatsFor, type CheckStat } from '../ledger/check-stats.ts'
25
26
  import { NEGATIVE_TTL_CAP_MS } from '../ledger/world-cache.ts'
@@ -114,6 +115,7 @@ function instanceOf(root: string, c: RepoConfig, row: CheckRow, stat: CheckStat
114
115
  description: row.description,
115
116
  lastLines: wireLines,
116
117
  gate: row.gate,
118
+ runs: runsCellsOf(row),
117
119
  build: row.build,
118
120
  blocking: row.blocking,
119
121
  advisory,
@@ -7,6 +7,7 @@
7
7
  // the docs wiki this lib ships beside.
8
8
 
9
9
  import type { WhenAt, WhenSource } from '../core/when.ts'
10
+ import type { RunsCells } from '../core/runs-matrix.ts'
10
11
  import type { CheckFrom } from '../ledger/gate-checks.ts'
11
12
 
12
13
  export type NeedSeverity = 'red' | 'yellow'
@@ -22,14 +23,15 @@ export const RUNS_ID = 'runs'
22
23
  * row, the runs page and the to fix list all open the same page by it */
23
24
  export const testId = (file: string): string => `test:${file}`
24
25
 
25
- /** where the tree files the test files no check reaches, in both views */
26
+ /** the path segment of a test file no check reaches, where a check id sits
27
+ * for the others, so the filter finds a test file by its check */
26
28
  export const NO_CHECK = 'in no check'
27
29
 
28
- /** the resource KIND, never the collection it came from: one folder holds
29
- * several kinds, which is what makes the path tree worth having. There is no
30
+ /** the resource KIND, never the collection it came from: it decides the
31
+ * section a row lives under (hub-app/tree-build.ts KIND_SECTION). There is no
30
32
  * catch all kind: a row belongs to an owner, and what belongs to the hub
31
33
  * itself is its status (serve/hub-problems.ts), never a row */
32
- export const RESOURCE_TYPES = ['ask', 'secret', 'asset', 'post', 'workflow', 'wiki', 'contract', 'check', 'test', 'reflection'] as const
34
+ export const RESOURCE_TYPES = ['ask', 'secret', 'asset', 'post', 'workflow', 'wiki', 'contract', 'check', 'test', 'reflection', 'process'] as const
33
35
 
34
36
  export type ResourceType = (typeof RESOURCE_TYPES)[number]
35
37
 
@@ -53,6 +55,9 @@ export type CheckFacts = {
53
55
  lastLines: string[]
54
56
  /** does `shipkit ci` run it? false = proactive, `shipkit check` only */
55
57
  gate: boolean
58
+ /** every context it runs in and how it caches, as the when page draws
59
+ * it (core/runs-matrix.ts) */
60
+ runs: RunsCells
56
61
  /** a BUILD STEP: the gate runs it, the health pass does not (derived from
57
62
  * a floor slot or the repo's `tasks: []`, never declared by a check) */
58
63
  build: boolean
@@ -30,7 +30,7 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
30
30
  }
31
31
  }
32
32
 
33
- // ONE WAY IN (build/shipkit.md): the verification verbs an agent types from
33
+ // ONE WAY IN (build/shipkit/one-way-in.md): the verification verbs an agent types from
34
34
  // muscle memory ROUTE to shipkit, so `bun run test` gets the cache and the
35
35
  // job attachment without anyone having to remember a rule. The tool body they
36
36
  // used to hold moves to `<name>:raw`, which is what shipkit itself runs and
@@ -40,15 +40,19 @@ 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
42
  import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
43
- import { offLine, type WhenDecl } from './when.ts'
43
+ import { offLine, runsIn, type ResolvedRuns, type RunContext } from './runs.ts'
44
44
  import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
45
45
  import { Ledger, stampCached } from '../ledger/ledger.ts'
46
- import { attachedStamp } from './outcome-words.ts'
46
+ import { attachedStamp, ranStamp } 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
50
  import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
51
51
  import { extractProblems } from '../problems/extract.ts'
52
+
53
+ /** the first line of a check that ran in this run, stamped with its cost. A
54
+ * world answer carries its keep in a stamp already, so the cost joins it */
55
+ const ranHere = (l: Line, ms: number): Line => (l.stamp != null && l.stamp.cached !== true ? { ...l, stamp: { ...l.stamp, ranMs: ms } } : { ...l, text: `${l.text} ${ranStamp(ms)}` })
52
56
  import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
53
57
 
54
58
  /** the ceiling a check takes when it names none. A build step's floor
@@ -82,7 +86,8 @@ export type RunnableCheck = {
82
86
  blocking: boolean | null
83
87
  /** a build step: the gate runs it, the health pass does not */
84
88
  build: boolean
85
- when: WhenDecl
89
+ /** every context it runs in, resolved (core/runs.ts) */
90
+ runs: ResolvedRuns
86
91
  deps: string[]
87
92
  group: string | null
88
93
  timeoutMs: number
@@ -159,8 +164,10 @@ export type RunChecksOpts = {
159
164
  root: string
160
165
  config: CheckEnv['config']
161
166
  audience: Audience
162
- /** `release` checks are in at the full tier and out at the quick one */
167
+ /** the full tests tier: `onFull` decides instead of `onCi` */
163
168
  fullTier: boolean
169
+ /** the release preflight: `onRelease` decides, whatever the tier */
170
+ release?: boolean
164
171
  /** where a `cmd` check's whole output is streamed */
165
172
  logDir: string
166
173
  /** the world answers' store, null = nothing is remembered by TTL */
@@ -256,10 +263,13 @@ export type Selection = {
256
263
  errors: string[]
257
264
  }
258
265
 
266
+ /** the axis each context reads, as a parked line names it */
267
+ const CTX_AXIS: Record<RunContext, string> = { preCommit: 'onPreCommit', ci: 'onCi', full: 'onFull', release: 'onRelease', health: 'onHealth' }
268
+
259
269
  /** what this run asks about. The audience decides the shape of the question,
260
270
  * `--only` and `--with` name checks past any filter, and OFF is the one word
261
271
  * no flag lifts. */
262
- export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
272
+ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'release' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
263
273
  const errors: string[] = []
264
274
  const ids = new Set(o.checks.map((u) => u.id))
265
275
  if (ids.size !== o.checks.length) errors.push('duplicate check ids')
@@ -318,26 +328,28 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
318
328
  if (u.blocking == null) undeclared.push(u.id)
319
329
  selected.push(u)
320
330
  }
331
+ // the one context this run asks about: every row declares whether it
332
+ // runs there (core/runs.ts), and nothing is guessed from its kind
333
+ const ctx: RunContext = o.audience === 'health' ? 'health' : o.release === true ? 'release' : o.fullTier ? 'full' : 'ci'
321
334
  for (const u of o.checks) {
322
- // IS IT THIS RUN'S BUSINESS AT ALL? The health pass never builds (a
323
- // repo's test suite and its type checker answer "is my change good",
324
- // which is the gate's question), the TUI's build panel asks for
325
- // nothing else, and a sub-unit release skips what its own gate ran.
326
- // A check out of scope here is not even reported as off: it is not part
327
- // of this question.
335
+ // IS IT THIS RUN'S BUSINESS AT ALL? The TUI's build panel asks for
336
+ // nothing but build steps, and a sub-unit release skips what its own
337
+ // gate ran. A check out of scope here is not even reported as off: it
338
+ // is not part of this question.
328
339
  if (!named.has(u.id)) {
329
- if (o.audience === 'health' && u.build) continue
330
340
  if (o.buildOnly === true && !u.build) continue
331
341
  if (excluded.has(u.id)) { parked.push({ id: u.id, text: `${u.id} (not part of this release, its own gate covers it)` }); continue }
332
342
  }
333
343
  // OFF is the one word that means never, past every other flag: a
334
344
  // switch a filter can lift is a suggestion
335
- if (u.when.at === 'off') { off.push(u); continue }
345
+ if (u.runs.off != null) { off.push(u); continue }
336
346
  if (named.has(u.id)) { take(u); continue }
337
347
  if (u.group != null && skipGroups.has(u.group)) { parked.push({ id: u.id, text: `${u.id} (--skip ${u.group})` }); continue }
338
- if (o.audience === 'gate') {
339
- if (u.when.at === 'health' && o.includeProactive !== true) { parked.push({ id: u.id, text: `${u.id} (health)` }); continue }
340
- if (u.when.at === 'release' && !o.fullTier) { parked.push({ id: u.id, text: `${u.id} (release)` }); continue }
348
+ if (!runsIn(u.runs, ctx)) {
349
+ // the health pass answers its own question and lists nothing else
350
+ if (ctx === 'health') continue
351
+ // release.requireHealthy asks the gate for the health probes too
352
+ if (!(o.includeProactive === true && u.runs.onHealth && !u.build)) { parked.push({ id: u.id, text: `${u.id} (not ${CTX_AXIS[ctx]})` }); continue }
341
353
  }
342
354
  if (o.onlyBlocking === true && u.blocking !== true) { if (u.blocking == null) undeclared.push(u.id); nonBlocking.set(u.id, u); continue }
343
355
  take(u)
@@ -471,7 +483,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
471
483
  // OFF, in ONE place: skipped by name, never run, never stored, and the
472
484
  // reason printed where the verdict would have been
473
485
  for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
474
- const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
486
+ const line: Line = { kind: 'skip', text: offLine(u.id, u.runs) }
475
487
  if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
476
488
  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 })
477
489
  }
@@ -566,9 +578,14 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
566
578
  const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
567
579
  const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
568
580
  const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
569
- const shown = u.synthetic && !u.replayed && head != null
570
- ? [{ ...head, text: `${id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
581
+ const composed = u.synthetic && !u.replayed && head != null
582
+ ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}` }, ...u.lines.slice(1)]
571
583
  : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
584
+ // every line says where its answer came from: a replay carries its
585
+ // cached stamp already, so one that ran here says so and what it cost
586
+ const shown = u.synthetic || u.replayed || u.attached || u.startedAt === 0 || composed[0] == null
587
+ ? composed
588
+ : [ranHere(composed[0], u.ms), ...composed.slice(1)]
572
589
  // a caller printing its own verdict wants one line per green check and
573
590
  // only the warnings of a warned one; a failure prints whole
574
591
  const quiet = (l: Line, i: number): boolean =>
@@ -585,7 +602,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
585
602
  const hit = o.ledger?.replay(u.id)
586
603
  if (hit != null) {
587
604
  u.replayed = true
588
- u.lines = stampCached(hit.lines, hit.measuredMs)
605
+ u.lines = stampCached(hit.lines, hit.measuredMs, hit.at, clock())
589
606
  u.lastLine = hit.lastLine ?? ''
590
607
  u.status = hit.status
591
608
  // a replay stands for the verdict it replays, findings included:
@@ -1,14 +1,15 @@
1
- // THE CONFIG HALF of the SK016 migration, in its own process. Writing the
1
+ // THE CONFIG HALF of the SK016 and SK029 migrations, in its own process. Writing the
2
2
  // managed block and reading the result back both LOAD repo.config.ts as a
3
3
  // module, which no check context read can stand for, so the fixer reaches it
4
4
  // through `c.exec` and the check file itself never imports the loader.
5
5
  //
6
6
  // bun config-rewrite.ts <root> '<plan json>' → one json line on stdout
7
7
  //
8
- // The order is the migration's: the `when` entries first, then each key is
9
- // dropped only once what leaned on it has a home, then the result is read
10
- // back through the loader and the previous bytes go back when it does not
11
- // survive.
8
+ // The order is the migration's: a key the managed block holds (the retired
9
+ // `when`) is dropped first, so the block describes the config again, then the
10
+ // `off` entries are written into the block, then each other key is dropped
11
+ // only once what leaned on it has a home. The result is read back through the
12
+ // loader and the previous bytes go back when it does not survive.
12
13
  import { readFileSync } from 'fs'
13
14
  import { join } from 'path'
14
15
  import { atomicWrite } from './locked-write.ts'
@@ -17,10 +18,11 @@ import { writeGateBlock } from './when-block.ts'
17
18
  import { loadRepoConfig } from '../repo-config.ts'
18
19
 
19
20
  export type ConfigPlan = {
20
- /** check id → `when` value, written into the managed block */
21
- when: Record<string, string>
22
- /** keys to drop, in order. `afterWhen` drops only once the block took the entries */
23
- drop: { key: string; why: string; afterWhen?: boolean }[]
21
+ /** check id → why it is off, written into the managed block */
22
+ off: Record<string, string>
23
+ /** keys to drop, in order. `first` drops before the block is written (a
24
+ * key inside it), `afterOff` only once the block took the entries */
25
+ drop: { key: string; why: string; phase?: 'first' | 'afterOff' }[]
24
26
  /** the config's `name` before the rewrite: what the read back must still say */
25
27
  name: string
26
28
  }
@@ -35,27 +37,38 @@ export type ConfigRewrite = {
35
37
  export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<ConfigRewrite> {
36
38
  const did: string[] = []
37
39
  const hand: string[] = []
38
- let whenWritten = false
39
- if (Object.keys(plan.when).length > 0) {
40
+ const path = join(root, 'repo.config.ts')
41
+ const before = readFileSync(path, 'utf8')
42
+ const drop = (text: string, d: ConfigPlan['drop'][number]): string => {
43
+ const next = dropConfigKey(text, d.key)
44
+ if (next == null) { hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`); return text }
45
+ did.push(`repo.config.ts drops \`${d.key}\``)
46
+ return next
47
+ }
48
+ let first = before
49
+ for (const d of plan.drop.filter((x) => x.phase === 'first')) first = drop(first, d)
50
+ if (first !== before) atomicWrite(path, first)
51
+ let offWritten = false
52
+ if (Object.keys(plan.off).length > 0) {
40
53
  try {
41
- await writeGateBlock(root, { when: plan.when })
42
- whenWritten = true
43
- did.push(`${Object.keys(plan.when).length} when entr(ies) written into the managed block`)
54
+ await writeGateBlock(root, { off: plan.off })
55
+ offWritten = true
56
+ did.push(`${Object.keys(plan.off).length} off entr(ies) written into the managed block`)
44
57
  } catch (e) {
45
- hand.push(`the \`when\` block refused the rewrite (${String(e).slice(0, 160)}) — write ${JSON.stringify(plan.when)} into it by hand`)
58
+ // a key dropped first held these entries: without the block they
59
+ // would be gone, so the file goes back as it was
60
+ if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(plan.off)}: ${String(e).slice(0, 160)}` } }
61
+ hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(plan.off)} into it by hand`)
46
62
  }
47
63
  }
48
- const path = join(root, 'repo.config.ts')
49
- const before = readFileSync(path, 'utf8')
50
- let text = before
51
- for (const d of plan.drop) {
52
- if (d.afterWhen === true && !whenWritten) continue
53
- const next = dropConfigKey(text, d.key)
54
- if (next == null) hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`)
55
- else { text = next; did.push(`repo.config.ts drops \`${d.key}\``) }
64
+ const mid = readFileSync(path, 'utf8')
65
+ let text = mid
66
+ for (const d of plan.drop.filter((x) => x.phase !== 'first')) {
67
+ if (d.phase === 'afterOff' && !offWritten) continue
68
+ text = drop(text, d)
56
69
  }
70
+ if (text !== mid) atomicWrite(path, text)
57
71
  if (text !== before) {
58
- atomicWrite(path, text)
59
72
  try {
60
73
  const after = await loadRepoConfig(root, { fresh: true })
61
74
  if (after.name !== plan.name) throw new Error(`the config reads as '${String(after.name)}'`)
@@ -110,7 +110,7 @@ export function isLegacyCheckFile(path: string): boolean {
110
110
  /** every built-in id, from the file names alone. Sync and import free, so
111
111
  * `defineRepo` can read the namespace at config load. Read once per
112
112
  * process: the toolkit's own files cannot appear mid run, and `defineRepo`
113
- * asks for the list once per `when` entry. */
113
+ * asks for the list once per `off` entry. */
114
114
  let builtinIds: string[] | null = null
115
115
  export function builtinCheckIds(): string[] {
116
116
  if (builtinIds != null) return builtinIds
@@ -144,6 +144,20 @@ async function load(path: string): Promise<CheckDef | null> {
144
144
  }
145
145
  }
146
146
 
147
+ /** the stand in for a check file that does not load: it fails in every
148
+ * context with the load error, so the file cannot drop out of a gate unseen */
149
+ function unloadable(path: string, message: string): CheckDef {
150
+ return {
151
+ description: 'the check file loads',
152
+ blocking: true,
153
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: false, onClock: false },
154
+ cache: { by: 'never', why: 'the file does not load' },
155
+ fix: 'none',
156
+ applies: 'always',
157
+ run: (c) => c.fail(`${basename(path)} does not load: ${message.replace(`${path}: `, '')}`, 'shipkit check --fix SK029 ports a retired check file with the values it ran on, and shipkit ci does it in its fix pass'),
158
+ }
159
+ }
160
+
147
161
  /** every check this repo can run: its own files, plus the toolkit's. Throws
148
162
  * on a registration nobody can act on (a duplicate id, a repo check squatting
149
163
  * the reserved prefix, a built-in with no owner page), so a broken
@@ -170,7 +184,14 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
170
184
  const ids = new Set<string>()
171
185
  for (const f of [...builtins, ...own, ...units]) {
172
186
  const isBuiltin = builtins.includes(f)
173
- const def = await load(f)
187
+ let def: CheckDef | null
188
+ try {
189
+ def = await load(f)
190
+ } catch (e) {
191
+ // a file that does not load is ONE red row, never a dead inventory:
192
+ // every other check still runs, and this one says why in every run
193
+ def = unloadable(f, (e as Error).message)
194
+ }
174
195
  if (def == null) continue
175
196
  const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
176
197
  const fallback = isBuiltin
@@ -22,6 +22,7 @@ import { fallbackNames, isCheckFilePath, readLegacyCheck, type LegacyCheck } fro
22
22
  import type { ScanCtx } from './check-ctx.ts'
23
23
  import type { RepoConfig } from '../repo-config.ts'
24
24
  import { descriptionProblem, type TaskDef } from '../check.ts'
25
+ import { deriveExplicit, type Derived } from './port-check-file.ts'
25
26
 
26
27
  /** the narrow module every rewritten file imports `check` from, and the ONE
27
28
  * spelling: the barrel costs 400ms of module load and discovery imports
@@ -130,16 +131,34 @@ export const PLACEHOLDER_MARK = 'TODO: state what must be true'
130
131
  * drops none of them. */
131
132
  export function scriptPort(read: LegacyCheck, fallbackBlocking: boolean | undefined): string {
132
133
  const blocking = read.blocking ?? fallbackBlocking
134
+ // what the ported `run` body resolves to: the script's own declarations,
135
+ // read as the gate read them, on a body keyed on what it reads
136
+ const d = explicitOf(deriveExplicit({
137
+ when: read.gate ? null : 'health',
138
+ build: false,
139
+ body: 'run',
140
+ ...(read.inputs != null ? { inputs: `[${read.inputs.map(q).join(', ')}]` } : {}),
141
+ ...(read.ttl != null ? { ttl: q(read.ttl) } : {}),
142
+ ...(read.never != null ? { never: q(read.never) } : {}),
143
+ repair: false,
144
+ rawAccess: null,
145
+ }, q))
133
146
  const fields = [
134
147
  blocking == null ? 'blocking: <true | false, it declares none>' : `blocking: ${blocking}`,
135
- ...(read.inputs != null ? [`inputs: [${read.inputs.map(q).join(', ')}]`] : []),
136
- ...(read.ttl != null ? [`ttl: ${q(read.ttl)}`] : []),
137
- ...(read.never != null ? [`never: ${q(read.never)}`] : []),
138
- ...(read.gate ? [] : [`when: 'health'`]),
148
+ `runs: ${d.runs}`,
149
+ `cache: ${d.cache}`,
150
+ `fix: ${q(d.fix)}`,
151
+ `applies: ${d.applies}`,
139
152
  ]
140
153
  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)`
141
154
  }
142
155
 
156
+ /** a derivation that cannot fail on what a script or a task can declare */
157
+ function explicitOf(d: Derived | { error: string }): Derived {
158
+ if ('error' in d) throw new Error(`the legacy declaration has no explicit form: ${d.error}`)
159
+ return d
160
+ }
161
+
143
162
  /** the check file a task's argv runs through bun, `bun <file>` or `bun run
144
163
  * <file>`, relative, `./` prefixed or absolute, resolved against the task's
145
164
  * cwd. `rest` is what the argv passes after the file: a task that passes its
@@ -159,16 +178,24 @@ export function checkFileRunBy(t: Pick<TaskDef, 'cmd' | 'cwd'>, root: string): {
159
178
  * always was: the gate runs it and the health pass leaves it out. */
160
179
  export function taskCheckSource(t: TaskDef, root: string): string {
161
180
  const own = t.description != null && descriptionProblem(t.description) == null ? t.description.trim() : null
162
- const fields = [`id: ${q(t.id)},`, `description: ${q(own ?? descriptionPlaceholder())},`, 'blocking: true,', 'build: true,', `cmd: [${t.cmd!.map(q).join(', ')}],`]
181
+ // where it ran and how it was kept, as the gate and the planner read a
182
+ // task: an optIn one ran only by name
183
+ const d = explicitOf(deriveExplicit({
184
+ when: t.optIn === true ? 'health' : null,
185
+ build: true,
186
+ body: 'cmd',
187
+ ...(t.inputs != null ? { inputs: `[${t.inputs.map(q).join(', ')}]` } : {}),
188
+ ...(t.ttl != null ? { ttl: q(t.ttl) } : {}),
189
+ ...(t.never != null ? { never: q(t.never) } : {}),
190
+ repair: false,
191
+ rawAccess: null,
192
+ }, q))
193
+ const fields = [`id: ${q(t.id)},`, `description: ${q(own ?? descriptionPlaceholder())},`, 'blocking: true,', 'build: true,', `runs: ${d.runs},`, `cache: ${d.cache},`, `fix: ${q(d.fix)},`, `applies: ${d.applies},`, `cmd: [${t.cmd!.map(q).join(', ')}],`]
163
194
  if (t.deps != null && t.deps.length > 0) fields.push(`deps: [${t.deps.map(q).join(', ')}],`)
164
- if (t.inputs != null) fields.push(`inputs: [${t.inputs.map(q).join(', ')}],`)
165
- if (t.ttl != null) fields.push(`ttl: ${q(t.ttl)},`)
166
- if (t.never != null) fields.push(`never: ${q(t.never)},`)
167
195
  if (t.group != null) fields.push(`group: ${q(t.group)},`)
168
196
  if (t.timeoutMs != null) fields.push(`timeoutMs: ${t.timeoutMs},`)
169
197
  if (t.env != null) fields.push(`env: { ${Object.entries(t.env).map(([k, v]) => `${q(k)}: ${q(v)}`).join(', ')} },`)
170
198
  if (t.cwd != null) fields.push(`cwd: ${q(t.cwd)},`)
171
- if (t.optIn === true) fields.push(`when: 'health',`)
172
199
  const w = checkWrapper(root)
173
200
  return [...w.head, w.open, ...fields.map((f) => ` ${f}`), w.close, ''].join('\n')
174
201
  }
@@ -189,6 +216,8 @@ export function dropConfigKey(text: string, key: string): string | null {
189
216
  const start = at[0]!
190
217
  const end = statementEnd(lines, start)
191
218
  let from = start
192
- while (from > 0 && lines[from - 1]!.trim().startsWith('//')) from--
219
+ // the comment block above the key goes with it, never a managed block
220
+ // marker (`// >>> shipkit …`): the key may be the first line inside one
221
+ while (from > 0 && lines[from - 1]!.trim().startsWith('//') && !/^\/\/ (>>>|<<<) shipkit/.test(lines[from - 1]!.trim())) from--
193
222
  return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
194
223
  }
@@ -1,4 +1,4 @@
1
- // ONE WAY IN, the harness half (build/shipkit.md). The package.json half
1
+ // ONE WAY IN, the harness half (build/shipkit/one-way-in.md). The package.json half
2
2
  // (rule SK001) routes `bun run test` into shipkit, but `bun test` is a bun
3
3
  // BUILTIN: no script can intercept it, so a package.json alone leaves the
4
4
  // reflex wide open. Claude Code permission rules can close it.
@@ -76,7 +76,7 @@ export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
76
76
  export function refusal(r: RawRunner, segment?: string): string {
77
77
  const alt = r.iterate == null ? '' : `, or \`${r.iterate}\` to iterate on ONE suite while you fix a bug`
78
78
  const which = segment == null || segment.trim() === '' || segment.trim() === r.head ? '' : ` (refused on \`${segment.trim().slice(0, 80)}\`)`
79
- return `\`${r.head}\`${which} bypasses shipkit, so nothing is cached and no other agent's run can be attached to. Use \`${r.use}\`${alt}. Lifting this for good: delete the "Bash(${r.head}*)" line from .claude/settings.json permissions.deny (build/shipkit.md, ONE WAY IN).`
79
+ return `\`${r.head}\`${which} bypasses shipkit, so nothing is cached and no other agent's run can be attached to. Use \`${r.use}\`${alt}. Lifting this for good: delete the "Bash(${r.head}*)" line from .claude/settings.json permissions.deny (build/shipkit/one-way-in.md).`
80
80
  }
81
81
 
82
82
  /** shell wrappers that do not change what is being run */
@@ -38,8 +38,24 @@ export const checkVerdictWords = (status: 'pass' | 'warn' | 'fail' | 'skip' | nu
38
38
  : status === 'skip' ? 'skipped'
39
39
  : status === 'pass' ? (o.replayed ? `passed, ${CACHED}` : 'passed') : 'never ran here'
40
40
 
41
- /** the stamp on the first line of a cached check: `(cached, 5.6s saved)` */
42
- export const cachedStamp = (savedMs: number): string => `(${CACHED}, ${(savedMs / 1000).toFixed(1)}s saved)`
41
+ /** a span in its one biggest unit: `45s`, `12m`, `3h`, `2d` */
42
+ export function spanShort(ms: number): string {
43
+ const s = Math.max(0, Math.round(ms / 1000))
44
+ if (s < 60) return `${s}s`
45
+ if (s < 3600) return `${Math.floor(s / 60)}m`
46
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
47
+ return `${Math.floor(s / 86400)}d`
48
+ }
49
+
50
+ /** what a run cost: `12ms` under a second, `6.4s` past it */
51
+ export const costShort = (ms: number): string => (ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`)
52
+
53
+ /** the stamp on the first line of a cached check: how old the answer is and
54
+ * what measuring it cost, `(cached, 3h old, 5.6s saved)` */
55
+ export const cachedStamp = (savedMs: number, ageMs: number): string => `(${CACHED}, ${spanShort(ageMs)} old, ${costShort(savedMs)} saved)`
56
+
57
+ /** the stamp on the first line of a check that ran in this run: `(ran 6.4s)` */
58
+ export const ranStamp = (ms: number): string => `(${RAN} ${costShort(ms)})`
43
59
 
44
60
  /** the stamp on the first line of an attached check */
45
61
  export const attachedStamp = (pid: number, ranMs: number): string => `(${ATTACHED} to pid ${pid}, ${RAN} there in ${(ranMs / 1000).toFixed(1)}s)`