rvlib-shipkit 1.3.0 → 1.5.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 (203) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  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 +12 -2
  7. package/src/check.ts +73 -98
  8. package/src/checks/SK001-package-json-shape.check.ts +26 -4
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  10. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  11. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  12. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  13. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  14. package/src/checks/SK007-github-visibility.check.ts +4 -2
  15. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  16. package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
  17. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  18. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  19. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  20. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  21. package/src/checks/SK014-prose-ste.check.ts +20 -6
  22. package/src/checks/SK015-nested-repo.check.ts +4 -2
  23. package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
  24. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  25. package/src/checks/SK018-test-files-run.check.ts +6 -2
  26. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  27. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  28. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  29. package/src/checks/SK024-identity-colors.check.ts +5 -3
  30. package/src/checks/SK025-release-drift.check.ts +4 -5
  31. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  32. package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
  33. package/src/checks/SK028-readme.check.ts +10 -2
  34. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  35. package/src/cli/cmd_Asks.ts +5 -5
  36. package/src/cli/cmd_Assets.ts +7 -7
  37. package/src/cli/cmd_Bump.ts +12 -11
  38. package/src/cli/cmd_Check.ts +8 -7
  39. package/src/cli/cmd_Checks.ts +7 -6
  40. package/src/cli/cmd_Ci.ts +12 -11
  41. package/src/cli/cmd_Commands.ts +16 -13
  42. package/src/cli/cmd_ConfigKeys.ts +7 -6
  43. package/src/cli/cmd_Contracts.ts +3 -3
  44. package/src/cli/cmd_CrossRepoActivity.ts +10 -6
  45. package/src/cli/cmd_Fleet.ts +9 -7
  46. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  47. package/src/cli/cmd_Home.ts +23 -17
  48. package/src/cli/cmd_Hooks.ts +6 -5
  49. package/src/cli/cmd_Init.ts +5 -4
  50. package/src/cli/cmd_Keys.ts +8 -7
  51. package/src/cli/cmd_Mv.ts +7 -6
  52. package/src/cli/cmd_Needs.ts +4 -3
  53. package/src/cli/cmd_Overview.ts +4 -3
  54. package/src/cli/cmd_Pb.ts +2 -2
  55. package/src/cli/cmd_Planned.ts +6 -5
  56. package/src/cli/cmd_Prose.ts +5 -5
  57. package/src/cli/cmd_Reflect.ts +7 -7
  58. package/src/cli/cmd_Release.ts +6 -5
  59. package/src/cli/cmd_Releases.ts +5 -4
  60. package/src/cli/cmd_Serve.ts +4 -3
  61. package/src/cli/cmd_Smoke.ts +5 -4
  62. package/src/cli/cmd_Start.ts +7 -6
  63. package/src/cli/cmd_Status.ts +4 -3
  64. package/src/cli/cmd_Tui.ts +3 -2
  65. package/src/cli/cmd_When.ts +71 -0
  66. package/src/cli/cmd_Wiki.ts +4 -4
  67. package/src/cli.ts +7 -2
  68. package/src/collections/checks.ts +2 -0
  69. package/src/collections/collection-types.ts +24 -4
  70. package/src/collections/providers.ts +6 -2
  71. package/src/contracts/apply-contracts.ts +9 -8
  72. package/src/contracts/contract-types.ts +8 -2
  73. package/src/contracts/contracts-engine.ts +34 -13
  74. package/src/contracts/manifests/identity.contract.ts +6 -5
  75. package/src/contracts/manifests/social.contract.ts +4 -2
  76. package/src/contracts/manifests/steam.contract.ts +7 -3
  77. package/src/contracts/web-surface.ts +59 -0
  78. package/src/core/budget.ts +19 -9
  79. package/src/core/canonical-scripts.ts +1 -1
  80. package/src/core/check-runner.ts +63 -25
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +31 -7
  83. package/src/core/layout.ts +2 -0
  84. package/src/core/legacy-shapes.ts +39 -10
  85. package/src/core/motherhome.ts +88 -66
  86. package/src/core/one-way-in-policy.ts +2 -2
  87. package/src/core/outcome-words.ts +18 -2
  88. package/src/core/port-check-cli.ts +52 -0
  89. package/src/core/port-check-file.ts +295 -0
  90. package/src/core/port-check-files.ts +31 -0
  91. package/src/core/retention.ts +0 -14
  92. package/src/core/run-check.ts +14 -14
  93. package/src/core/run-ci.ts +11 -7
  94. package/src/core/run-fix.ts +30 -23
  95. package/src/core/run-fixers.ts +42 -5
  96. package/src/core/runs-matrix.ts +179 -0
  97. package/src/core/runs.ts +242 -0
  98. package/src/core/update-policy.ts +20 -0
  99. package/src/core/when-block.ts +29 -30
  100. package/src/core/when.ts +41 -103
  101. package/src/core/wire.ts +34 -9
  102. package/src/fleet/scan-fleet.ts +20 -4
  103. package/src/fleet/update-fleet.ts +6 -6
  104. package/src/home/activity-shape.ts +4 -1
  105. package/src/home/activity.ts +22 -15
  106. package/src/home/gui-app/ShipkitGui.swift +144 -25
  107. package/src/home/gui-app.ts +93 -30
  108. package/src/home/home-launchd.ts +30 -6
  109. package/src/home/home-routes.ts +14 -2
  110. package/src/home/home-start.ts +33 -5
  111. package/src/home/hubs.ts +5 -5
  112. package/src/home/machine-stores.ts +4 -2
  113. package/src/home/open-app.ts +23 -10
  114. package/src/home/pm-config.ts +15 -11
  115. package/src/home/pm-inputs.ts +76 -0
  116. package/src/home/pm.ts +11 -3
  117. package/src/home/process-rows.ts +23 -6
  118. package/src/home/resources.ts +67 -15
  119. package/src/home/run-daemon.ts +6 -1
  120. package/src/home/run-pm.ts +30 -5
  121. package/src/home/serve-web.ts +2 -2
  122. package/src/home/supervise.ts +24 -1
  123. package/src/home/widgets.ts +32 -3
  124. package/src/hooks/commit-message.ts +2 -3
  125. package/src/hooks/run-hooks.ts +60 -6
  126. package/src/init-repo.ts +6 -6
  127. package/src/ledger/gate-checks.ts +115 -61
  128. package/src/ledger/git-key.ts +5 -1
  129. package/src/ledger/ledger.ts +5 -5
  130. package/src/ledger/store.ts +11 -0
  131. package/src/ledger/toolchain-identity.ts +45 -27
  132. package/src/ledger/world-cache.ts +17 -15
  133. package/src/plan/repo-source.ts +18 -39
  134. package/src/release/run-release.ts +5 -0
  135. package/src/repo-config.ts +36 -56
  136. package/src/rules/rule.ts +2 -2
  137. package/src/scaffold/files/agent-coding.md +1 -1
  138. package/src/scaffold/files/agent-index.md +1 -1
  139. package/src/scaffold/files/verify-skill.md +2 -2
  140. package/src/serve/hub-app/activity-st.ts +5 -4
  141. package/src/serve/hub-app/activity-view.tsx +4 -4
  142. package/src/serve/hub-app/admin-view.tsx +13 -11
  143. package/src/serve/hub-app/check-view.tsx +66 -46
  144. package/src/serve/hub-app/check-words.ts +1 -11
  145. package/src/serve/hub-app/checks-svc.ts +16 -36
  146. package/src/serve/hub-app/col-tables-view.tsx +131 -0
  147. package/src/serve/hub-app/col-tables.ts +115 -0
  148. package/src/serve/hub-app/cols-svc.ts +33 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +7 -1
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +15 -3
  160. package/src/serve/hub-app/home-strip.css +69 -19
  161. package/src/serve/hub-app/home-strip.tsx +192 -118
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +21 -5
  164. package/src/serve/hub-app/hub-types.ts +11 -5
  165. package/src/serve/hub-app/hub.css +88 -44
  166. package/src/serve/hub-app/icons.tsx +15 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +11 -8
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +34 -49
  171. package/src/serve/hub-app/palette-st.ts +5 -5
  172. package/src/serve/hub-app/palette-view.tsx +1 -1
  173. package/src/serve/hub-app/plan-view.tsx +51 -0
  174. package/src/serve/hub-app/planned-st.ts +9 -4
  175. package/src/serve/hub-app/processes-view.tsx +38 -0
  176. package/src/serve/hub-app/project-list.tsx +3 -3
  177. package/src/serve/hub-app/projects-svc.ts +15 -10
  178. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  179. package/src/serve/hub-app/reach.ts +36 -0
  180. package/src/serve/hub-app/readme-svc.ts +3 -2
  181. package/src/serve/hub-app/repo-sections.ts +117 -0
  182. package/src/serve/hub-app/repo-st.ts +47 -10
  183. package/src/serve/hub-app/runs-tabs.ts +1 -1
  184. package/src/serve/hub-app/sidebar.tsx +12 -37
  185. package/src/serve/hub-app/timeline-svc.ts +3 -2
  186. package/src/serve/hub-app/timeline-view.tsx +14 -11
  187. package/src/serve/hub-app/tree-build.ts +149 -155
  188. package/src/serve/hub-app/tree-row.tsx +39 -9
  189. package/src/serve/hub-app/tree-svc.ts +70 -49
  190. package/src/serve/hub-app/when-view.tsx +89 -0
  191. package/src/serve/hub-app/when.css +17 -0
  192. package/src/serve/hub-runtime.ts +3 -16
  193. package/src/serve/project-gutter.ts +1 -2
  194. package/src/serve/run-serve.ts +1 -1
  195. package/src/serve/serve-hub.ts +4 -14
  196. package/src/serve/warm-cell.ts +49 -16
  197. package/src/suite/judge-suites.ts +42 -17
  198. package/src/suite/suite-cli.ts +5 -8
  199. package/src/wiki/lint-md.ts +2 -0
  200. package/src/wiki/scan-md.ts +37 -10
  201. package/src/serve/hub-app/processes-svc.ts +0 -63
  202. package/src/serve/hub-app/repo-sections.tsx +0 -61
  203. package/src/serve/repo-processes.ts +0 -89
@@ -30,7 +30,7 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
30
30
  }
31
31
  }
32
32
 
33
- // ONE WAY IN (build/shipkit.md): the verification verbs an agent types from
33
+ // ONE WAY IN (build/shipkit/one-way-in.md): the verification verbs an agent types from
34
34
  // muscle memory ROUTE to shipkit, so `bun run test` gets the cache and the
35
35
  // job attachment without anyone having to remember a rule. The tool body they
36
36
  // used to hold moves to `<name>:raw`, which is what shipkit itself runs and
@@ -40,15 +40,19 @@ import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
40
40
  import { budgetLine, machineLoad } from './budget.ts'
41
41
  import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
42
42
  import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
43
- import { offLine, type WhenDecl } from './when.ts'
43
+ import { offLine, runsIn, type ResolvedRuns, type RunContext } from './runs.ts'
44
44
  import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
45
45
  import { Ledger, stampCached } from '../ledger/ledger.ts'
46
- import { attachedStamp } from './outcome-words.ts'
46
+ import { attachedStamp, ranStamp } from './outcome-words.ts'
47
47
  import type { RunLog, RunStatus } from '../ledger/run-log.ts'
48
48
  import { liveStateOf } from '../ledger/live-runs.ts'
49
49
  import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
50
50
  import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
51
51
  import { extractProblems } from '../problems/extract.ts'
52
+
53
+ /** the first line of a check that ran in this run, stamped with its cost. A
54
+ * world answer carries its keep in a stamp already, so the cost joins it */
55
+ const ranHere = (l: Line, ms: number): Line => (l.stamp != null && l.stamp.cached !== true ? { ...l, stamp: { ...l.stamp, ranMs: ms } } : { ...l, text: `${l.text} ${ranStamp(ms)}` })
52
56
  import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
53
57
 
54
58
  /** the ceiling a check takes when it names none. A build step's floor
@@ -56,6 +60,10 @@ import { feedsProblemsPane, type Population, type Problem } from '../problems/pr
56
60
  * about the world. */
57
61
  export const CHECK_TIMEOUT_MS = 120_000
58
62
 
63
+ /** the most findings a failed build step keeps beside its headline: a tool
64
+ * that prints a wall of them is read in its log */
65
+ const MAX_EXTRA = 20
66
+
59
67
  /** epoch ms that never goes backwards inside one process, whole numbers so a
60
68
  * row's `at - ms` is its start exactly: the wall clock can step back (an NTP
61
69
  * correction), and a check stamped before an earlier one ended makes the
@@ -78,7 +86,8 @@ export type RunnableCheck = {
78
86
  blocking: boolean | null
79
87
  /** a build step: the gate runs it, the health pass does not */
80
88
  build: boolean
81
- when: WhenDecl
89
+ /** every context it runs in, resolved (core/runs.ts) */
90
+ runs: ResolvedRuns
82
91
  deps: string[]
83
92
  group: string | null
84
93
  timeoutMs: number
@@ -155,8 +164,10 @@ export type RunChecksOpts = {
155
164
  root: string
156
165
  config: CheckEnv['config']
157
166
  audience: Audience
158
- /** `release` checks are in at the full tier and out at the quick one */
167
+ /** the full tests tier: `onFull` decides instead of `onCi` */
159
168
  fullTier: boolean
169
+ /** the release preflight: `onRelease` decides, whatever the tier */
170
+ release?: boolean
160
171
  /** where a `cmd` check's whole output is streamed */
161
172
  logDir: string
162
173
  /** the world answers' store, null = nothing is remembered by TTL */
@@ -252,10 +263,13 @@ export type Selection = {
252
263
  errors: string[]
253
264
  }
254
265
 
266
+ /** the axis each context reads, as a parked line names it */
267
+ const CTX_AXIS: Record<RunContext, string> = { preCommit: 'onPreCommit', ci: 'onCi', full: 'onFull', release: 'onRelease', health: 'onHealth' }
268
+
255
269
  /** what this run asks about. The audience decides the shape of the question,
256
270
  * `--only` and `--with` name checks past any filter, and OFF is the one word
257
271
  * no flag lifts. */
258
- export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
272
+ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'release' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
259
273
  const errors: string[] = []
260
274
  const ids = new Set(o.checks.map((u) => u.id))
261
275
  if (ids.size !== o.checks.length) errors.push('duplicate check ids')
@@ -314,26 +328,28 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
314
328
  if (u.blocking == null) undeclared.push(u.id)
315
329
  selected.push(u)
316
330
  }
331
+ // the one context this run asks about: every row declares whether it
332
+ // runs there (core/runs.ts), and nothing is guessed from its kind
333
+ const ctx: RunContext = o.audience === 'health' ? 'health' : o.release === true ? 'release' : o.fullTier ? 'full' : 'ci'
317
334
  for (const u of o.checks) {
318
- // IS IT THIS RUN'S BUSINESS AT ALL? The health pass never builds (a
319
- // repo's test suite and its type checker answer "is my change good",
320
- // which is the gate's question), the TUI's build panel asks for
321
- // nothing else, and a sub-unit release skips what its own gate ran.
322
- // A check out of scope here is not even reported as off: it is not part
323
- // of this question.
335
+ // IS IT THIS RUN'S BUSINESS AT ALL? The TUI's build panel asks for
336
+ // nothing but build steps, and a sub-unit release skips what its own
337
+ // gate ran. A check out of scope here is not even reported as off: it
338
+ // is not part of this question.
324
339
  if (!named.has(u.id)) {
325
- if (o.audience === 'health' && u.build) continue
326
340
  if (o.buildOnly === true && !u.build) continue
327
341
  if (excluded.has(u.id)) { parked.push({ id: u.id, text: `${u.id} (not part of this release, its own gate covers it)` }); continue }
328
342
  }
329
343
  // OFF is the one word that means never, past every other flag: a
330
344
  // switch a filter can lift is a suggestion
331
- if (u.when.at === 'off') { off.push(u); continue }
345
+ if (u.runs.off != null) { off.push(u); continue }
332
346
  if (named.has(u.id)) { take(u); continue }
333
347
  if (u.group != null && skipGroups.has(u.group)) { parked.push({ id: u.id, text: `${u.id} (--skip ${u.group})` }); continue }
334
- if (o.audience === 'gate') {
335
- if (u.when.at === 'health' && o.includeProactive !== true) { parked.push({ id: u.id, text: `${u.id} (health)` }); continue }
336
- if (u.when.at === 'release' && !o.fullTier) { parked.push({ id: u.id, text: `${u.id} (release)` }); continue }
348
+ if (!runsIn(u.runs, ctx)) {
349
+ // the health pass answers its own question and lists nothing else
350
+ if (ctx === 'health') continue
351
+ // release.requireHealthy asks the gate for the health probes too
352
+ if (!(o.includeProactive === true && u.runs.onHealth && !u.build)) { parked.push({ id: u.id, text: `${u.id} (not ${CTX_AXIS[ctx]})` }); continue }
337
353
  }
338
354
  if (o.onlyBlocking === true && u.blocking !== true) { if (u.blocking == null) undeclared.push(u.id); nonBlocking.set(u.id, u); continue }
339
355
  take(u)
@@ -431,6 +447,9 @@ type Live = RunnableCheck & CheckResult & {
431
447
  /** what the headline carries after the id: a failure's last line, or the
432
448
  * reason it never ran */
433
449
  tail: string
450
+ /** a failed build step's other findings, the wire lines its tool printed
451
+ * beside the one the headline carries */
452
+ extra: Line[]
434
453
  }
435
454
 
436
455
  export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
@@ -454,14 +473,17 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
454
473
  blocking: u.blocking, advisory: r.advisory, lines: r.lines, problems: r.problems, logPath: r.logPath, lastLine: r.lastLine, note: r.note, attached,
455
474
  })
456
475
  // 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 })
476
+ // row carrying its wait would read as a check running in this process.
477
+ // A non-blocking failure is filed as the warning the gate printed, so
478
+ // no surface reading the record paints red what the gate called orange
479
+ const filed = r.status === 'fail' && r.advisory ? 'warn' : r.status
480
+ o.log?.add({ id: u.id, status: filed, ms: r.ms, replayed: r.replayed || attached, lines: r.lines, at })
459
481
  }
460
482
 
461
483
  // OFF, in ONE place: skipped by name, never run, never stored, and the
462
484
  // reason printed where the verdict would have been
463
485
  for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
464
- const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
486
+ const line: Line = { kind: 'skip', text: offLine(u.id, u.runs) }
465
487
  if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
466
488
  report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
467
489
  }
@@ -476,7 +498,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
476
498
  const runDir = openRunDir(o.logDir)
477
499
  const checks: Live[] = sel.selected.map((u) => ({
478
500
  ...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: '',
501
+ 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
502
  logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
481
503
  }))
482
504
  const selectedIds = new Set(checks.map((u) => u.id))
@@ -556,9 +578,14 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
556
578
  const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
557
579
  const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
558
580
  const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
559
- const shown = u.synthetic && !u.replayed && head != null
560
- ? [{ ...head, text: `${id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
581
+ const composed = u.synthetic && !u.replayed && head != null
582
+ ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}` }, ...u.lines.slice(1)]
561
583
  : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
584
+ // every line says where its answer came from: a replay carries its
585
+ // cached stamp already, so one that ran here says so and what it cost
586
+ const shown = u.synthetic || u.replayed || u.attached || u.startedAt === 0 || composed[0] == null
587
+ ? composed
588
+ : [ranHere(composed[0], u.ms), ...composed.slice(1)]
562
589
  // a caller printing its own verdict wants one line per green check and
563
590
  // only the warnings of a warned one; a failure prints whole
564
591
  const quiet = (l: Line, i: number): boolean =>
@@ -575,7 +602,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
575
602
  const hit = o.ledger?.replay(u.id)
576
603
  if (hit != null) {
577
604
  u.replayed = true
578
- u.lines = stampCached(hit.lines, hit.measuredMs)
605
+ u.lines = stampCached(hit.lines, hit.measuredMs, hit.at, clock())
579
606
  u.lastLine = hit.lastLine ?? ''
580
607
  u.status = hit.status
581
608
  // a replay stands for the verdict it replays, findings included:
@@ -670,14 +697,25 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
670
697
  u.problems = findings(u, u.lines, r.out)
671
698
  const lastFail = u.lines.filter((l) => l.kind === 'fail').at(-1)
672
699
  if (lastFail != null) u.lastLine = lastFail.text.slice(0, 200)
700
+ // a tool that speaks the wire (the suite runner) ends on its FAIL
701
+ // lines, and the headline carries only the last one. Only that closing
702
+ // block is its report: a wire line earlier in the log is some test's
703
+ // own output
704
+ if (u.build && u.status === 'fail') {
705
+ const parsed = parseWireOutput(r.out)
706
+ let i = parsed.length
707
+ while (i > 0 && (parsed[i - 1]!.kind === 'fail' || parsed[i - 1]!.kind === 'fix-fail')) i--
708
+ u.extra = parsed.slice(i).filter((l) => !(l.kind === 'fail' && `FAIL: ${l.text}` === u.lastLine)).slice(-MAX_EXTRA)
709
+ }
673
710
  }
674
711
 
675
712
  /** a check that printed nothing of its own still has to report that it
676
713
  * ran: the verdict, its name, and (at print time) what it cost */
677
714
  const headlineOf = (u: Live): void => {
678
715
  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}` }]
716
+ const more = u.extra.filter((l) => l.kind === 'fail').length
717
+ u.tail = u.status === 'fail' ? (u.lastLine === '' ? '' : ` — ${u.lastLine}${more > 0 ? ` (+${more} more below)` : ''}`) : u.status === 'skip' ? (u.note === '' ? '' : ` (${u.note})`) : ''
718
+ u.lines = [{ kind: u.status === 'fail' ? 'fail' : u.status === 'skip' ? 'skip' : 'ok', text: `${u.id}${u.tail}` }, ...u.extra]
681
719
  }
682
720
 
683
721
  /** what an attached asker adopts: the verdict and the lines BEFORE this
@@ -1,14 +1,15 @@
1
- // THE CONFIG HALF of the SK016 migration, in its own process. Writing the
1
+ // THE CONFIG HALF of the SK016 and SK029 migrations, in its own process. Writing the
2
2
  // managed block and reading the result back both LOAD repo.config.ts as a
3
3
  // module, which no check context read can stand for, so the fixer reaches it
4
4
  // through `c.exec` and the check file itself never imports the loader.
5
5
  //
6
6
  // bun config-rewrite.ts <root> '<plan json>' → one json line on stdout
7
7
  //
8
- // The order is the migration's: the `when` entries first, then each key is
9
- // dropped only once what leaned on it has a home, then the result is read
10
- // back through the loader and the previous bytes go back when it does not
11
- // survive.
8
+ // The order is the migration's: a key the managed block holds (the retired
9
+ // `when`) is dropped first, so the block describes the config again, then the
10
+ // `off` entries are written into the block, then each other key is dropped
11
+ // only once what leaned on it has a home. The result is read back through the
12
+ // loader and the previous bytes go back when it does not survive.
12
13
  import { readFileSync } from 'fs'
13
14
  import { join } from 'path'
14
15
  import { atomicWrite } from './locked-write.ts'
@@ -17,10 +18,11 @@ import { writeGateBlock } from './when-block.ts'
17
18
  import { loadRepoConfig } from '../repo-config.ts'
18
19
 
19
20
  export type ConfigPlan = {
20
- /** check id → `when` value, written into the managed block */
21
- when: Record<string, string>
22
- /** keys to drop, in order. `afterWhen` drops only once the block took the entries */
23
- drop: { key: string; why: string; afterWhen?: boolean }[]
21
+ /** check id → why it is off, written into the managed block */
22
+ off: Record<string, string>
23
+ /** keys to drop, in order. `first` drops before the block is written (a
24
+ * key inside it), `afterOff` only once the block took the entries */
25
+ drop: { key: string; why: string; phase?: 'first' | 'afterOff' }[]
24
26
  /** the config's `name` before the rewrite: what the read back must still say */
25
27
  name: string
26
28
  }
@@ -35,27 +37,38 @@ export type ConfigRewrite = {
35
37
  export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<ConfigRewrite> {
36
38
  const did: string[] = []
37
39
  const hand: string[] = []
38
- let whenWritten = false
39
- if (Object.keys(plan.when).length > 0) {
40
+ const path = join(root, 'repo.config.ts')
41
+ const before = readFileSync(path, 'utf8')
42
+ const drop = (text: string, d: ConfigPlan['drop'][number]): string => {
43
+ const next = dropConfigKey(text, d.key)
44
+ if (next == null) { hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`); return text }
45
+ did.push(`repo.config.ts drops \`${d.key}\``)
46
+ return next
47
+ }
48
+ let first = before
49
+ for (const d of plan.drop.filter((x) => x.phase === 'first')) first = drop(first, d)
50
+ if (first !== before) atomicWrite(path, first)
51
+ let offWritten = false
52
+ if (Object.keys(plan.off).length > 0) {
40
53
  try {
41
- await writeGateBlock(root, { when: plan.when })
42
- whenWritten = true
43
- did.push(`${Object.keys(plan.when).length} when entr(ies) written into the managed block`)
54
+ await writeGateBlock(root, { off: plan.off })
55
+ offWritten = true
56
+ did.push(`${Object.keys(plan.off).length} off entr(ies) written into the managed block`)
44
57
  } catch (e) {
45
- hand.push(`the \`when\` block refused the rewrite (${String(e).slice(0, 160)}) — write ${JSON.stringify(plan.when)} into it by hand`)
58
+ // a key dropped first held these entries: without the block they
59
+ // would be gone, so the file goes back as it was
60
+ if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(plan.off)}: ${String(e).slice(0, 160)}` } }
61
+ hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(plan.off)} into it by hand`)
46
62
  }
47
63
  }
48
- const path = join(root, 'repo.config.ts')
49
- const before = readFileSync(path, 'utf8')
50
- let text = before
51
- for (const d of plan.drop) {
52
- if (d.afterWhen === true && !whenWritten) continue
53
- const next = dropConfigKey(text, d.key)
54
- if (next == null) hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`)
55
- else { text = next; did.push(`repo.config.ts drops \`${d.key}\``) }
64
+ const mid = readFileSync(path, 'utf8')
65
+ let text = mid
66
+ for (const d of plan.drop.filter((x) => x.phase !== 'first')) {
67
+ if (d.phase === 'afterOff' && !offWritten) continue
68
+ text = drop(text, d)
56
69
  }
70
+ if (text !== mid) atomicWrite(path, text)
57
71
  if (text !== before) {
58
- atomicWrite(path, text)
59
72
  try {
60
73
  const after = await loadRepoConfig(root, { fresh: true })
61
74
  if (after.name !== plan.name) throw new Error(`the config reads as '${String(after.name)}'`)
@@ -56,20 +56,23 @@ export type DiscoveredCheck = {
56
56
  from: CheckFrom
57
57
  }
58
58
 
59
- function walk(dir: string, out: string[], suffix = SUFFIX): void {
59
+ function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
60
+ dirs?.push(dir)
60
61
  let entries
61
62
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
62
63
  for (const e of entries) {
63
64
  if (e.isDirectory()) {
64
- if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix)
65
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix, dirs)
65
66
  } else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
66
67
  }
67
68
  }
68
69
 
69
- /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted */
70
- export function repoUnitFiles(root: string): string[] {
70
+ /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
71
+ * `dirs` collects every dir the walk read: a file added to one moves its
72
+ * mtime, which is how a reader tells the list moved without walking again */
73
+ export function repoUnitFiles(root: string, dirs?: string[]): string[] {
71
74
  const out: string[] = []
72
- walk(root, out, UNIT_SUFFIX)
75
+ walk(root, out, UNIT_SUFFIX, dirs)
73
76
  return out.sort()
74
77
  }
75
78
 
@@ -107,7 +110,7 @@ export function isLegacyCheckFile(path: string): boolean {
107
110
  /** every built-in id, from the file names alone. Sync and import free, so
108
111
  * `defineRepo` can read the namespace at config load. Read once per
109
112
  * process: the toolkit's own files cannot appear mid run, and `defineRepo`
110
- * asks for the list once per `when` entry. */
113
+ * asks for the list once per `off` entry. */
111
114
  let builtinIds: string[] | null = null
112
115
  export function builtinCheckIds(): string[] {
113
116
  if (builtinIds != null) return builtinIds
@@ -141,6 +144,20 @@ async function load(path: string): Promise<CheckDef | null> {
141
144
  }
142
145
  }
143
146
 
147
+ /** the stand in for a check file that does not load: it fails in every
148
+ * context with the load error, so the file cannot drop out of a gate unseen */
149
+ function unloadable(path: string, message: string): CheckDef {
150
+ return {
151
+ description: 'the check file loads',
152
+ blocking: true,
153
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: false, onClock: false },
154
+ cache: { by: 'never', why: 'the file does not load' },
155
+ fix: 'none',
156
+ applies: 'always',
157
+ run: (c) => c.fail(`${basename(path)} does not load: ${message.replace(`${path}: `, '')}`, 'shipkit check --fix SK029 ports a retired check file with the values it ran on, and shipkit ci does it in its fix pass'),
158
+ }
159
+ }
160
+
144
161
  /** every check this repo can run: its own files, plus the toolkit's. Throws
145
162
  * on a registration nobody can act on (a duplicate id, a repo check squatting
146
163
  * the reserved prefix, a built-in with no owner page), so a broken
@@ -167,7 +184,14 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
167
184
  const ids = new Set<string>()
168
185
  for (const f of [...builtins, ...own, ...units]) {
169
186
  const isBuiltin = builtins.includes(f)
170
- const def = await load(f)
187
+ let def: CheckDef | null
188
+ try {
189
+ def = await load(f)
190
+ } catch (e) {
191
+ // a file that does not load is ONE red row, never a dead inventory:
192
+ // every other check still runs, and this one says why in every run
193
+ def = unloadable(f, (e as Error).message)
194
+ }
171
195
  if (def == null) continue
172
196
  const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
173
197
  const fallback = isBuiltin
@@ -32,6 +32,8 @@ export const LAYOUT = {
32
32
  reviews: sk('reviews'),
33
33
  // scratch: plans, logs, leases, detached runs, screenshots
34
34
  tmp: sk('tmp'),
35
+ // built app bundles, one per global process opened as an app (home/gui-app.ts)
36
+ wrapper: sk('wrapper'),
35
37
  // generated on every run
36
38
  status: sk('STATUS.md'),
37
39
  statusFull: sk('STATUS.full.md'),
@@ -22,6 +22,7 @@ import { fallbackNames, isCheckFilePath, readLegacyCheck, type LegacyCheck } fro
22
22
  import type { ScanCtx } from './check-ctx.ts'
23
23
  import type { RepoConfig } from '../repo-config.ts'
24
24
  import { descriptionProblem, type TaskDef } from '../check.ts'
25
+ import { deriveExplicit, type Derived } from './port-check-file.ts'
25
26
 
26
27
  /** the narrow module every rewritten file imports `check` from, and the ONE
27
28
  * spelling: the barrel costs 400ms of module load and discovery imports
@@ -130,16 +131,34 @@ export const PLACEHOLDER_MARK = 'TODO: state what must be true'
130
131
  * drops none of them. */
131
132
  export function scriptPort(read: LegacyCheck, fallbackBlocking: boolean | undefined): string {
132
133
  const blocking = read.blocking ?? fallbackBlocking
134
+ // what the ported `run` body resolves to: the script's own declarations,
135
+ // read as the gate read them, on a body keyed on what it reads
136
+ const d = explicitOf(deriveExplicit({
137
+ when: read.gate ? null : 'health',
138
+ build: false,
139
+ body: 'run',
140
+ ...(read.inputs != null ? { inputs: `[${read.inputs.map(q).join(', ')}]` } : {}),
141
+ ...(read.ttl != null ? { ttl: q(read.ttl) } : {}),
142
+ ...(read.never != null ? { never: q(read.never) } : {}),
143
+ repair: false,
144
+ rawAccess: null,
145
+ }, q))
133
146
  const fields = [
134
147
  blocking == null ? 'blocking: <true | false, it declares none>' : `blocking: ${blocking}`,
135
- ...(read.inputs != null ? [`inputs: [${read.inputs.map(q).join(', ')}]`] : []),
136
- ...(read.ttl != null ? [`ttl: ${q(read.ttl)}`] : []),
137
- ...(read.never != null ? [`never: ${q(read.never)}`] : []),
138
- ...(read.gate ? [] : [`when: 'health'`]),
148
+ `runs: ${d.runs}`,
149
+ `cache: ${d.cache}`,
150
+ `fix: ${q(d.fix)}`,
151
+ `applies: ${d.applies}`,
139
152
  ]
140
153
  return `a check script, run as its own process: port it by hand into \`export default check({ description, ${fields.join(', ')}, run: (c) => … })\`, each print a call on c (c.ok, c.warn, c.fail)`
141
154
  }
142
155
 
156
+ /** a derivation that cannot fail on what a script or a task can declare */
157
+ function explicitOf(d: Derived | { error: string }): Derived {
158
+ if ('error' in d) throw new Error(`the legacy declaration has no explicit form: ${d.error}`)
159
+ return d
160
+ }
161
+
143
162
  /** the check file a task's argv runs through bun, `bun <file>` or `bun run
144
163
  * <file>`, relative, `./` prefixed or absolute, resolved against the task's
145
164
  * cwd. `rest` is what the argv passes after the file: a task that passes its
@@ -159,16 +178,24 @@ export function checkFileRunBy(t: Pick<TaskDef, 'cmd' | 'cwd'>, root: string): {
159
178
  * always was: the gate runs it and the health pass leaves it out. */
160
179
  export function taskCheckSource(t: TaskDef, root: string): string {
161
180
  const own = t.description != null && descriptionProblem(t.description) == null ? t.description.trim() : null
162
- const fields = [`id: ${q(t.id)},`, `description: ${q(own ?? descriptionPlaceholder())},`, 'blocking: true,', 'build: true,', `cmd: [${t.cmd!.map(q).join(', ')}],`]
181
+ // where it ran and how it was kept, as the gate and the planner read a
182
+ // task: an optIn one ran only by name
183
+ const d = explicitOf(deriveExplicit({
184
+ when: t.optIn === true ? 'health' : null,
185
+ build: true,
186
+ body: 'cmd',
187
+ ...(t.inputs != null ? { inputs: `[${t.inputs.map(q).join(', ')}]` } : {}),
188
+ ...(t.ttl != null ? { ttl: q(t.ttl) } : {}),
189
+ ...(t.never != null ? { never: q(t.never) } : {}),
190
+ repair: false,
191
+ rawAccess: null,
192
+ }, q))
193
+ const fields = [`id: ${q(t.id)},`, `description: ${q(own ?? descriptionPlaceholder())},`, 'blocking: true,', 'build: true,', `runs: ${d.runs},`, `cache: ${d.cache},`, `fix: ${q(d.fix)},`, `applies: ${d.applies},`, `cmd: [${t.cmd!.map(q).join(', ')}],`]
163
194
  if (t.deps != null && t.deps.length > 0) fields.push(`deps: [${t.deps.map(q).join(', ')}],`)
164
- if (t.inputs != null) fields.push(`inputs: [${t.inputs.map(q).join(', ')}],`)
165
- if (t.ttl != null) fields.push(`ttl: ${q(t.ttl)},`)
166
- if (t.never != null) fields.push(`never: ${q(t.never)},`)
167
195
  if (t.group != null) fields.push(`group: ${q(t.group)},`)
168
196
  if (t.timeoutMs != null) fields.push(`timeoutMs: ${t.timeoutMs},`)
169
197
  if (t.env != null) fields.push(`env: { ${Object.entries(t.env).map(([k, v]) => `${q(k)}: ${q(v)}`).join(', ')} },`)
170
198
  if (t.cwd != null) fields.push(`cwd: ${q(t.cwd)},`)
171
- if (t.optIn === true) fields.push(`when: 'health',`)
172
199
  const w = checkWrapper(root)
173
200
  return [...w.head, w.open, ...fields.map((f) => ` ${f}`), w.close, ''].join('\n')
174
201
  }
@@ -189,6 +216,8 @@ export function dropConfigKey(text: string, key: string): string | null {
189
216
  const start = at[0]!
190
217
  const end = statementEnd(lines, start)
191
218
  let from = start
192
- while (from > 0 && lines[from - 1]!.trim().startsWith('//')) from--
219
+ // the comment block above the key goes with it, never a managed block
220
+ // marker (`// >>> shipkit …`): the key may be the first line inside one
221
+ while (from > 0 && lines[from - 1]!.trim().startsWith('//') && !/^\/\/ (>>>|<<<) shipkit/.test(lines[from - 1]!.trim())) from--
193
222
  return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
194
223
  }