rvlib-shipkit 1.3.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 (80) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +4 -2
  4. package/src/assets/kits/identity-kit.ts +15 -13
  5. package/src/assets/run-assets.ts +12 -2
  6. package/src/check.ts +6 -4
  7. package/src/checks/SK001-package-json-shape.check.ts +20 -2
  8. package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
  9. package/src/checks/SK014-prose-ste.check.ts +16 -6
  10. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  11. package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
  12. package/src/checks/SK028-readme.check.ts +6 -2
  13. package/src/cli/cmd_CrossRepoActivity.ts +4 -1
  14. package/src/collections/collection-types.ts +15 -0
  15. package/src/collections/providers.ts +6 -2
  16. package/src/contracts/apply-contracts.ts +9 -8
  17. package/src/contracts/contract-types.ts +8 -2
  18. package/src/contracts/contracts-engine.ts +34 -13
  19. package/src/contracts/manifests/identity.contract.ts +6 -5
  20. package/src/contracts/manifests/social.contract.ts +4 -2
  21. package/src/contracts/manifests/steam.contract.ts +7 -3
  22. package/src/contracts/web-surface.ts +59 -0
  23. package/src/core/budget.ts +19 -9
  24. package/src/core/check-runner.ts +26 -5
  25. package/src/core/discover-checks.ts +8 -5
  26. package/src/core/layout.ts +2 -0
  27. package/src/core/motherhome.ts +88 -66
  28. package/src/core/run-check.ts +2 -5
  29. package/src/core/run-ci.ts +3 -3
  30. package/src/core/wire.ts +38 -9
  31. package/src/home/activity-shape.ts +4 -1
  32. package/src/home/activity.ts +22 -15
  33. package/src/home/gui-app/ShipkitGui.swift +144 -25
  34. package/src/home/gui-app.ts +93 -30
  35. package/src/home/home-launchd.ts +10 -2
  36. package/src/home/home-routes.ts +2 -1
  37. package/src/home/home-start.ts +33 -5
  38. package/src/home/machine-stores.ts +4 -2
  39. package/src/home/open-app.ts +23 -10
  40. package/src/home/pm-config.ts +15 -11
  41. package/src/home/pm-inputs.ts +76 -0
  42. package/src/home/pm.ts +3 -3
  43. package/src/home/process-rows.ts +23 -6
  44. package/src/home/resources.ts +67 -15
  45. package/src/home/run-daemon.ts +6 -1
  46. package/src/home/run-pm.ts +29 -5
  47. package/src/home/widgets.ts +32 -3
  48. package/src/hooks/commit-message.ts +2 -3
  49. package/src/hooks/run-hooks.ts +12 -2
  50. package/src/ledger/gate-checks.ts +3 -3
  51. package/src/ledger/store.ts +11 -0
  52. package/src/ledger/world-cache.ts +17 -15
  53. package/src/repo-config.ts +6 -1
  54. package/src/serve/hub-app/activity-view.tsx +3 -3
  55. package/src/serve/hub-app/checks-svc.ts +2 -1
  56. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  57. package/src/serve/hub-app/col-tables.ts +115 -0
  58. package/src/serve/hub-app/cols-svc.ts +28 -0
  59. package/src/serve/hub-app/dock.css +1 -1
  60. package/src/serve/hub-app/home-st.ts +1 -1
  61. package/src/serve/hub-app/home-strip.css +32 -17
  62. package/src/serve/hub-app/home-strip.tsx +99 -89
  63. package/src/serve/hub-app/hub-app.tsx +7 -1
  64. package/src/serve/hub-app/hub-types.ts +6 -3
  65. package/src/serve/hub-app/hub.css +24 -2
  66. package/src/serve/hub-app/icons.tsx +4 -0
  67. package/src/serve/hub-app/keys.ts +6 -6
  68. package/src/serve/hub-app/machine-st.ts +26 -37
  69. package/src/serve/hub-app/palette-st.ts +5 -5
  70. package/src/serve/hub-app/palette-view.tsx +1 -1
  71. package/src/serve/hub-app/project-list.tsx +3 -3
  72. package/src/serve/hub-app/repo-st.ts +1 -1
  73. package/src/serve/hub-app/runs-tabs.ts +1 -1
  74. package/src/serve/hub-app/timeline-view.tsx +14 -11
  75. package/src/serve/project-gutter.ts +1 -2
  76. package/src/serve/warm-cell.ts +49 -16
  77. package/src/suite/judge-suites.ts +42 -17
  78. package/src/suite/suite-cli.ts +5 -8
  79. package/src/wiki/lint-md.ts +2 -0
  80. package/src/wiki/scan-md.ts +37 -10
@@ -1,10 +1,10 @@
1
1
  // SK027 agent wiki links (build/shipkit/built-in-checks.md): a repo's agent/
2
2
  // IS an md wiki (entry index.md, the CLAUDE.md include), so an orphan page or
3
- // a dangling ref there fails. A cross wiki pointer (into a house wiki) is
4
- // backticked, never a bare → or @, which reads as an agent local link and
5
- // dangles. A repo without agent/index.md skips.
6
- import { existsSync } from 'fs'
7
- import { join } from 'path'
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
8
  import { check } from '../check.ts'
9
9
  import { lintMd } from '../wiki/lint-md.ts'
10
10
 
@@ -18,11 +18,33 @@ export const agentWikiLinks = check({
18
18
  inputs: ['agent'],
19
19
  run: (c) => {
20
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' })
22
- for (const f of lint.orphans) c.fail(`agent/${f}: orphan, unreachable from agent/index.md: link it from a page or delete it`)
23
- for (const d of lint.dangling) c.fail(`agent/${d.from}: dangling ref → ${d.token}, target not found (backtick a cross wiki ref; fix or delete the pointer)`)
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)`)
24
29
  if (!c.failed) c.ok('agent/ has no orphans and no dangling refs')
25
30
  },
26
31
  })
27
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
+
28
50
  export default agentWikiLinks
@@ -52,12 +52,16 @@ const REQUIREMENTS: Requirement[] = [
52
52
  const why = found.map((f) => (f as { bad: string }).bad)
53
53
  return {
54
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',
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
56
  }
57
57
  },
58
58
  },
59
59
  ]
60
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
+
61
65
  export const readme = check({
62
66
  id: 'SK028',
63
67
  blocking: false,
@@ -66,7 +70,7 @@ export const readme = check({
66
70
  owner: 'build/shipkit/built-in-checks.md',
67
71
  run: (c) => {
68
72
  const md = c.read(README)
69
- 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')
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}`)
70
74
  c.ok(`${README} exists`)
71
75
  for (const r of REQUIREMENTS) {
72
76
  const f = r.test(c, md)
@@ -60,7 +60,10 @@ export function renderActivity(a: ActivityAnswer, cap: number): string[] {
60
60
  for (const r of [...busy, ...quiet]) {
61
61
  const branch = r.branch ?? (r.detached == null ? '' : `detached ${r.detached}`)
62
62
  out.push(`${r.name} ${dot(r)} ${branch} (${upstreamWords(r)})`)
63
- if (r.status != null && r.status.reds.length > 0) out.push(` failing: ${r.status.reds.map((id) => (r.status!.since[id] == null ? id : `${id} (${ageShort(r.status!.since[id]!, a.now)})`)).join(', ')}`)
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
+ }
64
67
  if (r.error != null) out.push(` git failed: ${r.error}`)
65
68
  const dirt = r.error == null ? dirtWords(r) : ''
66
69
  if (dirt !== '') {
@@ -161,6 +161,19 @@ export type TestRun = {
161
161
  * declaration names a file that is not on disk */
162
162
  export type TestStanding = 'runs' | 'ignored' | 'orphan' | 'opaque' | 'missing'
163
163
 
164
+ /** an ask as its line on the asks page reads it: the frontmatter of its file */
165
+ export type AskFacts = {
166
+ kind: string
167
+ /** `YYYY-MM-DD`, `?` when the file carries none */
168
+ created: string
169
+ /** `YYYY-MM-DD` for a done ask, null otherwise or when it was not stamped */
170
+ closed: string | null
171
+ /** what it blocks, `-` for nothing named */
172
+ blocks: string
173
+ /** the shell probe that closes it once it exits 0, null for a manual ask */
174
+ probe: string | null
175
+ }
176
+
164
177
  /** one test file, as its row and its page read it */
165
178
  export type TestFacts = {
166
179
  /** repo relative */
@@ -243,6 +256,8 @@ export type Instance = {
243
256
  check?: CheckFacts
244
257
  /** test files only: where it runs and what it last cost */
245
258
  test?: TestFacts
259
+ /** asks only: what its frontmatter says */
260
+ ask?: AskFacts
246
261
  /** an open ask owns the action (dedup = acted-on once, never hidden):
247
262
  * the instance stays red HERE but verbs and pings live with the ask */
248
263
  askOwned?: boolean
@@ -17,12 +17,12 @@ import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
17
17
  // runtime and the canvas binding, ~400ms of module load, and the hub's
18
18
  // collection list is built on every request by callers that mostly never heal
19
19
  import type { healIdentity } from '../assets/run-assets.ts'
20
- import { doneAsks, openAsks } from '../asks/asks-queue.ts'
20
+ import { doneAsks, openAsks, type Ask } from '../asks/asks-queue.ts'
21
21
  import { evaluateContracts } from '../contracts/contracts-engine.ts'
22
22
  import { CheckCtx } from '../core/check-ctx.ts'
23
23
  import type { RepoConfig } from '../repo-config.ts'
24
24
  import { activeContracts, contractsConfigFrom } from '../contracts/apply-contracts.ts'
25
- import type { CheckFacts, Collection, Instance } from './collection-types.ts'
25
+ import type { AskFacts, CheckFacts, Collection, Instance } from './collection-types.ts'
26
26
  import { mdBody } from './md-body.ts'
27
27
  import { socialPostsCollection } from './posts.ts'
28
28
  import { assetState, secretState, type SecretLevel, type SecretProbe } from './state-rules.ts'
@@ -119,6 +119,8 @@ const slug = (t: string): string => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').
119
119
  /** wire lines re-rendered as a fenced block for an instance's md */
120
120
  const wireMd = (lines: string[]): string => `\`\`\`\n${lines.join('\n')}\n\`\`\``
121
121
 
122
+ const askFacts = (a: Ask): AskFacts => ({ kind: a.kind, created: a.created, closed: a.closed, blocks: a.blocks, probe: a.check })
123
+
122
124
  export function asksCollection(root: string, c: RepoConfig): Collection {
123
125
  return {
124
126
  id: 'asks',
@@ -141,6 +143,7 @@ export function asksCollection(root: string, c: RepoConfig): Collection {
141
143
  file: `${rel}/${a.file}`,
142
144
  md: mdBody(join(root, rel, a.file)),
143
145
  remarks: lastRemarks(join(root, rel, a.file)),
146
+ ask: askFacts(a),
144
147
  }))
145
148
  // the done/ archive rides along QUIET (no severity) — past answers
146
149
  // stay consultable and remarkable; newest first, after every open ask
@@ -155,6 +158,7 @@ export function asksCollection(root: string, c: RepoConfig): Collection {
155
158
  file: `${rel}/done/${a.file}`,
156
159
  md: mdBody(join(root, rel, 'done', a.file)),
157
160
  remarks: lastRemarks(join(root, rel, 'done', a.file)),
161
+ ask: askFacts(a),
158
162
  }))
159
163
  return [...open, ...done]
160
164
  },
@@ -10,21 +10,22 @@ import { tagCheckLines } from '../check.ts'
10
10
  import { defineScan } from '../scan.ts'
11
11
  import { allContracts } from './contract-manifests.ts'
12
12
  import type { ContractManifest } from './contract-types.ts'
13
+ import { webDeploys, webRootOf, type WebDisk } from './web-surface.ts'
13
14
  import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts-engine.ts'
14
15
 
15
- export function contractsConfigFrom(c: RepoConfig, d: Pick<ScanCtx, 'root' | 'exists'>): ContractsConfig {
16
+ export function contractsConfigFrom(c: RepoConfig, d: WebDisk & Pick<ScanCtx, 'root'>): ContractsConfig {
16
17
  const venues = c.venues ?? []
17
18
  const social = c.social ?? []
18
- const declared = [
19
- ...venues,
20
- ...(social.length > 0 ? ['social'] : []),
21
- ...(social.includes('discord') ? ['discord'] : []),
22
- ]
19
+ const declared = [...venues, ...(social.length > 0 ? ['social'] : [])]
20
+ const deploys = webDeploys(d)
21
+ // a public repo whose only surface is an npm package has no page to serve an identity on
22
+ const npmOnly = c.release?.npm === true && deploys.length === 0 && venues.length === 0
23
23
  return {
24
24
  root: d.root,
25
25
  declared,
26
26
  social,
27
- publicSurface: venues.length > 0 || d.exists('deploy.sh'),
27
+ publicSurface: venues.length > 0 || deploys.length > 0 || (c.visibility === 'public' && !npmOnly),
28
+ webRoot: webRootOf(c.contracts?.webRoot, deploys),
28
29
  exempt: c.contracts?.exempt,
29
30
  docsBase: c.docsBase,
30
31
  }
@@ -45,7 +46,7 @@ export function applyContractsResult(t: CheckCtx, r: ContractsResult, tag?: { id
45
46
  for (const l of lines) t.lines.push(l)
46
47
  if (r.exitCode === 1) t.failed = true
47
48
  if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
48
- if (r.lines.length === 0) {
49
+ if (r.lines.every((l) => l.kind === 'note')) {
49
50
  const text = 'no contracts apply (nothing declared, nothing detected)'
50
51
  t.ok(tag?.id != null ? `[${tag.id}] ${text}` : text)
51
52
  }
@@ -1,7 +1,8 @@
1
1
  // The data language `*.contract.ts` manifest files are written in: a venue
2
2
  // contract = folder + artifact rules + detectors. Evaluation engine lands in
3
3
  // ./contracts-engine.ts (0.1). All rule paths are REPO-ROOT-relative so a
4
- // contract can require files outside its own folder (e.g. public/og.png).
4
+ // contract can require files outside its own folder (e.g. {web}/og.png, where
5
+ // `{web}` is the repo's web root, contracts/web-surface.ts).
5
6
  // `warnOnly` demotes a rule to WARN — for mandates gated on a later step.
6
7
 
7
8
  export type RuleOpts = { warnOnly?: boolean }
@@ -25,9 +26,13 @@ export type Detector =
25
26
  | { kind: 'script'; pattern: string }
26
27
  | { kind: 'fileContains'; path: string; pattern: string }
27
28
  | { kind: 'dirExists'; path: string }
29
+ /** a file matching the glob holds a line matching the (multiline) pattern */
30
+ | { kind: 'fileMatches'; glob: string; pattern: string }
28
31
 
29
32
  export type ContractEnv = {
30
33
  root: string
34
+ /** the dir the site serves, what `{web}` in a rule path stands for */
35
+ webRoot: string
31
36
  venues: string[]
32
37
  social: string[]
33
38
  exists: (relPath: string) => boolean
@@ -47,7 +52,7 @@ export type ContractManifest = {
47
52
  owner: string
48
53
  /** `declared` = required once the repo names this venue. `always` = every repo, behind `gate` */
49
54
  appliesTo: 'declared' | 'always'
50
- /** `always` contracts only: FAIL once a public surface exists, WARN before */
55
+ /** `always` contracts only: applies once a public surface exists, silent before */
51
56
  gate?: 'public-surface'
52
57
  /** signals that this venue is in play although the repo never declared it, so config cannot lie */
53
58
  detect: Detector[]
@@ -65,6 +70,7 @@ export const custom = (id: string, probe: CustomRule['probe'], p?: RuleOpts): Cu
65
70
  export const script = (pattern: string): Detector => ({ kind: 'script', pattern })
66
71
  export const fileContains = (path: string, pattern: string): Detector => ({ kind: 'fileContains', path, pattern })
67
72
  export const dirExists = (path: string): Detector => ({ kind: 'dirExists', path })
73
+ export const fileMatches = (glob: string, pattern: string): Detector => ({ kind: 'fileMatches', glob, pattern })
68
74
 
69
75
  export function validateManifest(m: ContractManifest): string[] {
70
76
  const errs: string[] = []
@@ -4,8 +4,9 @@
4
4
  // - undeclared venue whose detectors fire → FAIL (config can't lie,
5
5
  // folders can't rot)
6
6
  // - exempt venues skip VISIBLY with their reason; never silence
7
- // - 'always' contracts with gate 'public-surface': full severity once the
8
- // repo has a public surface, everything demoted to WARN before.
7
+ // - 'always' contracts with gate 'public-surface': apply once the repo has a
8
+ // public surface, silent before (nothing is served yet).
9
+ // - a rule path's `{web}` is the repo's web root (contracts/web-surface.ts).
9
10
  // Every FAIL carries a fix: pointer = docsBase + manifest.owner.
10
11
  //
11
12
  // It reads ONLY through the disk it is handed (a check context), so what a
@@ -23,8 +24,10 @@ export type ContractsConfig = {
23
24
  declared: string[]
24
25
  /** social channel list — handed to custom rules via ContractEnv */
25
26
  social: string[]
26
- /** repo has a public surface (deploy script or any declared venue) — gates 'always' contracts */
27
+ /** repo has a public surface (a deploy, a declared venue, a public repo) — gates 'always' contracts */
27
28
  publicSurface: boolean
29
+ /** the dir the site serves, substituted for `{web}` in rule paths (default 'public') */
30
+ webRoot?: string
28
31
  /** venue → reason; skips the venue VISIBLY */
29
32
  exempt?: Record<string, string>
30
33
  /** prefix for fix: pointers, e.g. a docs checkout path or URL */
@@ -84,6 +87,14 @@ export function globFiles(pattern: string, d: ContractDisk): string[] {
84
87
  /** the text of a file, null when no FILE stands there */
85
88
  const textOf = (d: ContractDisk, rel: string): string | null => (d.exists(rel, 'file') ? d.read(rel) : null)
86
89
 
90
+ /** a rule with `{web}` replaced by the web root, `.` meaning the repo root */
91
+ export function atWebRoot(rule: ArtifactRule, webRoot: string): ArtifactRule {
92
+ const sub = (s: string): string => s.replaceAll('{web}/', webRoot === '.' ? '' : `${webRoot}/`)
93
+ if ('path' in rule) return { ...rule, path: sub(rule.path) }
94
+ if ('pattern' in rule) return { ...rule, pattern: sub(rule.pattern) }
95
+ return rule
96
+ }
97
+
87
98
  async function checkRule(rule: ArtifactRule, env: ContractEnv, d: ContractDisk): Promise<string[]> {
88
99
  const problems: string[] = []
89
100
  switch (rule.kind) {
@@ -145,6 +156,10 @@ function detectorFires(m: ContractManifest, d: ContractDisk): string | null {
145
156
  for (const det of m.detect) {
146
157
  if (det.kind === 'dirExists') {
147
158
  if (d.exists(det.path)) return `${det.path}/ exists`
159
+ } else if (det.kind === 'fileMatches') {
160
+ const re = new RegExp(det.pattern, 'm')
161
+ const hit = globFiles(det.glob, d).find((f) => re.test(textOf(d, f) ?? ''))
162
+ if (hit != null) return `${hit} matches /${det.pattern}/`
148
163
  } else if (det.kind === 'fileContains') {
149
164
  const text = textOf(d, det.path)
150
165
  if (text != null && new RegExp(det.pattern).test(text)) return `${det.path} matches /${det.pattern}/`
@@ -166,8 +181,10 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
166
181
  const lines: Line[] = []
167
182
  const outcomes: ContractOutcome[] = []
168
183
  let anyFail = false
184
+ const webRoot = cfg.webRoot ?? 'public'
169
185
  const env: ContractEnv = {
170
186
  root: cfg.root,
187
+ webRoot,
171
188
  venues: cfg.declared,
172
189
  social: cfg.social,
173
190
  exists: (rel) => d.exists(rel),
@@ -180,7 +197,7 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
180
197
  for (const m of manifests) {
181
198
  const exemptReason = cfg.exempt?.[m.venue]
182
199
  if (exemptReason != null) {
183
- lines.push({ kind: 'skip', text: `${m.venue} exempt: "${exemptReason}"` })
200
+ lines.push({ kind: 'skip', text: `${m.venue}: exempt: "${exemptReason}"` })
184
201
  outcomes.push({ venue: m.venue, status: 'exempt', problems: 0 })
185
202
  continue
186
203
  }
@@ -189,28 +206,32 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
189
206
  const signal = detectorFires(m, d)
190
207
  if (signal != null) {
191
208
  anyFail = true
192
- lines.push({ kind: 'fail', text: `${m.venue} ships (${signal}) but venue is not declared — declare it or remove the signal` })
209
+ lines.push({ kind: 'fail', text: `${m.venue}: ships (${signal}) but venue is not declared — declare it or remove the signal` })
193
210
  lines.push(...fix(m, 'declare + fill the contract'))
194
211
  outcomes.push({ venue: m.venue, status: 'fail', problems: 1 })
195
212
  } else {
213
+ // a folder with nothing out yet (local drafts) is work in progress, never a ship
214
+ if (d.exists(m.dir, 'dir')) lines.push({ kind: 'note', text: `${m.venue}: ${m.dir}/ holds work that is not out yet, nothing to check until the venue is declared` })
196
215
  outcomes.push({ venue: m.venue, status: 'not-applicable', problems: 0 })
197
216
  }
198
217
  continue
199
218
  }
200
- // demote everything to WARN on ungated 'always' contracts (no public surface yet)
201
- const warnAll = m.appliesTo === 'always' && m.gate === 'public-surface' && !cfg.publicSurface
219
+ // an 'always' contract gated on a public surface means nothing before one exists
220
+ if (m.appliesTo === 'always' && m.gate === 'public-surface' && !cfg.publicSurface) {
221
+ outcomes.push({ venue: m.venue, status: 'not-applicable', problems: 0 })
222
+ continue
223
+ }
202
224
  let problems = 0
203
225
  let warned = 0
204
226
  for (const rule of m.artifacts) {
205
- const found = await checkRule(rule, env, d)
206
- const asWarn = warnAll || rule.warnOnly === true
227
+ const found = await checkRule(atWebRoot(rule, webRoot), env, d)
207
228
  for (const p of found) {
208
- if (asWarn) {
229
+ if (rule.warnOnly === true) {
209
230
  warned++
210
- lines.push({ kind: 'warn', text: `${m.venue} ${p}${warnAll ? ' (no public surface yet — WARN not FAIL)' : ''}` })
231
+ lines.push({ kind: 'warn', text: `${m.venue}: ${p}` })
211
232
  } else {
212
233
  problems++
213
- lines.push({ kind: 'fail', text: `${m.venue} ${p}` })
234
+ lines.push({ kind: 'fail', text: `${m.venue}: ${p}` })
214
235
  }
215
236
  }
216
237
  }
@@ -224,7 +245,7 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
224
245
  } else if (warned > 0) {
225
246
  outcomes.push({ venue: m.venue, status: 'warn', problems: warned })
226
247
  } else {
227
- lines.push({ kind: 'ok', text: `${m.venue} ${m.dir}/ complete (${m.artifacts.length} rule(s))` })
248
+ lines.push({ kind: 'ok', text: `${m.venue}: ${m.dir}/ complete (${m.artifacts.length} rule(s))` })
228
249
  outcomes.push({ venue: m.venue, status: 'ok', problems: 0 })
229
250
  }
230
251
  }
@@ -1,6 +1,7 @@
1
1
  // normative WHAT for .shipkit/identity/ — every repo with a public surface owns
2
- // its brand source + committed favicon/logo/OG outputs. gate: FAIL once a
3
- // public surface exists (deploy.sh or any declared venue), WARN before.
2
+ // its brand source + committed favicon/logo/OG outputs, at the web root its
3
+ // deploy serves (`{web}`). gate: applies once a public surface exists (a
4
+ // deploy, a declared venue, a public repo that is more than an npm package).
4
5
  import { defineContract, file, glob, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
5
6
 
6
7
  export default defineContract({
@@ -12,8 +13,8 @@ export default defineContract({
12
13
  detect: [],
13
14
  artifacts: [
14
15
  file('.shipkit/identity/brand.ts'),
15
- png('public/favicon.png', 512, 512),
16
- png('public/og.png', 1200, 630, { atLeast: true }),
17
- glob('public/logo.*', { min: 1 }),
16
+ png('{web}/favicon.png', 512, 512),
17
+ png('{web}/og.png', 1200, 630, { atLeast: true }),
18
+ glob('{web}/logo.*', { min: 1 }),
18
19
  ],
19
20
  })
@@ -1,13 +1,15 @@
1
1
  // normative WHAT for .shipkit/social/ (posts ONLY — the Discord server contract is
2
2
  // discord.contract.ts). Declared = the repo config lists social channels.
3
- import { custom, defineContract, dirExists } from 'rvlib-shipkit/src/contracts/contract-types.ts'
3
+ // Undeclared, it ships only once a post is out (`status: posted`): a folder
4
+ // of local drafts is work in progress, never a ship.
5
+ import { custom, defineContract, fileMatches } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
6
 
5
7
  export default defineContract({
6
8
  venue: 'social',
7
9
  dir: '.shipkit/social',
8
10
  owner: 'distribute/post-publicly.md',
9
11
  appliesTo: 'declared',
10
- detect: [dirExists('.shipkit/social')],
12
+ detect: [fileMatches('.shipkit/social/**/*.md', '^status:\\s*posted\\b')],
11
13
  artifacts: [
12
14
  custom('declared-channel-dirs', (env) =>
13
15
  env.social
@@ -1,6 +1,6 @@
1
1
  // normative WHAT for .shipkit/steam/ — the WHY + workflow live in the sibling page.
2
2
  // Capsule sizes as of 2026-07 — Valve churns them: re-verify at use, bump here.
3
- import { defineContract, dir, dirExists, file, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
3
+ import { custom, defineContract, dirExists, file, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
4
4
 
5
5
  export default defineContract({
6
6
  venue: 'steam',
@@ -22,7 +22,11 @@ export default defineContract({
22
22
  // .ico, dims not png-parseable — presence-checked only
23
23
  file('.shipkit/steam/capsules/client-icon.ico'),
24
24
  glob('.shipkit/steam/screenshots/*.png', { min: 5, width: 1920, height: 1080 }),
25
- // vdf scripts only exist once the appid does — nag, don't block
26
- dir('.shipkit/steam/vdf', { warnOnly: true }),
25
+ // vdf scripts need the Steamworks appid (recorded in steam_appid.txt,
26
+ // Valve's own file name): before it, the line names that blocker
27
+ custom('vdf', (env) => {
28
+ if (!env.exists('.shipkit/steam/steam_appid.txt')) return ['no Steamworks appid yet (.shipkit/steam/steam_appid.txt): the vdf/ build scripts wait on it']
29
+ return env.exists('.shipkit/steam/vdf') ? [] : ['.shipkit/steam/vdf/ missing: the appid exists, write the app build and depot scripts']
30
+ }, { warnOnly: true }),
27
31
  ],
28
32
  })
@@ -0,0 +1,59 @@
1
+ // What a repo serves on the web, read from how it deploys: a GitHub Pages
2
+ // workflow, a wrangler config, or deploy.sh. The web root is where the
3
+ // identity outputs (favicon, logo, OG image) must land to be served, so it
4
+ // comes from the deploy, never a hardcoded `public/`.
5
+ // pure over the disk it is handed: SK002 is cached on what it reads through it
6
+ import type { ScanCtx } from '../core/check-ctx.ts'
7
+
8
+ export type WebDisk = Pick<ScanCtx, 'read' | 'exists' | 'entries'>
9
+ /** `source`: the file that says the repo deploys; `root`: the served dir, null when that file does not name one */
10
+ export type WebDeploy = { source: string; root: string | null }
11
+
12
+ const WORKFLOWS = '.github/workflows'
13
+ const PAGES = /actions\/(?:deploy-pages|upload-pages-artifact)|peaceiris\/actions-gh-pages|cloudflare\/(?:pages|wrangler)-action|wrangler\s+pages\s+deploy/
14
+ const WRANGLER = ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc']
15
+ export const DEFAULT_WEB_ROOT = 'public'
16
+
17
+ const clean = (dir: string): string => dir.replace(/^["']|["']$/g, '').replace(/^\.\//, '').replace(/\/+$/, '') || '.'
18
+
19
+ /** the served dir a deploy file names, null when it names none */
20
+ function rootIn(text: string): string | null {
21
+ const pick = (re: RegExp): string | null => {
22
+ const m = text.match(re)
23
+ return m?.[1] != null ? clean(m[1]) : null
24
+ }
25
+ return (
26
+ pick(/upload-pages-artifact@[^\n]*\n(?:[ \t]+[^\n]*\n)*?[ \t]+path:\s*(\S+)/) ??
27
+ pick(/publish_dir:\s*(\S+)/) ??
28
+ pick(/wrangler\s+pages\s+deploy\s+([^\s-]\S*)/) ??
29
+ pick(/pages_build_output_dir["']?\s*[:=]\s*["']([^"']+)["']/) ??
30
+ pick(/\[assets\][^[]*?directory\s*=\s*["']([^"']+)["']/) ??
31
+ pick(/"assets"\s*:\s*\{[^}]*"directory"\s*:\s*"([^"]+)"/) ??
32
+ pick(/\[site\][^[]*?bucket\s*=\s*["']([^"']+)["']/)
33
+ )
34
+ }
35
+
36
+ /** every deploy this repo declares, in the order a web root is read from */
37
+ export function webDeploys(d: WebDisk): WebDeploy[] {
38
+ const out: WebDeploy[] = []
39
+ for (const e of d.entries(WORKFLOWS)) {
40
+ if (e.kind !== 'file' || !/\.ya?ml$/.test(e.name)) continue
41
+ const text = d.read(`${WORKFLOWS}/${e.name}`) ?? ''
42
+ if (PAGES.test(text)) out.push({ source: `${WORKFLOWS}/${e.name}`, root: rootIn(text) })
43
+ }
44
+ for (const f of WRANGLER) {
45
+ const text = d.exists(f, 'file') ? d.read(f) : null
46
+ if (text != null) out.push({ source: f, root: rootIn(text) })
47
+ }
48
+ const sh = d.exists('deploy.sh', 'file') ? d.read('deploy.sh') : null
49
+ if (sh != null) out.push({ source: 'deploy.sh', root: rootIn(sh) })
50
+ return out
51
+ }
52
+
53
+ /** where the identity outputs go: the config's `contracts.webRoot`, else the
54
+ * first dir a deploy names, else `public` */
55
+ export function webRootOf(configured: string | undefined, deploys: WebDeploy[]): string {
56
+ if (configured != null) return clean(configured)
57
+ return deploys.find((x) => x.root != null)?.root ?? DEFAULT_WEB_ROOT
58
+ }
59
+
@@ -11,12 +11,14 @@ import type { MachineLoad } from '../suite/judge-suites.ts'
11
11
  * 2s at their 90th percentile, and the few past it shell out to a tool */
12
12
  export const IN_PROCESS_BUDGET_MS = 2_000
13
13
 
14
- /** a subprocess (a build step, a test suite, a `cmd` check) or a world probe.
14
+ /** a subprocess (a build step, a test suite, a `cmd` check) or a world probe
15
+ * (a `ttl` check, or a `never:` one, whose answer lives outside the repo).
15
16
  * Read off the same record: every typecheck stays under 6s at its 90th
16
17
  * percentile, and the quick test suites under 6s but one */
17
18
  export const SUBPROCESS_BUDGET_MS = 10_000
18
19
 
19
- /** what a check declares about itself that picks its default */
20
+ /** what a check declares about itself that picks its default. `world`: it
21
+ * declares `ttl` or `never`, so it reads the world rather than the repo */
20
22
  export type BudgetKind = { build: boolean; cmd: boolean; world: boolean }
21
23
 
22
24
  export const defaultBudget = (k: BudgetKind): number => (k.build || k.cmd || k.world ? SUBPROCESS_BUDGET_MS : IN_PROCESS_BUDGET_MS)
@@ -36,7 +38,15 @@ export const unknownBudgetIds = (ids: readonly string[], budgets: Record<string,
36
38
  export const budgetValueProblem = (v: unknown): string | null =>
37
39
  typeof v === 'number' && Number.isInteger(v) && v > 0 ? null : `must be a whole number of milliseconds above 0, got ${JSON.stringify(v)}`
38
40
 
39
- export const machineLoad = (): MachineLoad => ({ load1: loadavg()[0] ?? 0, cores: availableParallelism() })
41
+ /** `<load1>/<cores>` in this variable pins the reading: a test of a budget
42
+ * verdict must not depend on what else the machine runs */
43
+ export const MACHINE_LOAD_ENV = 'SHIPKIT_MACHINE_LOAD'
44
+
45
+ export const machineLoad = (): MachineLoad => {
46
+ const pinned = process.env[MACHINE_LOAD_ENV]?.match(/^(\d+(?:\.\d+)?)\/(\d+)$/)
47
+ if (pinned != null) return { load1: Number(pinned[1]), cores: Number(pinned[2]) }
48
+ return { load1: loadavg()[0] ?? 0, cores: availableParallelism() }
49
+ }
40
50
 
41
51
  /** `10s` for a whole second count, `150ms` otherwise */
42
52
  export const fmtBudget = (ms: number): string => (ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`)
@@ -49,12 +59,12 @@ export const isBudgetLine = (text: string): boolean => /^\S+ (passed|warned) in
49
59
  /** a test check sends the reader to the page that breaks its time down */
50
60
  const isTestCheck = (id: string): boolean => id === 'test' || id.startsWith('test:')
51
61
 
52
- /** the WARN a pass over its budget prints, or null inside it. A loaded
53
- * machine is named, because wall time is what the reader waits on and load
54
- * is the first thing to rule out */
62
+ /** the WARN a pass over its budget prints, or null inside it. Null too with
63
+ * the load at or past the core count: the budget judges wall time, and on a
64
+ * saturated machine the wall is the machine's, not the check's (a suite's
65
+ * own verdict judges CPU instead, suite/judge-suites.ts) */
55
66
  export function budgetLine(id: string, ms: number, budgetMs: number, load: MachineLoad, verb: 'passed' | 'warned' = 'passed'): string | null {
56
- if (ms <= budgetMs) return null
57
- const loaded = load.load1 >= load.cores ? ` under load ${load.load1.toFixed(1)} on ${load.cores} cores,` : ''
67
+ if (ms <= budgetMs || load.load1 >= load.cores) return null
58
68
  const where = isTestCheck(id) ? 'see the tests page for where the time goes' : 'its page in the hub shows every run'
59
- return `${id} ${verb} in ${(ms / 1000).toFixed(1)}s,${loaded} over its ${fmtBudget(budgetMs)} budget: ${where}`
69
+ return `${id} ${verb} in ${(ms / 1000).toFixed(1)}s, over its ${fmtBudget(budgetMs)} budget: ${where}`
60
70
  }
@@ -56,6 +56,10 @@ import { feedsProblemsPane, type Population, type Problem } from '../problems/pr
56
56
  * about the world. */
57
57
  export const CHECK_TIMEOUT_MS = 120_000
58
58
 
59
+ /** the most findings a failed build step keeps beside its headline: a tool
60
+ * that prints a wall of them is read in its log */
61
+ const MAX_EXTRA = 20
62
+
59
63
  /** epoch ms that never goes backwards inside one process, whole numbers so a
60
64
  * row's `at - ms` is its start exactly: the wall clock can step back (an NTP
61
65
  * correction), and a check stamped before an earlier one ended makes the
@@ -431,6 +435,9 @@ type Live = RunnableCheck & CheckResult & {
431
435
  /** what the headline carries after the id: a failure's last line, or the
432
436
  * reason it never ran */
433
437
  tail: string
438
+ /** a failed build step's other findings, the wire lines its tool printed
439
+ * beside the one the headline carries */
440
+ extra: Line[]
434
441
  }
435
442
 
436
443
  export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
@@ -454,8 +461,11 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
454
461
  blocking: u.blocking, advisory: r.advisory, lines: r.lines, problems: r.problems, logPath: r.logPath, lastLine: r.lastLine, note: r.note, attached,
455
462
  })
456
463
  // an attached check is filed as a replay: it measured nothing, and a
457
- // row carrying its wait would read as a check running in this process
458
- o.log?.add({ id: u.id, status: r.status, ms: r.ms, replayed: r.replayed || attached, lines: r.lines, at })
464
+ // row carrying its wait would read as a check running in this process.
465
+ // A non-blocking failure is filed as the warning the gate printed, so
466
+ // no surface reading the record paints red what the gate called orange
467
+ const filed = r.status === 'fail' && r.advisory ? 'warn' : r.status
468
+ o.log?.add({ id: u.id, status: filed, ms: r.ms, replayed: r.replayed || attached, lines: r.lines, at })
459
469
  }
460
470
 
461
471
  // OFF, in ONE place: skipped by name, never run, never stored, and the
@@ -476,7 +486,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
476
486
  const runDir = openRunDir(o.logDir)
477
487
  const checks: Live[] = sel.selected.map((u) => ({
478
488
  ...u,
479
- status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '',
489
+ status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '', extra: [],
480
490
  logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
481
491
  }))
482
492
  const selectedIds = new Set(checks.map((u) => u.id))
@@ -670,14 +680,25 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
670
680
  u.problems = findings(u, u.lines, r.out)
671
681
  const lastFail = u.lines.filter((l) => l.kind === 'fail').at(-1)
672
682
  if (lastFail != null) u.lastLine = lastFail.text.slice(0, 200)
683
+ // a tool that speaks the wire (the suite runner) ends on its FAIL
684
+ // lines, and the headline carries only the last one. Only that closing
685
+ // block is its report: a wire line earlier in the log is some test's
686
+ // own output
687
+ if (u.build && u.status === 'fail') {
688
+ const parsed = parseWireOutput(r.out)
689
+ let i = parsed.length
690
+ while (i > 0 && (parsed[i - 1]!.kind === 'fail' || parsed[i - 1]!.kind === 'fix-fail')) i--
691
+ u.extra = parsed.slice(i).filter((l) => !(l.kind === 'fail' && `FAIL: ${l.text}` === u.lastLine)).slice(-MAX_EXTRA)
692
+ }
673
693
  }
674
694
 
675
695
  /** a check that printed nothing of its own still has to report that it
676
696
  * ran: the verdict, its name, and (at print time) what it cost */
677
697
  const headlineOf = (u: Live): void => {
678
698
  u.synthetic = true
679
- u.tail = u.status === 'fail' ? (u.lastLine === '' ? '' : ` — ${u.lastLine}`) : u.status === 'skip' ? (u.note === '' ? '' : ` (${u.note})`) : ''
680
- u.lines = [{ kind: u.status === 'fail' ? 'fail' : u.status === 'skip' ? 'skip' : 'ok', text: `${u.id}${u.tail}` }]
699
+ const more = u.extra.filter((l) => l.kind === 'fail').length
700
+ u.tail = u.status === 'fail' ? (u.lastLine === '' ? '' : ` — ${u.lastLine}${more > 0 ? ` (+${more} more below)` : ''}`) : u.status === 'skip' ? (u.note === '' ? '' : ` (${u.note})`) : ''
701
+ u.lines = [{ kind: u.status === 'fail' ? 'fail' : u.status === 'skip' ? 'skip' : 'ok', text: `${u.id}${u.tail}` }, ...u.extra]
681
702
  }
682
703
 
683
704
  /** what an attached asker adopts: the verdict and the lines BEFORE this