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
@@ -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
@@ -3,22 +3,51 @@
3
3
  // exit 0/1. FROZEN: consumers grep startsWith('FAIL'/'WARN'/'skip:') — a
4
4
  // prefix change is a breaking change of the whole toolkit.
5
5
 
6
- export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string }
6
+ import { CACHED, RAN } from './outcome-words.ts'
7
+
8
+ /** when a remembered world answer was measured, and until when it keeps
9
+ * (null: until its declaration changes). Kept as data, never as text, and
10
+ * rendered against the reader's clock: a time frozen into the line reads
11
+ * as a future expiry long after it passed */
12
+ export type Stamp = { at: number; until: number | null; cached?: boolean }
13
+
14
+ export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string; stamp?: Stamp }
15
+
16
+ /** a span in its one biggest unit: `45s`, `12m`, `3h`, `2d` */
17
+ export function spanShort(ms: number): string {
18
+ const s = Math.max(0, Math.round(ms / 1000))
19
+ if (s < 60) return `${s}s`
20
+ if (s < 3600) return `${Math.floor(s / 60)}m`
21
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
22
+ return `${Math.floor(s / 86400)}d`
23
+ }
24
+
25
+ /** `cached, ran 3h ago, kept 57m more`, `ran 1d ago, expired 1d ago` */
26
+ export function stampWords(s: Stamp, now: number): string {
27
+ const age = now - s.at
28
+ const ran = age < 1000 ? `${RAN} just now` : `${RAN} ${spanShort(age)} ago`
29
+ const keeps = s.until == null ? 'kept until its declaration changes' : s.until > now ? `kept ${spanShort(s.until - now)} more` : `expired ${spanShort(now - s.until)} ago`
30
+ return `${s.cached === true ? `${CACHED}, ` : ''}${ran}, ${keeps}`
31
+ }
32
+
33
+ /** a line's text as a reader sees it now, its stamp rendered */
34
+ export const lineText = (l: Line, now: number = Date.now()): string => (l.stamp == null ? l.text : `${l.text} (${stampWords(l.stamp, now)})`)
7
35
 
8
36
  const green = (s: string): string => `\x1b[32m${s}\x1b[0m`
9
37
  const red = (s: string): string => `\x1b[31m${s}\x1b[0m`
10
38
  const yellow = (s: string): string => `\x1b[33m${s}\x1b[0m`
11
39
 
12
- export function formatLine(l: Line, color: boolean): string {
40
+ export function formatLine(l: Line, color: boolean, now: number = Date.now()): string {
13
41
  const c = (f: (s: string) => string, s: string) => (color ? f(s) : s)
42
+ const text = lineText(l, now)
14
43
  switch (l.kind) {
15
- case 'ok': return `${c(green, 'ok:')} ${l.text}`
16
- case 'warn': return c(yellow, `WARN ${l.text}`)
17
- case 'fail': return c(red, `FAIL: ${l.text}`)
18
- case 'fix-warn': return ` ${c(yellow, `fix: ${l.text}`)}`
19
- case 'fix-fail': return ` ${c(red, `fix: ${l.text}`)}`
20
- case 'skip': return `skip: ${l.text}`
21
- case 'note': return l.text
44
+ case 'ok': return `${c(green, 'ok:')} ${text}`
45
+ case 'warn': return c(yellow, `WARN ${text}`)
46
+ case 'fail': return c(red, `FAIL: ${text}`)
47
+ case 'fix-warn': return ` ${c(yellow, `fix: ${text}`)}`
48
+ case 'fix-fail': return ` ${c(red, `fix: ${text}`)}`
49
+ case 'skip': return `skip: ${text}`
50
+ case 'note': return text
22
51
  }
23
52
  }
24
53
 
@@ -49,6 +78,17 @@ export function parseDuration(d: string | number): number {
49
78
  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
79
  }
51
80
 
81
+ /** `ttl: 'forever'`: a DECIDED fact (a repo's visibility, an account that
82
+ * exists). It moves only when someone decides to move it, and that decision
83
+ * is the declared value in its key, so a clean answer never expires */
84
+ export const FOREVER = 'forever'
85
+
86
+ /** what a forever answer keeps for: no clock ends it, no sweep drops it */
87
+ export const FOREVER_MS = Number.MAX_SAFE_INTEGER
88
+
89
+ /** how long a clean answer keeps, from a declared ttl. 0 = none declared */
90
+ export const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === '' ? 0 : ttl === FOREVER ? FOREVER_MS : parseDuration(ttl))
91
+
52
92
  /** absolute local stamp (2026-08-05 09:12:34), never a relative age: a
53
93
  * relative render changes every run and turns committed STATUS.md
54
94
  * checkpoints into noise diffs */
@@ -12,6 +12,7 @@
12
12
  // `shipkit check`, so the fix travels through that one code path.
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
+ import { isArchived } from '../core/archived.ts'
15
16
  import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
16
17
  import { isBreaking } from '../core/update-policy.ts'
17
18
 
@@ -84,7 +85,7 @@ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[
84
85
  continue
85
86
  }
86
87
  const pkg = readPkg(dir)
87
- if (pkg == null) continue
88
+ if (pkg == null || isArchived(dir)) continue
88
89
  const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
89
90
  const names = Object.keys(all).filter((n) => n.startsWith(prefix))
90
91
  if (names.length === 0) continue
@@ -0,0 +1,138 @@
1
+ // the activity page's shapes and its few pure rules, apart from the git
2
+ // reader so the page bundle takes them without node's modules. The words
3
+ // here are what the page and `shipkit cross-repo-activity` both print.
4
+ import type { CheckState } from '../serve/project-gutter.ts'
5
+
6
+ /** the windows the page offers, in minutes */
7
+ export const WINDOW_MINUTES = [10, 30, 60, 180, 720, 1440, 4320, 10080] as const
8
+ export const DEFAULT_MINUTES = 30
9
+ /** the most commits the daemon lists per repo, and the most uncommitted paths it names */
10
+ export const MAX_COMMITS = 100
11
+ export const MAX_FILES = 80
12
+
13
+ export type FileKind = 'staged' | 'modified' | 'untracked'
14
+
15
+ export type ActivityCommit = {
16
+ sha: string
17
+ /** committer time, epoch ms */
18
+ at: number
19
+ title: string
20
+ author: string
21
+ /** the branch it sits on when it is not on the checked out one, else null */
22
+ branch: string | null
23
+ /** a remote holds it; false on a branch with no upstream */
24
+ pushed: boolean
25
+ }
26
+
27
+ export type ActivityRow = {
28
+ name: string
29
+ /** the checked out branch; null when detached (see `detached`) */
30
+ branch: string | null
31
+ /** the short sha HEAD sits on when detached */
32
+ detached: string | null
33
+ /** the upstream as configured, null when the branch has none */
34
+ upstream: string | null
35
+ /** the upstream is configured but no longer exists */
36
+ gone: boolean
37
+ /** commits ahead of and behind the upstream, null without one */
38
+ ahead: number | null
39
+ behind: number | null
40
+ counts: Record<FileKind, number>
41
+ /** the uncommitted paths, at most MAX_FILES; a path both staged and
42
+ * modified is listed once, as modified */
43
+ files: { kind: FileKind; path: string }[]
44
+ /** in the window, newest first */
45
+ commits: ActivityCommit[]
46
+ /** HEAD's latest commit when nothing was committed in the window, shown
47
+ * grey; null when the window holds a commit, or the repo has none */
48
+ last: ActivityCommit | null
49
+ /** its checks as its run record last saw them, only the ids it declares
50
+ * today when that is known; null when shipkit never ran there */
51
+ status: CheckState
52
+ /** the checks behind a red or orange status, failing first: what the
53
+ * status column lists on hover and on click */
54
+ failing?: StatusCheck[]
55
+ /** why git could not answer, else null */
56
+ error: string | null
57
+ }
58
+
59
+ /** one failing or warning check as the run record last saw it */
60
+ export type StatusCheck = {
61
+ id: string
62
+ /** its title as the repo declares it, else its id */
63
+ title: string
64
+ status: 'fail' | 'warn'
65
+ /** when that outcome was recorded, epoch ms */
66
+ at: number | null
67
+ /** the worst line it printed about the problem, '' when it printed none */
68
+ line: string
69
+ /** every fail and warn line it printed, the worst weight first: a check
70
+ * reporting three findings lists three */
71
+ lines: string[]
72
+ }
73
+
74
+ export type ActivityAnswer = { now: number; minutes: number; rows: ActivityRow[]; ms: number }
75
+
76
+ export type ActivityRepo = { name: string; path: string }
77
+
78
+ /** the window a request names, when it is one the page offers */
79
+ export function windowMinutes(v: string | null): number {
80
+ const n = Number(v)
81
+ return (WINDOW_MINUTES as readonly number[]).includes(n) ? n : DEFAULT_MINUTES
82
+ }
83
+
84
+ /** every house repo ships from main: shown green, any other orange, a
85
+ * leftover master included so the repo still to rename stands out */
86
+ export const isMainBranch = (b: string | null): boolean => b === 'main'
87
+
88
+ /** nothing committed in the window and nothing uncommitted: it sits below the busy repos */
89
+ export const idleRow = (r: ActivityRow): boolean =>
90
+ r.error == null && r.commits.length === 0 && r.counts.staged + r.counts.modified + r.counts.untracked === 0
91
+
92
+ /** the most commits a repo shows: a number from CAP_MIN to CAP_MAX */
93
+ export const CAP_MIN = 1
94
+ export const CAP_MAX = 15
95
+ export const DEFAULT_CAP = 3
96
+
97
+ /** a window's label on the toggle: `10m`, `3h`, `3d` */
98
+ export const windowWords = (m: number): string => (m < 60 ? `${m}m` : m < 4320 ? `${m / 60}h` : `${m / 1440}d`)
99
+
100
+ /** a typed cap, as a whole number in CAP_MIN..CAP_MAX; the default when it is no number */
101
+ export function clampCap(v: unknown): number {
102
+ const n = Math.round(Number(v))
103
+ return Number.isFinite(n) && String(v).trim() !== '' ? Math.min(CAP_MAX, Math.max(CAP_MIN, n)) : DEFAULT_CAP
104
+ }
105
+
106
+ /** an age in one short unit: `now`, `4m`, `2h`, `3d` */
107
+ export function ageShort(at: number, now: number): string {
108
+ const s = Math.max(0, Math.round((now - at) / 1000))
109
+ if (s < 60) return 'now'
110
+ if (s < 3600) return `${Math.floor(s / 60)}m`
111
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
112
+ return `${Math.floor(s / 86400)}d`
113
+ }
114
+
115
+ /** the uncommitted work in one line: `3 modified, 1 untracked`; '' when clean */
116
+ export function dirtWords(r: ActivityRow): string {
117
+ const c = r.counts
118
+ return [c.staged > 0 ? `${c.staged} staged` : '', c.modified > 0 ? `${c.modified} modified` : '', c.untracked > 0 ? `${c.untracked} untracked` : '']
119
+ .filter((x) => x !== '')
120
+ .join(', ')
121
+ }
122
+
123
+ /** where the branch stands against its upstream, for the branch's hover */
124
+ export function upstreamWords(r: ActivityRow): string {
125
+ if (r.upstream == null) return 'no upstream: nothing on this branch is pushed'
126
+ if (r.gone) return `${r.upstream} is gone`
127
+ const a = r.ahead ?? 0
128
+ const b = r.behind ?? 0
129
+ if (a === 0 && b === 0) return `even with ${r.upstream}`
130
+ return [a > 0 ? `${a} unpushed` : '', b > 0 ? `${b} behind` : ''].filter((x) => x !== '').join(', ') + ` against ${r.upstream}`
131
+ }
132
+
133
+ /** the uncommitted paths, one per line, for the hover */
134
+ export const filesWords = (r: ActivityRow): string => {
135
+ const mark = { staged: 'S', modified: 'M', untracked: '?' } as const
136
+ const shown = r.files.map((f) => `${mark[f.kind]} ${f.path}`)
137
+ return (r.files.length >= MAX_FILES ? [...shown, '…'] : shown).join('\n')
138
+ }
@@ -0,0 +1,256 @@
1
+ // the activity page's data: for every house repo, its branch, how far it is
2
+ // from its upstream, what it holds uncommitted and what it committed in a
3
+ // window. Plain local git, never a fetch, so "ahead" is against the upstream
4
+ // as this disk last saw it.
5
+ //
6
+ // the one constraint a reader breaks first: this runs only when the page asks
7
+ // (no tick). A pass reads ACTIVITY_CONCURRENCY repos at once, and an answer is
8
+ // shared for ACTIVITY_TTL_MS per window, never served past it, so a page left
9
+ // open costs about one pass a minute however many tabs show it, and a closed
10
+ // one costs nothing.
11
+ import { gitBin } from '../core/git-bin.ts'
12
+ import { readLastStatusReadonly } from '../ledger/run-log.ts'
13
+ import { checkStateOf, recordOf } from '../serve/project-gutter.ts'
14
+ import { lineText, type Line } from '../core/wire.ts'
15
+ import { MAX_COMMITS, MAX_FILES, type ActivityAnswer, type ActivityCommit, type ActivityRepo, type ActivityRow, type FileKind, type StatusCheck } from './activity-shape.ts'
16
+
17
+ export * from './activity-shape.ts'
18
+
19
+ /** how long one window's answer is shared between readers: half the page's
20
+ * 60s cadence, so tabs a few seconds apart share a pass and none is shown
21
+ * an answer older than this */
22
+ export const ACTIVITY_TTL_MS = 30_000
23
+
24
+ export type ParsedStatus = Pick<ActivityRow, 'branch' | 'detached' | 'upstream' | 'gone' | 'ahead' | 'behind' | 'counts' | 'files'>
25
+
26
+ /** `git status --porcelain=v2 --branch`, read */
27
+ export function parseStatus(text: string): ParsedStatus {
28
+ const out: ParsedStatus = { branch: null, detached: null, upstream: null, gone: false, ahead: null, behind: null, counts: { staged: 0, modified: 0, untracked: 0 }, files: [] }
29
+ let oid = ''
30
+ const add = (kind: FileKind, path: string): void => {
31
+ if (out.files.length < MAX_FILES) out.files.push({ kind, path })
32
+ }
33
+ for (const line of text.split('\n')) {
34
+ if (line.startsWith('# branch.oid ')) oid = line.slice(13)
35
+ else if (line.startsWith('# branch.head ')) {
36
+ const h = line.slice(14)
37
+ if (h !== '(detached)') out.branch = h
38
+ } else if (line.startsWith('# branch.upstream ')) out.upstream = line.slice(18)
39
+ else if (line.startsWith('# branch.ab ')) {
40
+ const m = /^\+(\d+) -(\d+)$/.exec(line.slice(12))
41
+ if (m != null) {
42
+ out.ahead = Number(m[1])
43
+ out.behind = Number(m[2])
44
+ }
45
+ } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
46
+ // `1 XY sub mH mI mW hH hI path`, `2 … Xscore path<TAB>orig`
47
+ const f = line.split(' ')
48
+ const xy = f[1] ?? '..'
49
+ const path = f.slice(line.startsWith('1 ') ? 8 : 9).join(' ').split('\t')[0]!
50
+ if (xy[0] !== '.') out.counts.staged++
51
+ if (xy[1] !== '.') out.counts.modified++
52
+ add(xy[1] !== '.' ? 'modified' : 'staged', path)
53
+ } else if (line.startsWith('u ')) {
54
+ out.counts.modified++
55
+ add('modified', line.split(' ').slice(10).join(' '))
56
+ } else if (line.startsWith('? ')) {
57
+ out.counts.untracked++
58
+ add('untracked', line.slice(2))
59
+ }
60
+ }
61
+ if (out.branch == null && oid !== '' && oid !== '(initial)') out.detached = oid.slice(0, 7)
62
+ if (out.upstream != null && out.ahead == null) out.gone = true
63
+ return out
64
+ }
65
+
66
+ const SEP = '\x1f'
67
+ const END = '\x1e'
68
+ const FORMAT = `--format=%h${SEP}%ct${SEP}%an${SEP}%S${SEP}%s${SEP}%H${END}`
69
+
70
+ /** `git log` in FORMAT, read; `unpushed` holds the full shas no remote has */
71
+ export function parseLog(text: string, onHead: boolean, unpushed: (full: string) => boolean = () => false): ActivityCommit[] {
72
+ const out: ActivityCommit[] = []
73
+ for (const rec of text.split(END)) {
74
+ const f = rec.replace(/^\n/, '').split(SEP)
75
+ if (f.length < 6) continue
76
+ const src = f[3]!.replace(/^refs\/heads\//, '')
77
+ out.push({ sha: f[0]!, at: Number(f[1]) * 1000, author: f[2]!, branch: onHead || src === '' ? null : src, title: f[4]!, pushed: !unpushed(f[5]!) })
78
+ }
79
+ return out
80
+ }
81
+
82
+ async function git(cwd: string, args: string[]): Promise<{ code: number; out: string; err: string }> {
83
+ // no optional locks: a look never takes the index lock an agent's commit needs
84
+ const p = Bun.spawn([gitBin(), ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })
85
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
86
+ return { code, out, err: err.trim() }
87
+ }
88
+
89
+ /** which check ids a repo declares today: the daemon's plan; null = not
90
+ * known, every recorded check counts */
91
+ export type Declared = (name: string) => ReadonlySet<string> | null
92
+
93
+ /** each declared check's title by id; null = not known, the id stands in */
94
+ export type Titles = (name: string) => ReadonlyMap<string, string> | null
95
+
96
+ /** every line a check printed about its problems: its fail and warn lines,
97
+ * those of its own weight first (a fail for a failure, a warning for a
98
+ * warning), else its first line that is not a pass, each without the id it
99
+ * leads with */
100
+ export function findingsOf(lines: string, status: 'fail' | 'warn', id: string): string[] {
101
+ let parsed: Line[]
102
+ try {
103
+ parsed = JSON.parse(lines) as Line[]
104
+ } catch {
105
+ return []
106
+ }
107
+ if (!Array.isArray(parsed)) return []
108
+ const of = (k: Line['kind']): Line[] => parsed.filter((l) => l.kind === k)
109
+ let hits = status === 'fail' ? [...of('fail'), ...of('warn')] : [...of('warn'), ...of('fail')]
110
+ if (hits.length === 0) hits = parsed.filter((l) => l.kind !== 'ok' && l.kind !== 'skip').slice(0, 1)
111
+ return hits.map((l) => {
112
+ const t = lineText(l).trim()
113
+ // `[ID] text`, and a runner line's `ID — text`: the list names the id already
114
+ for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
115
+ return t
116
+ })
117
+ }
118
+
119
+ /** the worst of them, '' when it printed none */
120
+ export const findingOf = (lines: string, status: 'fail' | 'warn', id: string): string => findingsOf(lines, status, id)[0] ?? ''
121
+
122
+ /** a repo's checks as its run record last saw them, counted over what it
123
+ * declares, and the failing and warning ones with what each said */
124
+ export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
125
+ const record = recordOf(repo.path)
126
+ if (record == null) return { status: null, failing: [] }
127
+ const rows = readLastStatusReadonly(record, { lines: true })
128
+ const status = checkStateOf(rows, declared?.(repo.name) ?? null)
129
+ if (status == null) return { status, failing: [] }
130
+ const byId = new Map(rows.map((r) => [r.id, r]))
131
+ const named = titles?.(repo.name) ?? null
132
+ const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
133
+ const r = byId.get(id)
134
+ const lines = r == null ? [] : findingsOf(r.lines, st, id)
135
+ return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: lines[0] ?? '', lines }
136
+ }
137
+ return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
138
+ }
139
+
140
+ /** one repo, from `since` (epoch ms) to now */
141
+ export async function repoActivity(repo: ActivityRepo, since: number, declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityRow> {
142
+ const empty = parseStatus('')
143
+ const row: ActivityRow = { name: repo.name, ...empty, commits: [], last: null, ...statusOf(repo, declared, titles), error: null }
144
+ try {
145
+ const iso = new Date(since).toISOString()
146
+ const range = [`--since=${iso}`, `--max-count=${MAX_COMMITS}`, '--no-show-signature', FORMAT]
147
+ // every ask that needs no other answer goes at once: git calls are
148
+ // ~5ms each but queue behind a busy daemon, so waiting for them one
149
+ // by one multiplies the wait. The upstream diff and the last commit
150
+ // are cheap enough to ask even when the answer goes unused
151
+ const [st, up, head, refs, one] = await Promise.all([
152
+ git(repo.path, ['status', '--porcelain=v2', '--branch']),
153
+ git(repo.path, ['rev-list', `--max-count=${MAX_COMMITS}`, '@{upstream}..HEAD']),
154
+ // a repo with no commit yet has no HEAD to walk: nothing committed
155
+ git(repo.path, ['log', ...range, 'HEAD']),
156
+ // the other local branches (an agent's worktree) whose tip moved in
157
+ // the window, minus what HEAD has: a repo with hundreds of branches
158
+ // walks only the few that moved
159
+ git(repo.path, ['for-each-ref', '--format=%(refname:short)%09%(committerdate:unix)%09%(upstream:short)%09%(upstream:track)', 'refs/heads']),
160
+ git(repo.path, ['log', '-1', '--no-show-signature', FORMAT, 'HEAD']),
161
+ ])
162
+ if (st.code !== 0) return { ...row, error: st.err || `git status exited ${st.code}` }
163
+ Object.assign(row, parseStatus(st.out))
164
+ // HEAD's unpushed commits: all of them without an upstream, else the
165
+ // `ahead` ones
166
+ const headAway = row.upstream == null || row.gone
167
+ const away = !headAway && (row.ahead ?? 0) > 0 && up.code === 0 ? new Set(up.out.split('\n').filter((l) => l !== '')) : new Set<string>()
168
+ const own = head.code === 0 ? parseLog(head.out, true, (h) => headAway || away.has(h)) : []
169
+ const moved = (refs.code === 0 ? refs.out.split('\n') : [])
170
+ .map((l) => l.split('\t'))
171
+ .filter((f) => f.length >= 2 && f[0] !== row.branch && Number(f[1]) * 1000 >= since)
172
+ // an upstream that is gone holds nothing
173
+ .map((f) => (f[3] === '[gone]' ? [f[0]!, f[1]!, ''] : f))
174
+ let other: ActivityCommit[] = []
175
+ if (moved.length > 0) {
176
+ const side = await git(repo.path, ['log', ...range, '--source', ...moved.map((f) => `refs/heads/${f[0]}`), '--not', 'HEAD', '--'])
177
+ // a side branch's commit is pushed once its own upstream holds it;
178
+ // on a failed ask every one reads unpushed, never pushed
179
+ const tracked = moved.filter((f) => (f[2] ?? '') !== '')
180
+ let sideAway = new Set<string>()
181
+ if (tracked.length > 0) {
182
+ const r = await git(repo.path, ['rev-list', `--since=${iso}`, ...tracked.map((f) => `refs/heads/${f[0]}`), '--not', ...tracked.map((f) => f[2]!), '--'])
183
+ if (r.code === 0) sideAway = new Set(r.out.split('\n').filter((l) => l !== ''))
184
+ else sideAway = new Set(['*'])
185
+ }
186
+ const untracked = new Set(moved.filter((f) => (f[2] ?? '') === '').map((f) => f[0]!))
187
+ other = side.code === 0 ? parseLog(side.out, false, (h) => sideAway.has('*') || sideAway.has(h)).map((c) => (c.branch != null && untracked.has(c.branch) ? { ...c, pushed: false } : c)) : []
188
+ }
189
+ row.commits = [...own, ...other].sort((a, b) => b.at - a.at).slice(0, MAX_COMMITS)
190
+ // an empty window still says what came last on HEAD
191
+ if (row.commits.length === 0) {
192
+ row.last = one.code === 0 ? parseLog(one.out, true, (h) => headAway || away.has(h))[0] ?? null : null
193
+ }
194
+ return row
195
+ } catch (e) {
196
+ return { ...row, error: (e as Error).message }
197
+ }
198
+ }
199
+
200
+ /** how many repos a pass reads at once: five git calls each, so a pass
201
+ * never has more than 30 git processes alive */
202
+ export const ACTIVITY_CONCURRENCY = 6
203
+
204
+ /** every repo, ACTIVITY_CONCURRENCY at a time, rows in the order given.
205
+ * THE computation: the daemon's route and `shipkit cross-repo-activity`
206
+ * both call it, and only render it differently */
207
+ export async function activityOf(repos: ActivityRepo[], minutes: number, now = Date.now(), declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityAnswer> {
208
+ const t0 = performance.now()
209
+ const rows: ActivityRow[] = new Array(repos.length)
210
+ let next = 0
211
+ const worker = async (): Promise<void> => {
212
+ while (next < repos.length) {
213
+ const i = next++
214
+ rows[i] = await repoActivity(repos[i]!, now - minutes * 60_000, declared, titles)
215
+ }
216
+ }
217
+ await Promise.all(Array.from({ length: Math.min(ACTIVITY_CONCURRENCY, repos.length) }, worker))
218
+ return { now, minutes, rows, ms: Math.round(performance.now() - t0) }
219
+ }
220
+
221
+ /** one answer per window, shared for ttlMs. Past it a read waits for a new
222
+ * pass: an old answer is never served as current. Callers during a pass
223
+ * share it. */
224
+ export class ActivityCache {
225
+ private kept = new Map<number, ActivityAnswer>()
226
+ private running = new Map<number, Promise<ActivityAnswer>>()
227
+ private ttlMs: number
228
+ private now: () => number
229
+ private declared: Declared | null
230
+ private titles: Titles | null
231
+
232
+ constructor(o: { ttlMs?: number; now?: () => number; declared?: Declared; titles?: Titles } = {}) {
233
+ this.ttlMs = o.ttlMs ?? ACTIVITY_TTL_MS
234
+ this.now = o.now ?? Date.now
235
+ this.declared = o.declared ?? null
236
+ this.titles = o.titles ?? null
237
+ }
238
+
239
+ private pass(repos: ActivityRepo[], minutes: number): Promise<ActivityAnswer> {
240
+ const going = this.running.get(minutes)
241
+ if (going != null) return going
242
+ const p = activityOf(repos, minutes, this.now(), this.declared, this.titles)
243
+ .then((a) => { this.kept.set(minutes, a); return a })
244
+ .finally(() => this.running.delete(minutes))
245
+ this.running.set(minutes, p)
246
+ return p
247
+ }
248
+
249
+ /** the kept answer while it is younger than the ttl, else a new pass.
250
+ * `fresh` always waits for a pass: the page's refresh button */
251
+ get(repos: ActivityRepo[], minutes: number, o: { fresh?: boolean } = {}): Promise<ActivityAnswer> {
252
+ const k = this.kept.get(minutes)
253
+ if (k == null || o.fresh === true || this.now() - k.now >= this.ttlMs) return this.pass(repos, minutes)
254
+ return Promise.resolve(k)
255
+ }
256
+ }
@@ -21,7 +21,8 @@ export type GlobalProcessDecl = {
21
21
  cwd: string
22
22
  url: string | null
23
23
  probe: string | null
24
- app: boolean
24
+ /** as declared: an emoji, or an image path relative to root */
25
+ icon: string | null
25
26
  actions: ActionDecl[]
26
27
  /** the repo that declares it, and the file, repo-relative */
27
28
  repo: string
@@ -51,7 +52,7 @@ export function toDecl(u: GlobalProcessUnit, repo: RepoRef, file: string): Globa
51
52
  cwd: resolve(repo.path, u.cwd ?? '.'),
52
53
  url: u.url ?? null,
53
54
  probe: u.probe ?? u.url ?? null,
54
- app: u.app === true,
55
+ icon: u.icon ?? null,
55
56
  actions: Object.entries(u.actions ?? {}).map(([id, a]) => ({ id, label: a.label, cmd: a.cmd ?? null })),
56
57
  repo: repo.name,
57
58
  root: repo.path,