rvlib-shipkit 1.2.0 → 1.4.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 (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -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
@@ -32,6 +32,8 @@ export const LAYOUT = {
32
32
  reviews: sk('reviews'),
33
33
  // scratch: plans, logs, leases, detached runs, screenshots
34
34
  tmp: sk('tmp'),
35
+ // built app bundles, one per global process opened as an app (home/gui-app.ts)
36
+ wrapper: sk('wrapper'),
35
37
  // generated on every run
36
38
  status: sk('STATUS.md'),
37
39
  statusFull: sk('STATUS.full.md'),
@@ -1,15 +1,20 @@
1
- // Report to the motherhome — upsert this repo's row into the docsBase
2
- // wiki's `build/shipkit/local-projects.md`, when that page ALREADY EXISTS
3
- // on this disk. The lib NEVER creates the page and never writes into a
4
- // relative or URL docsBase: the page is a tracked wiki file, so a real
5
- // docs checkout has it, and a consumer that merely set docsBase as a
6
- // fix-pointer prefix must never find surprise files in its docs tree.
7
- // The whole read-modify-write runs under the shared file lock
8
- // (core/locked-write) — concurrent reporters never lose rows or tear the
9
- // file; the loser skips and self-heals on its next run. Rows whose repo
10
- // path no longer exists are pruned on every write. Churn control: a row
11
- // rewrites only when its DATA changes or its last-seen day is older than
12
- // 7 days. Never fails the calling command.
1
+ // Report to the motherhome: a repo's row in the docsBase wiki's
2
+ // `build/shipkit/local-projects.md`, in two halves.
3
+ //
4
+ // - `reportToMotherhome` (every ci/check/init) files the row in the MACHINE
5
+ // DB and nothing else. A gate answers about its own repo, so it never
6
+ // writes, let alone commits, in the docs repo.
7
+ // - `landMotherhome` (the daemon's hourly sweep) renders every filed row into
8
+ // its page and lands the page as ONE commit, at most once per LAND_GAP_MS
9
+ // per page, only when a row's data changed or its last-seen day is older
10
+ // than STALE_DAYS.
11
+ //
12
+ // The lib NEVER creates the page and never reads a relative or URL docsBase:
13
+ // the page is a tracked wiki file, so a real docs checkout has it, and a
14
+ // consumer that merely set docsBase as a fix-pointer prefix must never find
15
+ // surprise files in its docs tree. The page's read-modify-write runs under
16
+ // the shared file lock (core/locked-write). Rows whose repo path no longer
17
+ // exists are pruned from the page and from the machine db.
13
18
  import { existsSync, readFileSync } from 'fs'
14
19
  import { homedir } from 'os'
15
20
  import { join, resolve } from 'path'
@@ -17,46 +22,31 @@ import type { RepoConfig } from '../repo-config.ts'
17
22
  import { atomicWrite, withFileLock } from './locked-write.ts'
18
23
  import { isLinkedWorktree } from './git-root.ts'
19
24
  import { machineCommit } from './machine-commit.ts'
25
+ import { machineDelete, machineGet, machineList, machineSet } from './machine-db.ts'
20
26
  import { LIB_VERSION } from './lib-version.ts'
21
27
 
22
28
  const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
23
29
  const END = '<!-- <<< shipkit local-projects -->'
24
30
  const STALE_DAYS = 7
31
+ /** the longest the page waits for a changed row: one commit a day at most */
32
+ export const LAND_GAP_MS = 24 * 60 * 60 * 1000
33
+ /** a row filed in the machine db, by its repo's path */
34
+ const ROW = 'motherhome:'
35
+ /** when a page last landed, by its path */
36
+ const LANDED = 'motherhome-landed:'
25
37
 
26
38
  type Row = { name: string; root: string; version: string; mode: string; seen: string }
39
+ type Filed = Row & { page: string }
27
40
 
28
41
  const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
29
42
 
30
- /**
31
- * Commit the row we just wrote, and ONLY that file.
32
- *
33
- * This page is machine written and rewrites whenever a repo's shipkit version
34
- * or consumption mode changes, so left alone it dirties the docs repo from a
35
- * gate run nobody associates with it, and the dirt then rides into an unrelated
36
- * commit or blocks a release. The row is not worth a human's attention in
37
- * either direction, so the machine that writes it also lands it.
38
- *
39
- * The scoping, the refusals and the escape hatch live in `core/machine-commit`,
40
- * which every machine written path commits through. This one fires only when
41
- * the row's DATA changed, not on every gate, so the hooks it runs are affordable.
42
- */
43
- function autoCommitRow(page: string, name: string): string {
44
- const landed = machineCommit({
45
- anchor: resolve(page, '..'),
46
- message: `chore(machine): refresh local-projects row (${name})`,
47
- paths: [page],
48
- })
49
- return landed ? ' + committed' : ''
50
- }
51
-
52
43
  const parseRow = (line: string): Row | null => {
53
44
  const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
54
45
  return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
55
46
  }
56
47
 
57
-
58
- // how this repo consumes the lib — `?` on an unreadable package.json (a
59
- // read failure must never be rendered as the fact 'path')
48
+ // how this repo consumes the lib: `?` on an unreadable package.json (a read
49
+ // failure must never be rendered as the fact 'path')
60
50
  function consumptionMode(root: string): string {
61
51
  try {
62
52
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
@@ -70,47 +60,79 @@ function consumptionMode(root: string): string {
70
60
 
71
61
  export type MotherhomeOutcome = { wrote: boolean; note?: string }
72
62
 
63
+ /** file this repo's row in the machine db. `wrote` says the filed row changed */
73
64
  export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
74
65
  try {
75
66
  if (config.docsBase == null) return { wrote: false }
76
67
  const base = config.docsBase.startsWith('~/') ? join(homedir(), config.docsBase.slice(2)) : config.docsBase
77
- // absolute local paths only — a relative docsBase would resolve
68
+ // absolute local paths only: a relative docsBase would resolve
78
69
  // against CWD, a URL is a display prefix
79
70
  if (!base.startsWith('/')) return { wrote: false }
80
71
  const page = join(base, 'build/shipkit/local-projects.md')
81
72
  if (!existsSync(page)) return { wrote: false }
82
- // a gate run inside a linked worktree must not write the MAIN
83
- // checkout's tracked page: the row it would add describes a temporary
84
- // side checkout, and the write lands as unexplained dirt in a tree
85
- // somebody else is working in. The worktree's own runs are covered
86
- // when its branch merges.
73
+ // a linked worktree is a temporary side checkout: its runs are
74
+ // covered when its branch merges
87
75
  if (isLinkedWorktree(root)) return { wrote: false, note: `motherhome: skipped (${config.name} is a linked worktree — its row belongs to the main checkout)` }
88
-
89
76
  const mode = consumptionMode(root)
90
77
  // source-consumed code is not the published release its version
91
- // string names — disclose it
78
+ // string names: disclose it
92
79
  const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
93
- const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
94
-
95
- const r = withFileLock(page, (): MotherhomeOutcome => {
96
- const lines = readFileSync(page, 'utf8').split('\n')
97
- const b = lines.indexOf(BEGIN)
98
- const e = lines.indexOf(END)
99
- if (b === -1 || e === -1 || e < b) return { wrote: false, note: `WARN motherhome upsert skipped — markers missing/corrupted in ${page}` }
100
- const rows = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
101
- const cur = rows.find((row) => row.root === me.root)
102
- const dataSame = cur != null && cur.name === me.name && cur.version === me.version && cur.mode === me.mode
103
- const fresh = cur != null && (Date.parse(today) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
104
- if (dataSame && fresh) return { wrote: false }
105
- // prune rows for repos that no longer exist at their recorded path
106
- const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
107
- atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
108
- const committed = autoCommitRow(page, me.name)
109
- return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})${committed}` }
110
- })
111
- // lock lost to a racing reporter — self-heals on the next run
112
- return r.ok ? r.value : { wrote: false }
80
+ const me: Filed = { page, name: config.name, root: resolve(root), version, mode, seen: today }
81
+ const key = `${ROW}${me.root}`
82
+ if (JSON.stringify(machineGet<Filed>(key)) === JSON.stringify(me)) return { wrote: false }
83
+ return machineSet(key, me) ? { wrote: true } : { wrote: false, note: 'WARN motherhome row not filed: the machine db could not be written' }
113
84
  } catch (err) {
114
- return { wrote: false, note: `WARN motherhome upsert failed (${String(err).slice(0, 120)}) — registry may be stale` }
85
+ return { wrote: false, note: `WARN motherhome row not filed (${String(err).slice(0, 120)})` }
86
+ }
87
+ }
88
+
89
+ /** a page's rows once the filed ones are merged in: a filed row replaces the
90
+ * page's only when its data changed or the page's day is STALE_DAYS old,
91
+ * and a row whose repo path is gone is pruned */
92
+ function mergeRows(onPage: Row[], filed: Row[]): Row[] {
93
+ const by = new Map(onPage.map((r) => [r.root, r]))
94
+ for (const f of filed) {
95
+ const cur = by.get(f.root)
96
+ const same = cur != null && cur.name === f.name && cur.version === f.version && cur.mode === f.mode
97
+ const fresh = cur != null && (Date.parse(f.seen) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
98
+ if (!(same && fresh)) by.set(f.root, { name: f.name, root: f.root, version: f.version, mode: f.mode, seen: f.seen })
99
+ }
100
+ return [...by.values()].filter((r) => existsSync(r.root)).sort((a, z) => a.name.localeCompare(z.name) || a.root.localeCompare(z.root))
101
+ }
102
+
103
+ /** render every filed row into its page and land each changed page as one
104
+ * commit, at most once per LAND_GAP_MS. Returns what to print */
105
+ export function landMotherhome(o: { now?: number; gapMs?: number } = {}): string[] {
106
+ const now = o.now ?? Date.now()
107
+ const notes: string[] = []
108
+ const filed = machineList<Filed>(ROW)
109
+ for (const f of filed) if (!existsSync(f.value.root)) machineDelete(f.name)
110
+ const pages = new Map<string, Row[]>()
111
+ for (const f of filed) if (existsSync(f.value.root)) pages.set(f.value.page, [...(pages.get(f.value.page) ?? []), f.value])
112
+ for (const [page, rows] of pages) {
113
+ try {
114
+ if (!existsSync(page)) continue
115
+ const last = machineGet<number>(`${LANDED}${page}`)
116
+ if (last != null && now - last < (o.gapMs ?? LAND_GAP_MS)) continue
117
+ const r = withFileLock(page, (): boolean => {
118
+ const text = readFileSync(page, 'utf8')
119
+ const lines = text.split('\n')
120
+ const b = lines.indexOf(BEGIN)
121
+ const e = lines.indexOf(END)
122
+ if (b === -1 || e === -1 || e < b) { notes.push(`WARN motherhome: markers missing or out of order in ${page}, rows not written`); return false }
123
+ const onPage = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
124
+ const next = [...lines.slice(0, b + 1), ...mergeRows(onPage, rows).map(rowLine), ...lines.slice(e)].join('\n')
125
+ if (next === text) return false
126
+ atomicWrite(page, next)
127
+ return true
128
+ })
129
+ if (!r.ok || !r.value) continue
130
+ machineSet(`${LANDED}${page}`, now)
131
+ const landed = machineCommit({ anchor: resolve(page, '..'), message: 'chore(machine): refresh local-projects rows', paths: [page] })
132
+ notes.push(`motherhome: ${page} rows refreshed${landed ? ' + committed' : ', not committed'}`)
133
+ } catch (err) {
134
+ notes.push(`WARN motherhome: ${page} not refreshed (${String(err).slice(0, 120)})`)
135
+ }
115
136
  }
137
+ return notes
116
138
  }
@@ -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
@@ -84,11 +87,8 @@ export type CheckOpts = {
84
87
  /** the gate binary that own ledger is keyed on (default: the one running
85
88
  * it). Ignored when `ledger` is passed, which carries its own. */
86
89
  cliEntry?: string
87
- /** refresh the motherhome registry row. runCi passes false and does it
88
- * ITSELF, after the ledger has committed: that row is a TRACKED file under
89
- * `wiki/`, so writing it before the post-run re-hash makes the gate look
90
- * like an agent editing the tree and throws away every result the run just
91
- * measured. */
90
+ /** file the motherhome row in the machine db (core/motherhome.ts). runCi
91
+ * passes false and does it ITSELF, once, after its verdict. */
92
92
  reportHome?: boolean
93
93
  }
94
94
 
@@ -197,10 +197,11 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
197
197
  bail: o.bail,
198
198
  color: o.color,
199
199
  print: sink,
200
+ detail: o.detail,
200
201
  onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
201
202
  })
202
203
  const lines: Line[] = []
203
- const say = (l: Line): void => { lines.push(l); print(formatLine(l, false)) }
204
+ const say = (l: Line, to: (line: string) => void = print): void => { lines.push(l); to(formatLine(l, false)) }
204
205
  // a declaration in a shape the check file replaced is reported by check
205
206
  // SK016, which also carries the fixer that rewrites it: one owner, one
206
207
  // line, one command, rather than a warning per surface.
@@ -210,7 +211,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
210
211
  // page just replaced
211
212
  if (summary.parked.length > 0) {
212
213
  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` : ''}` })
214
+ 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
215
  }
215
216
  // the survey dies with the run that made it, and only the run that made it
216
217
  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,19 +272,26 @@ 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
261
- // motherhome row (docsBase wiki on this disk) — never affects the gate,
262
- // and never fires under frozen: a bump/release gate must not re-dirty the
263
- // tree it just proved clean
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)
292
+ // the motherhome row, filed in the machine db and landed in the docsBase
293
+ // page by the daemon (core/motherhome.ts): never affects the gate, never
294
+ // writes in another repo, and a frozen run files nothing
264
295
  if (o.frozen !== true) {
265
296
  const mh = reportToMotherhome(root, c)
266
297
  if (mh.note != null) print(mh.note)