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
@@ -17,6 +17,7 @@
17
17
  //
18
18
  // Skipped dirs: node_modules, dist, tmp, references (templates for other
19
19
  // repos), and every dot-dir.
20
+ import { RETIRED_CHECK_IDS } from './retired-checks.ts'
20
21
  import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
21
22
  import { basename, join, relative } from 'path'
22
23
  import { check, type CheckDef, type CheckFrom } from '../check.ts'
@@ -176,6 +177,7 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
176
177
  if (isBuiltin) {
177
178
  if (id !== fallback) throw new Error(`${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`)
178
179
  if ((def.owner ?? '') === '') throw new Error(`${f}: a built-in check names the page that OWNS the invariant`)
180
+ if (RETIRED_CHECK_IDS[id] != null) throw new Error(`${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`)
179
181
  } else if (id.startsWith('SK')) {
180
182
  throw new Error(`${rel}: id '${id}' uses the reserved SK prefix — a repo check picks its own (e.g. RV${id.slice(-3)}) or plain words`)
181
183
  }
@@ -0,0 +1,20 @@
1
+ // the content of a file no tree id reaches (outside the repo, gitignored, an
2
+ // install), as one short hash for a check's `cacheKey`. `absent` when nothing
3
+ // stands there, so a file appearing or vanishing moves the key too.
4
+ import { createHash } from 'crypto'
5
+ import { readFileSync } from 'fs'
6
+ import { homedir } from 'os'
7
+ import { isAbsolute, join } from 'path'
8
+
9
+ export function fileStamp(abs: string): string {
10
+ try {
11
+ return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16)
12
+ } catch {
13
+ return 'absent'
14
+ }
15
+ }
16
+
17
+ /** a path as a config declares it (`~/...`, absolute, or repo relative), absolute */
18
+ export function configPath(root: string, p: string): string {
19
+ return p.startsWith('~/') ? join(homedir(), p.slice(2)) : isAbsolute(p) ? p : join(root, p)
20
+ }
@@ -1,27 +1,30 @@
1
- // SK021: the gh login the toolkit itself runs on: `init` and `start` create
2
- // a PRIVATE repo with it, every push and release goes through it, SK007 reads
3
- // visibility with it. gh keeps that login outside any secrets manager, so this
4
- // probe is the only place a lost one shows. It is a `core` credential: red on
5
- // the secrets page and in `shipkit needs` (collections/providers.ts reads the
6
- // `cred <name> missing` line), while the check itself is about the world, so
7
- // it never blocks a gate and runs in the health pass only.
1
+ // the gh login the toolkit itself runs on: `init` and `start` create a
2
+ // PRIVATE repo with it, every push and release goes through it, SK007 reads
3
+ // visibility with it. gh keeps that login outside any secrets manager, so the
4
+ // house repo's one `github-login` check (a machine fact, asked once, never in
5
+ // every repo) runs `probeGhLogin`. It is a `core` credential: red on the
6
+ // secrets page and in `shipkit needs` (collections/providers.ts reads the
7
+ // `cred <name> missing` line).
8
8
  //
9
9
  // A private repo needs the classic `repo` scope. The token itself is never
10
10
  // asked for: `gh auth status --json` carries no token field, and the text
11
11
  // answer masks it. github.com unreachable is `unreachable`, a WARN that
12
12
  // measured nothing, never a lost login.
13
13
  import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
14
- import { check } from '../check.ts'
14
+ import type { CheckCtx } from './check-ctx.ts'
15
15
 
16
16
  const HOST = 'github.com'
17
17
 
18
- /** the secrets page row this check proves: a built-in `core` credential
19
- * every repo has, shown once SK021 has run there */
18
+ /** the id of the house check that runs `probeGhLogin` */
19
+ export const GH_LOGIN_CHECK = 'github-login'
20
+
21
+ /** the secrets page row the probe proves: a built-in `core` credential, shown
22
+ * in the repo where the `github-login` check has run */
20
23
  export const GH_LOGIN_CRED = {
21
24
  name: 'gh/github/login',
22
25
  service: 'gh login',
23
26
  level: 'core' as const,
24
- probedBy: 'SK021',
27
+ probedBy: GH_LOGIN_CHECK,
25
28
  mintUrl: 'https://cli.github.com/manual/gh_auth_login',
26
29
  /** what the row offers to copy when no failing line names a better fix */
27
30
  command: 'gh auth login',
@@ -76,48 +79,36 @@ export function readGhText(out: string): GhLogin {
76
79
  return { state: 'ok', login: login[1]!, scopes: scopesOf(scopes?.[1]) }
77
80
  }
78
81
 
79
- export const githubLogin = check({
80
- id: 'SK021',
81
- // about the world, never this repo: a lost login warns in a gate and is
82
- // red on the secrets page and in needs, through the core credential row
83
- blocking: false,
84
- title: 'github login',
85
- description: 'gh is logged in to github.com with the repo scope shipkit creates repos with',
86
- owner: 'build/shipkit/built-in-checks.md',
87
- when: 'health',
88
- ttl: '1h',
89
- run: async (c) => {
90
- const cred = GH_LOGIN_CRED.name
91
- // the switch that keeps every github step off (git-bootstrap.ts), which every test suite sets
92
- if ((process.env['SHIPKIT_NO_GITHUB'] ?? '') !== '') return void c.lines.push({ kind: 'skip', text: 'gh login not probed, SHIPKIT_NO_GITHUB is set' })
93
- const gh = c.tool('gh')
94
- if (gh == null)
95
- return c.fail(`cred ${cred} missing: gh is not installed, so shipkit cannot create a repo, push or release`, 'install gh (https://cli.github.com), then run `gh auth login`')
96
-
97
- let r = await c.exec([gh, 'auth', 'status', '--hostname', HOST, '--active', '--json', 'hosts'], { cwd: c.root, timeoutMs: 20_000 })
98
- let seen: GhLogin
99
- if (r.code !== 0 && /unknown flag/i.test(r.out)) {
100
- r = await c.exec([gh, 'auth', 'status', '--hostname', HOST], { cwd: c.root, timeoutMs: 20_000 })
101
- seen = readGhText(r.out)
102
- } else seen = r.timedOut ? { state: 'unreachable', why: 'gh auth status timed out' } : readGhJson(r.out)
82
+ /** the probe: logged in to github.com, with the repo scope */
83
+ export async function probeGhLogin(c: CheckCtx): Promise<void> {
84
+ const cred = GH_LOGIN_CRED.name
85
+ // the switch that keeps every github step off (git-bootstrap.ts), which every test suite sets
86
+ if ((process.env['SHIPKIT_NO_GITHUB'] ?? '') !== '') return void c.lines.push({ kind: 'skip', text: 'gh login not probed, SHIPKIT_NO_GITHUB is set' })
87
+ const gh = c.tool('gh')
88
+ if (gh == null)
89
+ return c.fail(`cred ${cred} missing: gh is not installed, so shipkit cannot create a repo, push or release`, 'install gh (https://cli.github.com), then run `gh auth login`')
103
90
 
104
- switch (seen.state) {
105
- case 'none':
106
- return c.fail(`cred ${cred} missing: gh is not logged in to ${HOST}, so shipkit cannot create a repo, push or release`, 'gh auth login')
107
- case 'refused':
108
- return c.fail(`cred ${cred} missing: the gh login from ${seen.source} was refused by ${HOST} (${seen.why.slice(0, 120)})`, 'gh auth login')
109
- case 'unreachable':
110
- return c.unreachable(`gh login unverified: ${HOST} unreachable (${seen.why.slice(0, 120)})`)
111
- case 'unreadable':
112
- return c.unreachable(`gh login unverified: gh answered something unreadable (${seen.why})`)
113
- case 'ok':
114
- if (seen.scopes == null)
115
- return c.warn(`gh is logged in to ${HOST} as ${seen.login}, but its token lists no oauth scopes (a fine grained or app token), so creating a private repo is unverified`, 'gh auth login')
116
- if (!seen.scopes.includes('repo'))
117
- return c.fail(`cred ${cred} missing: the gh login ${seen.login} lacks the repo scope (it has ${seen.scopes.join(', ')}), so shipkit cannot create a private repo`, `gh auth refresh -h ${HOST} -s repo`)
118
- return c.ok(`gh is logged in to ${HOST} as ${seen.login}, with the repo scope`)
119
- }
120
- },
121
- })
91
+ let r = await c.exec([gh, 'auth', 'status', '--hostname', HOST, '--active', '--json', 'hosts'], { cwd: c.root, timeoutMs: 20_000 })
92
+ let seen: GhLogin
93
+ if (r.code !== 0 && /unknown flag/i.test(r.out)) {
94
+ r = await c.exec([gh, 'auth', 'status', '--hostname', HOST], { cwd: c.root, timeoutMs: 20_000 })
95
+ seen = readGhText(r.out)
96
+ } else seen = r.timedOut ? { state: 'unreachable', why: 'gh auth status timed out' } : readGhJson(r.out)
122
97
 
123
- export default githubLogin
98
+ switch (seen.state) {
99
+ case 'none':
100
+ return c.fail(`cred ${cred} missing: gh is not logged in to ${HOST}, so shipkit cannot create a repo, push or release`, 'gh auth login')
101
+ case 'refused':
102
+ return c.fail(`cred ${cred} missing: the gh login from ${seen.source} was refused by ${HOST} (${seen.why.slice(0, 120)})`, 'gh auth login')
103
+ case 'unreachable':
104
+ return c.unreachable(`gh login unverified: ${HOST} unreachable (${seen.why.slice(0, 120)})`)
105
+ case 'unreadable':
106
+ return c.unreachable(`gh login unverified: gh answered something unreadable (${seen.why})`)
107
+ case 'ok':
108
+ if (seen.scopes == null)
109
+ return c.warn(`gh is logged in to ${HOST} as ${seen.login}, but its token lists no oauth scopes (a fine grained or app token), so creating a private repo is unverified`, 'gh auth login')
110
+ if (!seen.scopes.includes('repo'))
111
+ return c.fail(`cred ${cred} missing: the gh login ${seen.login} lacks the repo scope (it has ${seen.scopes.join(', ')}), so shipkit cannot create a private repo`, `gh auth refresh -h ${HOST} -s repo`)
112
+ return c.ok(`gh is logged in to ${HOST} as ${seen.login}, with the repo scope`)
113
+ }
114
+ }
@@ -116,7 +116,7 @@ const KEY_RE = /(?:^|[{,])\s*(['"]?)identity\1\s*:\s*/gm
116
116
 
117
117
  /** per char of the text: the object depth inside `defineRepo({` after that
118
118
  * char (1 on the top level keys), or -1 inside a string or a comment */
119
- function configDepths(text: string): Int32Array {
119
+ export function configDepths(text: string): Int32Array {
120
120
  const depth = new Int32Array(text.length).fill(0)
121
121
  const open = /defineRepo\(\s*\{/.exec(text)
122
122
  if (open == null) return depth
@@ -0,0 +1,14 @@
1
+ // what a repo declares about itself and its root unit, read from its config.
2
+ // Its own module, free of any fs import, so a check that asks it stays keyed
3
+ // on what it read.
4
+ import type { RepoConfig } from '../repo-config.ts'
5
+
6
+ /** the github visibility this repo declares (absent = 'private') */
7
+ export const declaredVisibility = (c: RepoConfig): 'private' | 'public' => c.visibility ?? 'private'
8
+
9
+ /** declared public: strangers read the tracked tree */
10
+ export const declaredPublic = (c: RepoConfig): boolean => c.visibility === 'public'
11
+
12
+ /** the root unit declares an npm target. Declared, never inferred from a
13
+ * missing `private` flag, so an app that forgot the flag is still an app */
14
+ export const rootPublishesNpm = (c: RepoConfig): boolean => c.release?.npm === true
@@ -1,8 +1,8 @@
1
1
  // NOTHING GROWS UNBOUND: every store shipkit writes declares a cap here (an
2
2
  // age, a count, a size), and one sweep enforces all of them. The daemon runs
3
- // it on boot and hourly for every repo it loads and for the machine; rule
4
- // SK023 fails when a store is past its cap, so a sweep that stopped running
5
- // is loud. Each store's sweep lives beside the code that writes it, so a gate
3
+ // it on boot and hourly for every repo it loads and for the machine; the
4
+ // house repo's one `stores-under-cap` check fails when a store is past its
5
+ // cap, so a sweep that stopped running is loud. Each store's sweep lives beside the code that writes it, so a gate
6
6
  // on a machine with no daemon still keeps its own stores in bounds.
7
7
  //
8
8
  // The one constraint a reader breaks first: a sweep never removes what a live
@@ -45,8 +45,8 @@ export type Store = {
45
45
  sweep(now: number): void | Promise<void>
46
46
  }
47
47
 
48
- /** how far past its cap a store may drift between two sweeps before SK023
49
- * calls it over: the daemon sweeps hourly, and gates write in between */
48
+ /** how far past its cap a store may drift between two sweeps before the
49
+ * house check calls it over: the daemon sweeps hourly, and gates write in between */
50
50
  export const SLACK = { factor: 2, ageMs: 2 * 60 * 60 * 1000 }
51
51
 
52
52
  /** why a store is past its cap (with the drift a sweep interval allows), or
@@ -59,6 +59,15 @@ export function overCap(use: Use, cap: Cap, now: number, slack = SLACK): string
59
59
  return null
60
60
  }
61
61
 
62
+ /** a cap in words: `200 entries, 30d, 5.0 MB a file` */
63
+ export const capWords = (s: Store): string =>
64
+ [
65
+ s.cap.maxCount != null ? `${s.cap.maxCount} entries` : null,
66
+ s.cap.maxAgeMs != null ? fmtAge(s.cap.maxAgeMs) : null,
67
+ s.cap.maxFileBytes != null ? `${fmtBytes(s.cap.maxFileBytes)} a file` : null,
68
+ s.cap.maxBytes != null ? fmtBytes(s.cap.maxBytes) : null,
69
+ ].filter((x) => x != null).join(', ')
70
+
62
71
  export const fmtBytes = (n: number): string => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`)
63
72
  export const fmtAge = (ms: number): string => (ms >= 86_400_000 ? `${Math.round(ms / 86_400_000)}d` : ms >= 3_600_000 ? `${Math.round(ms / 3_600_000)}h` : `${Math.round(ms / 60_000)}m`)
64
73
 
@@ -0,0 +1,7 @@
1
+ // built-in check ids that no longer exist, and where their question went. An
2
+ // id is never reused, so a doc or a changelog may still name one, and
3
+ // discovery refuses a built-in that takes one again.
4
+ export const RETIRED_CHECK_IDS: Readonly<Record<string, string>> = {
5
+ SK021: "the gh login is a machine fact: the house repo's github-login check runs src/core/gh-login.ts",
6
+ SK023: "the store caps are the machine daemon's health: the house repo's stores-under-cap check",
7
+ }
@@ -37,6 +37,9 @@ export type CheckOpts = {
37
37
  status?: boolean
38
38
  /** line sink — commands pass their context stream; defaults to console */
39
39
  print?: (line: string) => void
40
+ /** where the lines nobody acts on go: the parked list and the green
41
+ * summary. `shipkit ci` shows them only under --verbose. Default: print */
42
+ detail?: (line: string) => void
40
43
  /** per-CHECK completion tap: the real status, whether it replayed, what it
41
44
  * cost. The demotion of a non-blocking failure happens in the LINES (a
42
45
  * FAIL is rewritten to a warn before anything sees it), so `status` here
@@ -197,10 +200,11 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
197
200
  bail: o.bail,
198
201
  color: o.color,
199
202
  print: sink,
203
+ detail: o.detail,
200
204
  onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
201
205
  })
202
206
  const lines: Line[] = []
203
- const say = (l: Line): void => { lines.push(l); print(formatLine(l, false)) }
207
+ const say = (l: Line, to: (line: string) => void = print): void => { lines.push(l); to(formatLine(l, false)) }
204
208
  // a declaration in a shape the check file replaced is reported by check
205
209
  // SK016, which also carries the fixer that rewrites it: one owner, one
206
210
  // line, one command, rather than a warning per surface.
@@ -210,7 +214,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
210
214
  // page just replaced
211
215
  if (summary.parked.length > 0) {
212
216
  const head = summary.parked.slice(0, 8).join(', ')
213
- say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` })
217
+ say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` }, o.detail ?? print)
214
218
  }
215
219
  // the survey dies with the run that made it, and only the run that made it
216
220
  if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
@@ -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'
@@ -85,6 +86,10 @@ export type CiOpts = {
85
86
  tests?: TestTier
86
87
  /** who started this run, as the run record files it */
87
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
88
93
  }
89
94
 
90
95
  export type { TestTier } from '../ledger/gate-checks.ts'
@@ -107,10 +112,26 @@ function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
107
112
  return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
108
113
  }
109
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
+
110
128
  export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
111
129
  const print = o.print ?? ((l: string) => console.log(l))
112
130
  const collected: string[] = []
113
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) }
114
135
  let c = o.config ?? (await loadRepoConfig(root))
115
136
  // mechanical failures are not information, so they never cost a round trip
116
137
  let fixFailed = false
@@ -123,7 +144,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
123
144
  // back as a test failure.
124
145
  const narrowed = (o.onlyIds ?? []).length > 0
125
146
  if (o.fix !== false) {
126
- 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 })
127
148
  fixFailed = pass.failed
128
149
  // a fixer that rewrote the config or moved a check file leaves the
129
150
  // config above describing a repo that is gone, so the gate reads the
@@ -134,20 +155,22 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
134
155
  return 1
135
156
  }
136
157
  }
137
- } else sink('skip: fix pass (--no-fix)')
158
+ } else detail('skip: fix pass (--no-fix)')
138
159
  const tier: TestTier = o.tests ?? envTier()
139
- // announced BEFORE the run, so a log that died mid-way still says which
140
- // tier it was asking for
141
- 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'
142
163
  ? `ok: test tier FULL — every suite, the slow ones included (${FULL_TESTS_ENV}=1)`
143
164
  : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
144
- for (const l of poolLines()) sink(l)
165
+ for (const l of poolLines()) (l.startsWith('WARN') ? sink : detail)(l)
145
166
  // a floor slot nothing could fill is VISIBLE — a thin gate must never look
146
167
  // like a full one (the fresh-repo floor gap)
147
168
  const floor = await allTasks(c, root)
148
- 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)
149
172
  for (const u of floor.unfilled) {
150
- sink(`skip: ${u.id} (${u.reason})`)
173
+ (asked(u.id) ? sink : detail)(`skip: ${u.id} (${u.reason})`)
151
174
  // asked for BY NAME and unfillable: this is where an agent lands after
152
175
  // the harness refused it the raw runner, so the line says what to add
153
176
  // rather than leaving it with `unknown check id` and no next move
@@ -210,7 +233,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
210
233
  // visible as a shrinking undeclared one.
211
234
  const decided = reg.checks - reg.burnDown.length - reg.declared
212
235
  if (reg.burnDown.length > 0 || decided > 0)
213
- 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)`)
214
237
  if (ledger.layer.failure != null)
215
238
  sink(`WARN the git scan failed, so NOTHING comes from the cache and nothing is stored this run — ${ledger.layer.failure}`)
216
239
  } catch (e) {
@@ -225,13 +248,14 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
225
248
  onCheck: o.onCheck,
226
249
  reportHome: false,
227
250
  print: sink,
251
+ detail,
228
252
  })
229
253
  // what the suite ACTUALLY ran, from its own headline. A flag that was set
230
254
  // proves the intent; the count proves the tests
231
255
  // proves the intent; the count proves the tests. Every test check counts,
232
256
  // one per unit
233
257
  const counts = run.results.map((r) => /Ran (\d+) tests?/.exec(r.lastLine)?.[1]).filter((n) => n != null).map(Number)
234
- 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` : ''}`)
235
259
  for (const u of run.undeclared) {
236
260
  sink(`FAIL: ${u} does not declare whether it blocks`)
237
261
  sink(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
@@ -248,16 +272,23 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
248
272
  advisoryFatal = true
249
273
  sink(`FAIL: ${run.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${run.advisoryFailures.join(', ')}`)
250
274
  sink(' fix: shipkit check')
251
- } 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(', ')}`)
252
276
  }
253
277
  // the post-run re-hash, then the store. NOTHING was written while the gate
254
278
  // ran: a check whose inputs moved under it answered about a tree that no
255
279
  // longer exists, and this is where that result is thrown away instead of
256
280
  // stored under a key that describes the file as it was before the edit.
257
- 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))
258
284
  // one write for the whole gate, after everything has finished
259
285
  for (const l of runLog.flush()) sink(formatLine(l, false))
260
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)
261
292
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
262
293
  // and never fires under frozen: a bump/release gate must not re-dirty the
263
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/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