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
@@ -0,0 +1,58 @@
1
+ // SK025 release drift (build/shipkit/built-in-checks.md): every unit this repo
2
+ // releases matches what npm serves and what its push remote holds, the same
3
+ // survey `shipkit releases` prints. It replaces a drift copy per page. A repo
4
+ // that releases nothing (a private root with no release config and no units)
5
+ // skips it. A world probe: the registry and the remote move on a publish this
6
+ // tree cannot see, so a clean answer keeps for a day, keyed on every unit's
7
+ // version so a local bump asks again at once.
8
+ import { check, type CheckCtx } from '../check.ts'
9
+ import { parseWireOutput } from '../core/wire.ts'
10
+ import type { RepoConfig } from '../repo-config.ts'
11
+ import { attentionLines, collectReports, type UnitReport } from '../release/run-releases.ts'
12
+ import { resolveUnits, type ReleaseUnit } from '../release/units.ts'
13
+
14
+ /** the units this repo releases: every declared one, and the root only when
15
+ * it publishes to npm or the repo declares a release config */
16
+ export function releasedUnits(root: string, config: RepoConfig): ReleaseUnit[] {
17
+ return resolveUnits(root, config).units.filter((u) => u.id !== 'root' || u.targets.npm || config.release != null)
18
+ }
19
+
20
+ /** the survey as check lines: one ok per unit in step, else its attention lines */
21
+ export function reportDrift(c: CheckCtx, reports: UnitReport[]): void {
22
+ for (const r of reports) {
23
+ const lines = attentionLines(r)
24
+ if (lines.length === 0) {
25
+ // "in sync with npm" only for a unit that HAS an npm target and whose
26
+ // registry read succeeded (attentionLines warns when it did not)
27
+ c.ok(r.npmTarget ? `${r.name}@${r.version} in sync with npm` : `${r.name}@${r.version}, no npm target, tag state only`)
28
+ continue
29
+ }
30
+ for (const l of parseWireOutput(lines.join('\n'))) {
31
+ if (l.kind === 'warn' || l.kind === 'fail') c.warn(l.text)
32
+ else if (l.kind === 'ok') c.ok(l.text)
33
+ else c.note(l.text)
34
+ }
35
+ }
36
+ }
37
+
38
+ export const releaseDrift = check({
39
+ id: 'SK025',
40
+ blocking: false,
41
+ title: 'release drift',
42
+ description: 'every unit this repo releases matches npm and its push remote',
43
+ owner: 'build/shipkit/built-in-checks.md',
44
+ when: 'health',
45
+ ttl: '20h',
46
+ every: (last) => (last?.status === 'pass' ? ['20h', '28h'] : '4h'),
47
+ planOff: (env) => (releasedUnits(env.root, env.config).length === 0 ? 'this repo releases nothing' : null),
48
+ cacheKey: (env) => releasedUnits(env.root, env.config).map((u) => `${u.id}@${u.version}`).join(','),
49
+ run: async (c) => {
50
+ const ids = new Set(releasedUnits(c.root, c.config).map((u) => u.id))
51
+ if (ids.size === 0) return void c.lines.push({ kind: 'skip', text: 'this repo releases nothing: no npm root, no release config, no units' })
52
+ const { reports, errors } = await collectReports(c.root, c.config)
53
+ for (const e of errors) c.warn(e)
54
+ reportDrift(c, reports.filter((r) => ids.has(r.id)))
55
+ },
56
+ })
57
+
58
+ export default releaseDrift
@@ -0,0 +1,44 @@
1
+ // SK026 asks auto close (build/shipkit/built-in-checks.md): probe every open
2
+ // ask's own `check:` and archive the ones that pass, so an act done in the
3
+ // world leaves the queue without anyone editing a file. An open queue is no
4
+ // warning: the hub and `shipkit needs` show it. A repo with no queue skips.
5
+ import { existsSync } from 'fs'
6
+ import { join } from 'path'
7
+ import { checkAsks, listAsks } from '../asks/asks-queue.ts'
8
+ import { check } from '../check.ts'
9
+ import { LAYOUT } from '../core/layout.ts'
10
+
11
+ export const asksAutoclose = check({
12
+ id: 'SK026',
13
+ blocking: false,
14
+ title: 'asks auto close',
15
+ description: 'no open ask whose own probe passes stays in the queue',
16
+ owner: 'build/shipkit/built-in-checks.md',
17
+ when: 'health',
18
+ never: "runs each open ask's own probe, a shell command that reads the world",
19
+ every: ['20h', '28h'],
20
+ // it only probes an ask that carries one, and archives one marked done
21
+ planOff: (env) => {
22
+ const dir = join(env.root, env.config.asks?.dir ?? LAYOUT.asks)
23
+ if (!existsSync(dir)) return 'no asks queue'
24
+ return listAsks(dir).some((a) => a.check != null || a.status === 'done') ? null : 'no open ask carries a probe'
25
+ },
26
+ run: async (c) => {
27
+ const dir = join(c.root, c.config.asks?.dir ?? LAYOUT.asks)
28
+ if (!existsSync(dir)) return void c.lines.push({ kind: 'skip', text: 'no asks queue in this repo' })
29
+ // archiving RENAMES files, and the release path runs the checks between
30
+ // its clean tree guard and `npm publish`: frozen means report only
31
+ if (c.frozen) return void c.lines.push({ kind: 'skip', text: 'asks not probed: a frozen run archives nothing' })
32
+ const events: string[] = []
33
+ await checkAsks(c.root, dir, (l) => events.push(l))
34
+ // `closed + archived <file>` / `archived <file>` are the queue's frozen stdout contract
35
+ for (const l of events) {
36
+ if (l.startsWith('closed') || l.startsWith('archived')) c.ok(`ask ${l}`)
37
+ // the queue could not run (another agent holds the write lease)
38
+ else if (l.startsWith('WARN')) c.warn(l.replace(/^WARN\s*/, ''))
39
+ }
40
+ if (!c.lines.some((l) => l.kind === 'ok')) c.ok('no open ask has a probe that passes')
41
+ },
42
+ })
43
+
44
+ export default asksAutoclose
@@ -0,0 +1,50 @@
1
+ // SK027 agent wiki links (build/shipkit/built-in-checks.md): a repo's agent/
2
+ // IS an md wiki (entry index.md, the CLAUDE.md include), so an orphan page or
3
+ // a dangling ref there fails. A link that leaves agent/ but stays in the repo
4
+ // is checked on disk; a pointer out of the repo (a house wiki) is backticked,
5
+ // never a bare → or @. A repo without agent/index.md skips.
6
+ import { existsSync, readFileSync } from 'fs'
7
+ import { basename, join } from 'path'
8
+ import { check } from '../check.ts'
9
+ import { lintMd } from '../wiki/lint-md.ts'
10
+
11
+ export const agentWikiLinks = check({
12
+ id: 'SK027',
13
+ blocking: true,
14
+ title: 'agent wiki links',
15
+ description: 'every agent/ page is linked from somewhere, and every agent/ link resolves',
16
+ owner: 'meta/agent-folder.md',
17
+ // the md lint walks agent/ on disk rather than through `c`: agent/ is all it reads
18
+ inputs: ['agent'],
19
+ run: (c) => {
20
+ if (!existsSync(join(c.root, 'agent/index.md'))) return void c.lines.push({ kind: 'skip', text: 'no agent/ wiki in this repo' })
21
+ const lint = lintMd({ root: join(c.root, 'agent'), index: 'index.md', boundary: c.root })
22
+ const linked = lint.pages.filter((p) => !lint.orphans.includes(p))
23
+ for (const f of lint.orphans) {
24
+ const by = namingPage(c.root, f, linked)
25
+ const where = by != null ? `${by}, which already names it (turn that mention into the link)` : 'agent/index.md or the page of its topic'
26
+ c.fail(`agent/${f}: orphan, unreachable from agent/index.md: add a markdown link to it from ${where}; delete it only if the page is dead`)
27
+ }
28
+ for (const d of lint.dangling) c.fail(`agent/${d.from}: dangling ref → ${d.token}, target not found on disk: fix the path or delete the pointer (a pointer out of this repo, into a house wiki or another repo, goes in backticks)`)
29
+ if (!c.failed) c.ok('agent/ has no orphans and no dangling refs')
30
+ },
31
+ })
32
+
33
+ /** the linked page whose text already names an orphan (its path, its file
34
+ * name, or a glob its file name matches), the one to turn into a link */
35
+ function namingPage(root: string, orphan: string, pages: string[]): string | null {
36
+ const name = basename(orphan)
37
+ const stem = name.replace(/\.md$/, '')
38
+ for (const p of pages) {
39
+ const text = readFileSync(join(root, 'agent', p), 'utf8')
40
+ if (text.includes(orphan) || text.includes(name) || text.includes(`${stem}.`) || text.includes(`${stem} `)) return `agent/${p}`
41
+ for (const m of text.matchAll(/[\w.-]*\*[\w.*-]*/g)) {
42
+ const g = m[0]
43
+ if (g.replace(/[*.]/g, '').length < 3) continue
44
+ if (new Bun.Glob(g).match(name) || new Bun.Glob(`${g}.md`).match(name)) return `agent/${p}`
45
+ }
46
+ }
47
+ return null
48
+ }
49
+
50
+ export default agentWikiLinks
@@ -0,0 +1,83 @@
1
+ // SK028 readme (build/shipkit/built-in-checks.md): the README.md a repo's hub
2
+ // page shows exists and carries what makes the repo recognizable at a glance.
3
+ // A list of named requirements, each reporting its own line, so a new
4
+ // mandatory item is one more entry. Read through `c` only, so the answer is
5
+ // keyed on README.md and the images it points to, never on a clock.
6
+ import { posix } from 'path'
7
+ import { check } from '../check.ts'
8
+ import type { CheckCtx } from '../core/check-ctx.ts'
9
+
10
+ const README = 'README.md'
11
+
12
+ /** every image source the README shows: markdown `![..](src)` and html
13
+ * `<img src>`, code fences and inline code left out, since a sample there
14
+ * shows nothing */
15
+ export const imageRefs = (md: string): string[] => {
16
+ const prose = md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').replace(/`[^`\n]*`/g, '')
17
+ const refs: { at: number; src: string }[] = []
18
+ for (const m of prose.matchAll(/!\[[^\]]*\]\(\s*<?([^\s)>]+)>?(?:\s+["'(][^)]*)?\)/g)) refs.push({ at: m.index, src: m[1]! })
19
+ for (const m of prose.matchAll(/<img\b[^>]*?\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) refs.push({ at: m.index, src: (m[1] ?? m[2] ?? m[3])! })
20
+ return refs.sort((a, b) => a.at - b.at).map((r) => r.src)
21
+ }
22
+
23
+ /** a status badge (ci, version, license) shows the repo's state, never what it looks like */
24
+ const isBadge = (src: string): boolean => /(^|[/.])(shields\.io|badgen\.net)\//i.test(src) || /badge[^/]*\.svg([?#]|$)/i.test(src)
25
+
26
+ /** where an image source lands: an https url, a repo file, or nowhere with why */
27
+ const resolveImage = (c: CheckCtx, src: string): { ok: string } | { bad: string } => {
28
+ if (isBadge(src)) return { bad: `${src.slice(0, 60)} is a badge` }
29
+ if (/^https:\/\//i.test(src)) return { ok: src }
30
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('//')) return { bad: `${src.slice(0, 60)} is neither an https url nor a repo file` }
31
+ let rel: string
32
+ try {
33
+ rel = posix.normalize(decodeURI(src.replace(/[?#].*$/, '')).replace(/^\/+/, ''))
34
+ } catch {
35
+ return { bad: `${src} is not a readable path` }
36
+ }
37
+ if (rel === '.' || rel.startsWith('../')) return { bad: `${src} points out of the repo` }
38
+ return c.exists(rel, 'file') ? { ok: rel } : { bad: `${rel} is missing` }
39
+ }
40
+
41
+ type Finding = { ok: string } | { warn: string; fix: string }
42
+ type Requirement = { name: string; test: (c: CheckCtx, md: string) => Finding }
43
+
44
+ /** what every README carries, in order. Add an entry to make one more item mandatory */
45
+ const REQUIREMENTS: Requirement[] = [
46
+ {
47
+ name: 'screenshot',
48
+ test: (c, md) => {
49
+ const found = imageRefs(md).map((src) => resolveImage(c, src))
50
+ const shown = found.find((f): f is { ok: string } => 'ok' in f)
51
+ if (shown != null) return { ok: `${README} shows a screenshot: ${shown.ok}` }
52
+ const why = found.map((f) => (f as { bad: string }).bad)
53
+ return {
54
+ warn: `${README}: screenshot: ${why.length === 0 ? 'no image at all' : `no image resolves (${why.join(', ')})`}`,
55
+ fix: `add a screenshot to README.md: ![what it looks like](docs/screenshot.png), a file in the repo, or an https url; ${OFF}`,
56
+ }
57
+ },
58
+ },
59
+ ]
60
+
61
+ /** the other way out, for a repo that cannot show one: the rule off, with the
62
+ * reason `defineRepo` requires (core/when.ts) */
63
+ const OFF = `or, when this repo cannot show one, turn the rule off with the reason: when: { SK028: 'off:<why this repo shows no screenshot>' } in repo.config.ts, inside its shipkit gate block`
64
+
65
+ export const readme = check({
66
+ id: 'SK028',
67
+ blocking: false,
68
+ title: 'readme',
69
+ description: 'README.md exists and shows at least one screenshot',
70
+ owner: 'build/shipkit/built-in-checks.md',
71
+ run: (c) => {
72
+ const md = c.read(README)
73
+ if (md == null) return c.warn(`${README}: exists: no ${README} at the repo root`, `write README.md: what the repo is, and a screenshot of it; ${OFF}`)
74
+ c.ok(`${README} exists`)
75
+ for (const r of REQUIREMENTS) {
76
+ const f = r.test(c, md)
77
+ if ('ok' in f) c.ok(f.ok)
78
+ else c.warn(f.warn, f.fix)
79
+ }
80
+ },
81
+ })
82
+
83
+ export default readme
@@ -99,7 +99,7 @@ export class AssetsCheckCommand extends Command {
99
99
 
100
100
  static override usage = Command.Usage({
101
101
  category: 'Assets',
102
- description: 'kit loads, kinds valid, cflow workflows load, drafts present, backend reachable, identity outputs present',
102
+ description: 'kit loads, kinds valid, cflow workflows load, drafts present, identity outputs present',
103
103
  examples: [['health-check the asset pipeline', 'shipkit assets check']],
104
104
  })
105
105
 
package/src/cli/cmd_Ci.ts CHANGED
@@ -24,6 +24,7 @@ export class CiCommand extends Command {
24
24
  ['run everything, take nothing from the cache', 'shipkit ci --fresh'],
25
25
  ['include an opt in check', 'shipkit ci --with up:itch'],
26
26
  ['in the background, read it back with shipkit status', 'shipkit ci --detach'],
27
+ ['every line, the ones nobody acts on included', 'shipkit ci --verbose'],
27
28
  ],
28
29
  })
29
30
 
@@ -38,6 +39,8 @@ export class CiCommand extends Command {
38
39
  fresh = Option.Boolean('--fresh', false, { description: 'run every check: no answer comes from the cache, and what this run finds is stored fresh' })
39
40
  full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
40
41
 
42
+ verbose = Option.Boolean('--verbose', false, { description: 'print every line, the ones nobody acts on included: a clean fix pass, the machine pool, the test tier, the cache declarations, the checks not in this run, the cache counts' })
43
+
41
44
  detach = Option.Boolean('--detach', false, { description: 'start this gate in the background and return at once with a run id. `shipkit status <id>` prints it. The gate is an ordinary one: same cache, same run record' })
42
45
  job = Option.String('--job', { hidden: true })
43
46
 
@@ -72,6 +75,7 @@ export class CiCommand extends Command {
72
75
  ...this.only.flatMap((v) => ['--only', v]),
73
76
  ...(this.fresh ? ['--fresh'] : []),
74
77
  ...(this.full ? ['--full'] : []),
78
+ ...(this.verbose ? ['--verbose'] : []),
75
79
  ]
76
80
  }
77
81
 
@@ -92,6 +96,7 @@ export class CiCommand extends Command {
92
96
  onlyIds: this.only,
93
97
  fresh: this.fresh,
94
98
  ...(this.full ? { tests: 'full' as const } : {}),
99
+ ...(this.verbose ? { verbose: true } : {}),
95
100
  }
96
101
  const print = (l: string): void => { this.context.stdout.write(`${l}\n`) }
97
102
  // on a machine with the home, the repo's hub runs it (home/via-hub.ts)
@@ -0,0 +1,119 @@
1
+ // shipkit cross-repo-activity: the activity page in the console. The rows come
2
+ // from activityOf, the one computation the daemon's route serves the page, so
3
+ // the two can never disagree; this file only renders them as text. It reads
4
+ // local git and each repo's run record directly and needs no daemon: when one
5
+ // answers, its plan says which checks each repo declares today, as the page's
6
+ // status dot counts them.
7
+ import { homedir } from 'os'
8
+ import { Command, Option } from 'clipanion'
9
+ import { activityOf, type Declared } from '../home/activity.ts'
10
+ import { ageShort, clampCap, DEFAULT_CAP, DEFAULT_MINUTES, dirtWords, idleRow, upstreamWords, type ActivityAnswer, type ActivityCommit, type ActivityRow } from '../home/activity-shape.ts'
11
+ import { railRepos } from '../serve/project-gutter-route.ts'
12
+
13
+ /** a span as typed: `10m`, `1h`, `3d`, `90` (minutes); null when it is none */
14
+ export function spanMinutes(v: string): number | null {
15
+ const m = /^(\d+(?:\.\d+)?)\s*(m|min|h|d)?$/.exec(v.trim())
16
+ if (m == null) return null
17
+ const n = Number(m[1]) * (m[2] === 'h' ? 60 : m[2] === 'd' ? 1440 : 1)
18
+ return n > 0 ? Math.round(n) : null
19
+ }
20
+
21
+ /** single dash spellings of the long options, as typed: `-max-commit 3` */
22
+ export function aliasArgs(argv: string[]): string[] {
23
+ return argv.map((a) => (/^-max-(commit|age)(=|$)/.test(a) ? `-${a}` : a))
24
+ }
25
+
26
+ /** the declared check ids from a running daemon's plan; null when none answers */
27
+ export async function daemonDeclared(port = Number(process.env.SHIPKIT_DAEMON_PORT ?? 4798)): Promise<Declared | null> {
28
+ try {
29
+ const r = await fetch(`http://127.0.0.1:${port}/api/home/planned.json`, { signal: AbortSignal.timeout(1_500) })
30
+ if (!r.ok) return null
31
+ const j = (await r.json()) as { rows: { repo: string; check: string }[] }
32
+ const by = new Map<string, Set<string>>()
33
+ for (const row of j.rows) {
34
+ if (!by.has(row.repo)) by.set(row.repo, new Set())
35
+ by.get(row.repo)!.add(row.check)
36
+ }
37
+ return (name) => by.get(name) ?? null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ const dot = (r: ActivityRow): string => {
44
+ const c = r.status
45
+ if (c == null) return '·'
46
+ if (c.state === 'red') return `● ${c.reds.length} red`
47
+ if (c.state === 'orange') return `● ${c.warns.length} warn`
48
+ return '● green'
49
+ }
50
+
51
+ const commitLine = (c: ActivityCommit, now: number, old: boolean): string =>
52
+ ` ${ageShort(c.at, now).padStart(3)} ${c.sha} ${c.pushed ? '✓' : '↑'} ${c.branch == null ? '' : `[${c.branch}] `}${c.title}${old ? ' (last commit, before the window)' : ''}`
53
+
54
+ /** the page's rows as text: busy repos first, then the quiet ones, each with
55
+ * its status, branch, upstream, uncommitted line and commits */
56
+ export function renderActivity(a: ActivityAnswer, cap: number): string[] {
57
+ const out: string[] = []
58
+ const busy = a.rows.filter((r) => !idleRow(r))
59
+ const quiet = a.rows.filter(idleRow)
60
+ for (const r of [...busy, ...quiet]) {
61
+ const branch = r.branch ?? (r.detached == null ? '' : `detached ${r.detached}`)
62
+ out.push(`${r.name} ${dot(r)} ${branch} (${upstreamWords(r)})`)
63
+ for (const c of r.failing ?? []) {
64
+ out.push(` ${c.status === 'fail' ? '✗' : '!'} ${c.id}${c.at == null ? '' : ` (${ageShort(c.at, a.now)})`}`)
65
+ for (const l of c.lines) out.push(` ${l}`)
66
+ }
67
+ if (r.error != null) out.push(` git failed: ${r.error}`)
68
+ const dirt = r.error == null ? dirtWords(r) : ''
69
+ if (dirt !== '') {
70
+ out.push(` uncommitted: ${dirt}`)
71
+ const mark = { staged: 'S', modified: 'M', untracked: '?' } as const
72
+ for (const f of r.files) out.push(` ${mark[f.kind]} ${f.path}`)
73
+ }
74
+ for (const c of r.commits.slice(0, cap)) out.push(commitLine(c, a.now, false))
75
+ if (r.commits.length > cap) out.push(` … ${r.commits.length - cap} more`)
76
+ if (r.last != null) out.push(commitLine(r.last, a.now, true))
77
+ if (r.commits.length === 0 && r.last == null && r.error == null) out.push(' no commit yet')
78
+ }
79
+ out.push(`${busy.length} busy, ${quiet.length} quiet, read in ${a.ms}ms`)
80
+ return out
81
+ }
82
+
83
+ export class CrossRepoActivityCommand extends Command {
84
+ static override paths = [['cross-repo-activity'], ['activity']]
85
+
86
+ static override usage = Command.Usage({
87
+ category: 'Home',
88
+ description: 'the activity page in the console: every house repo (archived ones left out), its checks, branch, upstream, uncommitted work and commits in a window. The same computation the page reads, local git only, no daemon needed',
89
+ examples: [
90
+ ['the last 30 minutes, 3 commits per repo, as the page opens', 'shipkit cross-repo-activity'],
91
+ ['the same, by the page name', 'shipkit activity'],
92
+ ['the last 3 hours, up to 10 commits per repo', 'shipkit cross-repo-activity --max-age 3h --max-commit 10'],
93
+ ['the route\'s exact payload', 'shipkit cross-repo-activity --max-age 30m --json'],
94
+ ],
95
+ })
96
+
97
+ maxAge = Option.String('--max-age', { required: false, description: `how far back: 10m, 1h, 3d, or minutes (default ${DEFAULT_MINUTES}m)` })
98
+ maxCommit = Option.String('--max-commit', { required: false, description: `commits shown per repo, 1 to 15 (default ${DEFAULT_CAP}). -max-commit works too` })
99
+ json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, minutes, rows, ms }' })
100
+
101
+ async execute(): Promise<number> {
102
+ const minutes = this.maxAge == null ? DEFAULT_MINUTES : spanMinutes(this.maxAge)
103
+ if (minutes == null) {
104
+ this.context.stderr.write(`--max-age must be a span like 10m, 1h or 3d, got ${JSON.stringify(this.maxAge)}\n`)
105
+ return 2
106
+ }
107
+ const cap = clampCap(this.maxCommit ?? DEFAULT_CAP)
108
+ const declared = await daemonDeclared()
109
+ // HOME as the shell has it now: the house include names `~`
110
+ const a = await activityOf(railRepos(process.cwd(), process.env.HOME || homedir()), minutes, Date.now(), declared)
111
+ if (this.json) {
112
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
113
+ return 0
114
+ }
115
+ for (const l of renderActivity(a, cap)) this.context.stdout.write(`${l}\n`)
116
+ if (declared == null) this.context.stdout.write('note: no daemon answered, so every check a run record holds counts, declared today or not\n')
117
+ return 0
118
+ }
119
+ }
@@ -0,0 +1,155 @@
1
+ // shipkit global-processes: the overview's processes table in the console,
2
+ // shipkit's own (pm, the daemon, the web) first. The rows come from pm's own
3
+ // answer through process-rows.ts, the one model the page draws, so the two can
4
+ // never disagree; this file only asks pm and the daemon and renders them as
5
+ // text. With pm down it reads the declared units the way pm does
6
+ // (pm-config.ts) and marks each one unknown.
7
+ import { homedir } from 'os'
8
+ import { Command, Option } from 'clipanion'
9
+ import { daemonPort, daemonUrl, homePort, pmPort, pmUrl } from '../home/home-paths.ts'
10
+ import { houseRootOf } from '../home/home-start.ts'
11
+ import type { PmAnswer } from '../home/pm.ts'
12
+ import { coreGroup, declaredRow, groupRows, processGroups, stateWords, type ProcessGroup } from '../home/process-rows.ts'
13
+ import type { PmConfig } from '../home/pm-config.ts'
14
+ import { readPmConfig } from '../home/run-pm.ts'
15
+ import { railRepos } from '../serve/project-gutter-route.ts'
16
+
17
+ export type GlobalProcessesAnswer = {
18
+ /** pm as it answered, null when it did not */
19
+ pm: { pid: number; version: string } | null
20
+ now: number
21
+ groups: ProcessGroup[]
22
+ /** the unit files that would not load */
23
+ errors: string[]
24
+ /** with pm down: whether a house was found to read the declared units from */
25
+ house: boolean
26
+ }
27
+
28
+ export type GlobalProcessesDeps = {
29
+ pm: () => Promise<PmAnswer | null>
30
+ /** the daemon's identity, for its version; null when it does not answer */
31
+ daemon?: () => Promise<{ version: string } | null>
32
+ /** the declared units, read like pm reads them; null when no house is found */
33
+ declared: () => Promise<Pick<PmConfig, 'processes' | 'errors'> | null>
34
+ /** the project list's order */
35
+ order: string[]
36
+ now: number
37
+ }
38
+
39
+ /** the ports this shell would find pm and its children on */
40
+ const localPorts = () => ({ pm: pmPort(), daemon: daemonPort(), web: homePort() })
41
+
42
+ /** pm's answer, shipkit's own rows first; the declared units as unknown when
43
+ * pm does not answer */
44
+ export async function globalProcessesAnswer(d: GlobalProcessesDeps): Promise<GlobalProcessesAnswer> {
45
+ const [pm, daemon] = await Promise.all([d.pm(), d.daemon?.() ?? null])
46
+ const core = { daemonVersion: daemon?.version ?? null, ports: localPorts() }
47
+ if (pm != null) return { pm: { pid: pm.pid, version: pm.version }, now: d.now, groups: processGroups(pm, d.order, core), errors: pm.unitErrors, house: true }
48
+ const c = await d.declared()
49
+ return { pm: null, now: d.now, groups: [coreGroup(null, core), ...groupRows((c?.processes ?? []).map(declaredRow), d.order)], errors: c?.errors ?? [], house: c != null }
50
+ }
51
+
52
+ /** the daemon's identity at /hub.json, or null */
53
+ export async function askDaemon(url = daemonUrl()): Promise<{ version: string } | null> {
54
+ try {
55
+ const r = await fetch(`${url}/hub.json`, { signal: AbortSignal.timeout(3_000) })
56
+ const j = r.ok ? ((await r.json()) as { hub?: string; version?: string }) : null
57
+ return j?.hub === 'shipkit-home' && typeof j.version === 'string' ? { version: j.version } : null
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ /** pm's status answer, or null when it does not answer */
64
+ export async function askPm(url = pmUrl()): Promise<PmAnswer | null> {
65
+ try {
66
+ const r = await fetch(`${url}/api/pm/status.json`, { signal: AbortSignal.timeout(1_500) })
67
+ if (!r.ok) return null
68
+ const j = (await r.json()) as PmAnswer
69
+ return j.pm === 'shipkit-pm' ? j : null
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ /** the project list's order and the declared units, read from disk the way
76
+ * pm reads them: what the table falls back on while pm does not answer */
77
+ export function localProcessDeps(cwd: string): Pick<GlobalProcessesDeps, 'declared' | 'order'> {
78
+ // HOME as the shell has it now: the house include names `~`
79
+ const home = process.env.HOME || homedir()
80
+ let order: string[] = []
81
+ try {
82
+ order = railRepos(cwd, home).map((r) => r.name)
83
+ } catch {
84
+ // no rail from here: the groups sort by name
85
+ }
86
+ return {
87
+ order,
88
+ declared: async () => {
89
+ const root = houseRootOf(cwd)
90
+ if (root == null) return null
91
+ try {
92
+ return await readPmConfig(root)
93
+ } catch (e) {
94
+ return { processes: [], errors: [(e as Error).message] }
95
+ }
96
+ },
97
+ }
98
+ }
99
+
100
+ const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
101
+
102
+ /** the table as text: shipkit's own, then per repo, one line per process,
103
+ * its note and last log line under it */
104
+ export function renderGlobalProcesses(a: GlobalProcessesAnswer): string[] {
105
+ const out: string[] = []
106
+ const n = a.groups.reduce((k, g) => k + (g.core ? 0 : g.rows.length), 0)
107
+ if (a.pm != null) out.push(`pm pid ${a.pm.pid}, shipkit ${a.pm.version}: ${n} global process${n === 1 ? '' : 'es'}`)
108
+ else if (!a.house) out.push(`pm does not answer on ${pmUrl()}, and no house wiki is found from ${process.cwd()}, so no declared process is known`)
109
+ else out.push(`pm does not answer on ${pmUrl()}: the ${n} declared process${n === 1 ? '' : 'es'} below, each state unknown`)
110
+ const rows = a.groups.flatMap((g) => g.rows)
111
+ const w = {
112
+ state: Math.max(0, ...rows.map((r) => stateWords(r, a.now).length)),
113
+ label: Math.max(0, ...rows.map((r) => r.label.length)),
114
+ host: Math.max(0, ...rows.map((r) => (r.host ?? '').length)),
115
+ }
116
+ for (const g of a.groups) {
117
+ out.push(g.repo)
118
+ for (const r of g.rows) {
119
+ const cells = [` ● ${pad(stateWords(r, a.now), w.state)}`, pad(r.label, w.label), pad(r.host ?? '', w.host), pad(r.pid == null ? '' : `pid ${r.pid}`, 10), r.version == null ? '' : `shipkit ${r.version}`]
120
+ out.push(cells.join(' ').trimEnd())
121
+ if (r.note != null) out.push(` ${r.note}`)
122
+ // shipkit's own logs only matter while one is not up
123
+ if (r.lastLog != null && !(r.core && r.status === 'up')) out.push(` log: ${r.lastLog}`)
124
+ }
125
+ }
126
+ if (a.pm != null && n === 0) out.push('none declared: a repo declares one in a <name>.shipkit.ts file, as export default globalProcess({...})')
127
+ for (const e of a.errors) out.push(`WARN ${e}`)
128
+ return out
129
+ }
130
+
131
+ export class GlobalProcessesCommand extends Command {
132
+ static override paths = [['global-processes'], ['gp']]
133
+
134
+ static override usage = Command.Usage({
135
+ category: 'Home',
136
+ description: 'every process on the machine and its state, as the overview shows them: shipkit\'s own (pm, the daemon, the web, with their version) first, then per repo, one line per global process with its state, url, pid and last log line. When pm is down, the declared ones with their state unknown. Always exits 0',
137
+ examples: [
138
+ ['the table', 'shipkit global-processes'],
139
+ ['the same, short', 'shipkit gp'],
140
+ ['the rows the overview draws, as json', 'shipkit global-processes --json'],
141
+ ],
142
+ })
143
+
144
+ json = Option.Boolean('--json', false, { description: 'the rows as json: { pm, now, groups, errors, house }, groups exactly as the overview draws them' })
145
+
146
+ async execute(): Promise<number> {
147
+ const a = await globalProcessesAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), now: Date.now() })
148
+ if (this.json) {
149
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
150
+ return 0
151
+ }
152
+ for (const l of renderGlobalProcesses(a)) this.context.stdout.write(`${l}\n`)
153
+ return 0
154
+ }
155
+ }
@@ -8,7 +8,8 @@
8
8
  import { createHash } from 'crypto'
9
9
  import { Command, Option } from 'clipanion'
10
10
  import { loadRepoConfig } from '../repo-config.ts'
11
- import { envTier, FULL_TESTS_ENV, gateChecks } from '../ledger/gate-checks.ts'
11
+ import { envTier, FULL_TESTS_ENV, gateInventory } from '../ledger/gate-checks.ts'
12
+ import { planGap, plannable } from '../plan/repo-source.ts'
12
13
  import { KeyLayer } from '../ledger/git-key.ts'
13
14
  import { Ledger } from '../ledger/ledger.ts'
14
15
  import { gateBinary } from '../ledger/toolchain-identity.ts'
@@ -42,14 +43,22 @@ export class KeysCommand extends Command {
42
43
  // every task's env, which is in the key, so a surface that renders keys
43
44
  // without it renders a tier nobody asked for
44
45
  const tier = this.full ? 'full' : envTier()
45
- const decls = await gateChecks(root, c, { tier })
46
+ const rows = await gateInventory(root, c, { tier })
47
+ const decls = rows.map((r) => r.decl)
48
+ // the plan's side of the burn-down: a check nothing keys and that
49
+ // declares no cadence is re-asked on a guess. A built-in there is a
50
+ // toolkit bug, a repo check rides the default range until it declares
51
+ const planGaps = rows.filter(plannable).flatMap((r) => {
52
+ const gap = planGap(r)
53
+ return gap == null ? [] : [{ id: r.decl.id, from: r.from, gap }]
54
+ })
46
55
  const layer = KeyLayer.open(root, decls, { cliEntry: gateBinary() })
47
56
  const keys = layer.keys()
48
57
  const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
49
58
 
50
59
  if (this.json) {
51
60
  const drift = this.rescan ? layer.rescan() : null
52
- out(JSON.stringify({ tier, checks: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
61
+ out(JSON.stringify({ tier, checks: keys, burnDown: layer.registration.burnDown, planGaps, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
53
62
  return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
54
63
  }
55
64
 
@@ -115,6 +124,11 @@ export class KeysCommand extends Command {
115
124
  const decided = new Set(layer.decls.filter((d) => d.never != null && d.never !== '').map((d) => d.id))
116
125
  const shown = this.burnDown ? keys.filter((k) => k.population === 'none' && !decided.has(k.id)) : keys
117
126
  for (const k of shown) out(`${k.population.padEnd(7)} ${k.id.padEnd(28)} ${k.key ?? `— ${k.reason ?? ''}`}`)
127
+ if (planGaps.length > 0) out('')
128
+ for (const g of planGaps)
129
+ out(g.from === 'repo'
130
+ ? `WARN ${g.id}: ${g.gap}, so the plan runs it every 20h..28h. Declare inputs, a cacheKey, ttl or every`
131
+ : `FAIL: built-in ${g.id}: ${g.gap}. A toolkit bug: the plan waits for a save until it declares one`)
118
132
 
119
133
  const content = keys.filter((k) => k.population === 'content').length
120
134
  const reads = keys.filter((k) => k.population === 'reads').length