rvlib-shipkit 1.4.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 (161) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/check.ts +68 -95
  5. package/src/checks/SK001-package-json-shape.check.ts +6 -2
  6. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  7. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  8. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  9. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  10. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  11. package/src/checks/SK007-github-visibility.check.ts +4 -2
  12. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  13. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  14. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  15. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  16. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  17. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  18. package/src/checks/SK014-prose-ste.check.ts +4 -0
  19. package/src/checks/SK015-nested-repo.check.ts +4 -2
  20. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  21. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  22. package/src/checks/SK018-test-files-run.check.ts +6 -2
  23. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  24. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  25. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  26. package/src/checks/SK024-identity-colors.check.ts +5 -3
  27. package/src/checks/SK025-release-drift.check.ts +4 -5
  28. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  29. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  30. package/src/checks/SK028-readme.check.ts +5 -1
  31. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  32. package/src/cli/cmd_Asks.ts +5 -5
  33. package/src/cli/cmd_Assets.ts +7 -7
  34. package/src/cli/cmd_Bump.ts +12 -11
  35. package/src/cli/cmd_Check.ts +8 -7
  36. package/src/cli/cmd_Checks.ts +7 -6
  37. package/src/cli/cmd_Ci.ts +12 -11
  38. package/src/cli/cmd_Commands.ts +16 -13
  39. package/src/cli/cmd_ConfigKeys.ts +7 -6
  40. package/src/cli/cmd_Contracts.ts +3 -3
  41. package/src/cli/cmd_CrossRepoActivity.ts +6 -5
  42. package/src/cli/cmd_Fleet.ts +9 -7
  43. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  44. package/src/cli/cmd_Home.ts +23 -17
  45. package/src/cli/cmd_Hooks.ts +6 -5
  46. package/src/cli/cmd_Init.ts +5 -4
  47. package/src/cli/cmd_Keys.ts +8 -7
  48. package/src/cli/cmd_Mv.ts +7 -6
  49. package/src/cli/cmd_Needs.ts +4 -3
  50. package/src/cli/cmd_Overview.ts +4 -3
  51. package/src/cli/cmd_Pb.ts +2 -2
  52. package/src/cli/cmd_Planned.ts +6 -5
  53. package/src/cli/cmd_Prose.ts +5 -5
  54. package/src/cli/cmd_Reflect.ts +7 -7
  55. package/src/cli/cmd_Release.ts +6 -5
  56. package/src/cli/cmd_Releases.ts +5 -4
  57. package/src/cli/cmd_Serve.ts +4 -3
  58. package/src/cli/cmd_Smoke.ts +5 -4
  59. package/src/cli/cmd_Start.ts +7 -6
  60. package/src/cli/cmd_Status.ts +4 -3
  61. package/src/cli/cmd_Tui.ts +3 -2
  62. package/src/cli/cmd_When.ts +71 -0
  63. package/src/cli/cmd_Wiki.ts +4 -4
  64. package/src/cli.ts +7 -2
  65. package/src/collections/checks.ts +2 -0
  66. package/src/collections/collection-types.ts +9 -4
  67. package/src/core/canonical-scripts.ts +1 -1
  68. package/src/core/check-runner.ts +37 -20
  69. package/src/core/config-rewrite.ts +37 -24
  70. package/src/core/discover-checks.ts +23 -2
  71. package/src/core/legacy-shapes.ts +39 -10
  72. package/src/core/one-way-in-policy.ts +2 -2
  73. package/src/core/outcome-words.ts +18 -2
  74. package/src/core/port-check-cli.ts +52 -0
  75. package/src/core/port-check-file.ts +295 -0
  76. package/src/core/port-check-files.ts +31 -0
  77. package/src/core/retention.ts +0 -14
  78. package/src/core/run-check.ts +12 -9
  79. package/src/core/run-ci.ts +8 -4
  80. package/src/core/run-fix.ts +30 -23
  81. package/src/core/run-fixers.ts +42 -5
  82. package/src/core/runs-matrix.ts +179 -0
  83. package/src/core/runs.ts +242 -0
  84. package/src/core/update-policy.ts +20 -0
  85. package/src/core/when-block.ts +29 -30
  86. package/src/core/when.ts +41 -103
  87. package/src/core/wire.ts +10 -14
  88. package/src/fleet/scan-fleet.ts +20 -4
  89. package/src/fleet/update-fleet.ts +6 -6
  90. package/src/home/home-launchd.ts +20 -4
  91. package/src/home/home-routes.ts +12 -1
  92. package/src/home/hubs.ts +5 -5
  93. package/src/home/pm.ts +8 -0
  94. package/src/home/run-pm.ts +1 -0
  95. package/src/home/serve-web.ts +2 -2
  96. package/src/home/supervise.ts +24 -1
  97. package/src/hooks/run-hooks.ts +48 -4
  98. package/src/init-repo.ts +6 -6
  99. package/src/ledger/gate-checks.ts +113 -59
  100. package/src/ledger/git-key.ts +5 -1
  101. package/src/ledger/ledger.ts +5 -5
  102. package/src/ledger/toolchain-identity.ts +45 -27
  103. package/src/plan/repo-source.ts +18 -39
  104. package/src/release/run-release.ts +5 -0
  105. package/src/repo-config.ts +30 -55
  106. package/src/rules/rule.ts +2 -2
  107. package/src/scaffold/files/agent-coding.md +1 -1
  108. package/src/scaffold/files/agent-index.md +1 -1
  109. package/src/scaffold/files/verify-skill.md +2 -2
  110. package/src/serve/hub-app/activity-st.ts +5 -4
  111. package/src/serve/hub-app/activity-view.tsx +1 -1
  112. package/src/serve/hub-app/admin-view.tsx +13 -11
  113. package/src/serve/hub-app/check-view.tsx +66 -46
  114. package/src/serve/hub-app/check-words.ts +1 -11
  115. package/src/serve/hub-app/checks-svc.ts +14 -35
  116. package/src/serve/hub-app/col-tables-view.tsx +13 -6
  117. package/src/serve/hub-app/cols-svc.ts +5 -4
  118. package/src/serve/hub-app/dock-st.ts +3 -2
  119. package/src/serve/hub-app/dock-view.tsx +36 -0
  120. package/src/serve/hub-app/dock.css +6 -0
  121. package/src/serve/hub-app/float-cards.tsx +30 -0
  122. package/src/serve/hub-app/floating.tsx +41 -4
  123. package/src/serve/hub-app/git-st.ts +42 -0
  124. package/src/serve/hub-app/git-view.tsx +61 -0
  125. package/src/serve/hub-app/hash-route.ts +45 -1
  126. package/src/serve/hub-app/header-bar.tsx +62 -72
  127. package/src/serve/hub-app/health-svc.ts +3 -2
  128. package/src/serve/hub-app/home-st.ts +14 -2
  129. package/src/serve/hub-app/home-strip.css +43 -8
  130. package/src/serve/hub-app/home-strip.tsx +111 -47
  131. package/src/serve/hub-app/home.html +1 -0
  132. package/src/serve/hub-app/hub-app.tsx +14 -4
  133. package/src/serve/hub-app/hub-types.ts +5 -2
  134. package/src/serve/hub-app/hub.css +65 -43
  135. package/src/serve/hub-app/icons.tsx +11 -0
  136. package/src/serve/hub-app/index.html +1 -0
  137. package/src/serve/hub-app/keys.ts +5 -2
  138. package/src/serve/hub-app/live-svc.ts +3 -2
  139. package/src/serve/hub-app/machine-st.ts +18 -22
  140. package/src/serve/hub-app/plan-view.tsx +51 -0
  141. package/src/serve/hub-app/planned-st.ts +9 -4
  142. package/src/serve/hub-app/processes-view.tsx +38 -0
  143. package/src/serve/hub-app/projects-svc.ts +15 -10
  144. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  145. package/src/serve/hub-app/reach.ts +36 -0
  146. package/src/serve/hub-app/readme-svc.ts +3 -2
  147. package/src/serve/hub-app/repo-sections.ts +117 -0
  148. package/src/serve/hub-app/repo-st.ts +46 -9
  149. package/src/serve/hub-app/sidebar.tsx +12 -37
  150. package/src/serve/hub-app/timeline-svc.ts +3 -2
  151. package/src/serve/hub-app/tree-build.ts +149 -155
  152. package/src/serve/hub-app/tree-row.tsx +39 -9
  153. package/src/serve/hub-app/tree-svc.ts +70 -49
  154. package/src/serve/hub-app/when-view.tsx +89 -0
  155. package/src/serve/hub-app/when.css +17 -0
  156. package/src/serve/hub-runtime.ts +3 -16
  157. package/src/serve/run-serve.ts +1 -1
  158. package/src/serve/serve-hub.ts +4 -14
  159. package/src/serve/hub-app/processes-svc.ts +0 -63
  160. package/src/serve/hub-app/repo-sections.tsx +0 -61
  161. package/src/serve/repo-processes.ts +0 -89
package/src/home/pm.ts CHANGED
@@ -27,6 +27,10 @@ export type ChildView = ProcView & { tail: string[] }
27
27
  /** where pm, the daemon and the web listen */
28
28
  export type CorePorts = { pm: number; daemon: number; web: number }
29
29
 
30
+ /** a child whose pid lives but has not answered for this long is hung and
31
+ * gets killed: a healthy daemon answers within about 20 s of its start */
32
+ export const CHILD_HANG_MS = 90_000
33
+
30
34
  /** how many log lines each of shipkit's own processes hands the page */
31
35
  const CORE_TAIL = 20
32
36
 
@@ -61,6 +65,8 @@ export type PmDeps = {
61
65
  /** runs a stop or restart of the web once its answer is out: the web
62
66
  * proxies that very request (default: 200 ms later) */
63
67
  later?: (f: () => void) => void
68
+ /** where pm says what it did on its own, a hung child killed */
69
+ say?: (line: string) => void
64
70
  }
65
71
 
66
72
  const WEB_ANSWER_MS = 200
@@ -79,6 +85,8 @@ export class Pm {
79
85
  probe: () => d.probe(role),
80
86
  // a crash loop while its code is being edited heals itself
81
87
  neverGiveUp: true,
88
+ hangMs: CHILD_HANG_MS,
89
+ say: d.say,
82
90
  }, d.deps ?? realDeps)]))
83
91
  }
84
92
 
@@ -100,6 +100,7 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
100
100
  cmd: (role) => [process.execPath, CLI, role],
101
101
  probe: roleAnswers,
102
102
  resources: new Resources(c.processes, dir),
103
+ say: io.printErr,
103
104
  })
104
105
  let server: Bun.Server<unknown>
105
106
  try {
@@ -51,8 +51,8 @@ export const REPO_ANSWER_MS = 30_000
51
51
  export const LIST_EVERY_MS = 30_000
52
52
  export const LIST_RETRY_MS = 1_000
53
53
 
54
- /** the one home answer that can wait on git: the activity page's first read of a window */
55
- export const readsGit = (path: string): boolean => path === '/api/home/activity.json'
54
+ /** the home answers that can wait on git: the activity page's first read of a window, and a repo's git page */
55
+ export const readsGit = (path: string): boolean => path === '/api/home/activity.json' || path === '/api/home/git.json'
56
56
 
57
57
  /** a run or a check run streams for minutes, and a silent one is never cut */
58
58
  export const streams = (path: string): boolean => /\/api\/(run|check\/run)$/.test(path)
@@ -29,6 +29,12 @@ export type ProcSpec = {
29
29
  /** never left down after CRASH_LIMIT crashes: it keeps retrying at the
30
30
  * longest backoff, so a code fix brings it back with no hand */
31
31
  neverGiveUp?: boolean
32
+ /** a pid that lives but has not answered its probe for this long is hung
33
+ * (a main thread stuck in one synchronous step still holds its port):
34
+ * killed, then started again after the crash backoff. None = never */
35
+ hangMs?: number
36
+ /** a line for every act the supervisor takes on its own */
37
+ say?: (line: string) => void
32
38
  }
33
39
 
34
40
  export type ProcView = {
@@ -137,6 +143,8 @@ export class Proc {
137
143
  private retryAt: number | null = null
138
144
  private killAt: number | null = null
139
145
  private probing = false
146
+ /** why the kill in flight was a hang kill, for the crash note */
147
+ private hung: string | null = null
140
148
  /** a restart: start again once the stop landed */
141
149
  private again = false
142
150
  /** it answers its probe with no pid of ours behind it */
@@ -275,6 +283,8 @@ export class Proc {
275
283
  const now = this.d.now()
276
284
  if (this.pid != null && !this.d.alive(this.pid)) {
277
285
  this.gone()
286
+ const hung = this.hung
287
+ this.hung = null
278
288
  if (this.again) {
279
289
  this.again = false
280
290
  this.want = 'up'
@@ -287,7 +297,7 @@ export class Proc {
287
297
  this.set('crashed', `exited ${this.crashes.length} times in ${Math.round(CRASH_WINDOW_MS / 60_000)} min, left down: see the log`)
288
298
  } else {
289
299
  this.retryAt = now + BACKOFF_MS[Math.min(this.crashes.length - 1, BACKOFF_MS.length - 1)]!
290
- this.set('crashed', `exited, restart in ${Math.round((this.retryAt - now) / 1000)}s`)
300
+ this.set('crashed', `${hung != null ? `${hung}, killed` : 'exited'}, restart in ${Math.round((this.retryAt - now) / 1000)}s`)
291
301
  }
292
302
  }
293
303
  }
@@ -318,6 +328,19 @@ export class Proc {
318
328
  } finally {
319
329
  this.probing = false
320
330
  }
331
+ this.killIfHung(now)
332
+ }
333
+
334
+ /** a pid that lives and still has not answered, hangMs after it last
335
+ * changed status, is stopped: the crash path starts it again */
336
+ private killIfHung(now: number): void {
337
+ const hang = this.spec.hangMs
338
+ if (hang == null || this.status !== 'starting' || this.pid == null || this.want !== 'up' || now - this.since < hang) return
339
+ this.hung = `did not answer its probe for ${Math.round((now - this.since) / 1000)}s`
340
+ this.spec.say?.(`WARN ${this.spec.id} (pid ${this.pid}) ${this.hung}: killed, it starts again`)
341
+ this.d.kill(this.pid, 'SIGTERM')
342
+ this.killAt = now + KILL_AFTER_MS
343
+ this.set('stopping', `${this.hung}, killing it`)
321
344
  }
322
345
 
323
346
  view(): ProcView {
@@ -14,6 +14,11 @@ import { gitRoot } from '../core/git-root.ts'
14
14
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
15
15
  import type { RepoConfig } from '../repo-config.ts'
16
16
  import { gitBin } from '../core/git-bin.ts'
17
+ import { discoverChecks } from '../core/discover-checks.ts'
18
+ import { CheckCtx } from '../core/check-ctx.ts'
19
+ import { runCheckBody, type CheckDef } from '../check.ts'
20
+ import { repoOff } from '../core/when.ts'
21
+ import { formatLine } from '../core/wire.ts'
17
22
 
18
23
  export type HooksConfig = {
19
24
  /** repo-specific commands run in the pre-commit gate, after the built-ins.
@@ -210,7 +215,7 @@ export function blockedStaged(staged: string[], config: RepoConfig): string[] {
210
215
  const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
211
216
 
212
217
  /** the pre-commit gate: built-in leak guard, then the repo's own commands.
213
- * A commit takes ~1s (build/shipkit.md): the gate times itself, and going
218
+ * A commit takes ~1s (build/shipkit/commit-gates.md): the gate times itself, and going
214
219
  * over budget WARNs naming the fix, so drift back to slow commits is loud. */
215
220
  export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
216
221
  const started = performance.now()
@@ -273,6 +278,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
273
278
  ],
274
279
  })
275
280
  }
281
+ out.push(...(await runPreCommitChecks(root, config)))
276
282
  for (const c of h.preCommit ?? []) {
277
283
  const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
278
284
  out.push({
@@ -282,15 +288,52 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
282
288
  })
283
289
  }
284
290
  const elapsed = performance.now() - started
285
- if (elapsed > 1500)
291
+ if (elapsed > HOOK_BUDGET_MS)
286
292
  out.push({
287
293
  id: 'hook-budget',
288
294
  ok: true,
289
- lines: [`WARN pre-commit took ${(elapsed / 1000).toFixed(1)}s (budget ~1s) — move slow preCommit commands to ci tasks (build/shipkit.md)`],
295
+ lines: [`WARN pre-commit took ${(elapsed / 1000).toFixed(1)}s (budget ${HOOK_BUDGET_MS / 1000}s) — move slow preCommit commands to ci tasks, or a check off onPreCommit (build/shipkit/runs.md)`],
290
296
  })
291
297
  return out
292
298
  }
293
299
 
300
+ /** the whole hook, boot included, past which it warns */
301
+ export const HOOK_BUDGET_MS = 1_000
302
+
303
+ /** the checks that declare `onPreCommit` and apply here, with their budget */
304
+ export type PreCommitCheck = { id: string; title: string; owner: string; budgetMs: number; run: NonNullable<CheckDef['run']>; repair: unknown }
305
+
306
+ export async function preCommitChecks(root: string, config: RepoConfig): Promise<PreCommitCheck[]> {
307
+ const out: PreCommitCheck[] = []
308
+ for (const u of await discoverChecks(root)) {
309
+ const p = u.def.runs.onPreCommit
310
+ if (p === false || u.def.run == null) continue
311
+ if (repoOff(config, u.id) != null) continue
312
+ if (typeof u.def.applies === 'function') {
313
+ try { if (u.def.applies({ root, config }) != null) continue } catch (e) { console.error(`WARN ${u.id} applies threw, so it applies: ${(e as Error).message}`) }
314
+ }
315
+ out.push({ id: u.id, title: u.title, owner: u.owner, budgetMs: p.budgetMs, run: u.def.run, repair: u.def.repair })
316
+ }
317
+ return out
318
+ }
319
+
320
+ /** every `onPreCommit` check, run RAW on the working tree: no ledger, no
321
+ * cache, no repair. A failure refuses the commit, a pass over its budget
322
+ * warns by name and never blocks on time */
323
+ async function runPreCommitChecks(root: string, config: RepoConfig): Promise<GateOutcome[]> {
324
+ const out: GateOutcome[] = []
325
+ for (const k of await preCommitChecks(root, config)) {
326
+ const c = CheckCtx.open({ root, config })
327
+ const t = performance.now()
328
+ await runCheckBody({ id: k.id, title: k.title, owner: k.owner, run: k.run, fix: k.repair }, c)
329
+ const ms = performance.now() - t
330
+ const lines = c.failed ? c.lines.map((l) => formatLine(l, false)) : [`ok: ${k.id} (${Math.round(ms)}ms)`]
331
+ if (ms > k.budgetMs) lines.push(`WARN ${k.id} took ${Math.round(ms)}ms at commit, over its ${k.budgetMs}ms onPreCommit budget — make it cheaper, or move it off onPreCommit`)
332
+ out.push({ id: k.id, ok: !c.failed, lines })
333
+ }
334
+ return out
335
+ }
336
+
294
337
  /** the commit-msg gate: the shared message predicate, then the repo's own. */
295
338
  export async function runCommitMsg(root: string, config: RepoConfig, messageFile: string): Promise<GateOutcome[]> {
296
339
  const h = config.hooks ?? {}
@@ -339,7 +382,7 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
339
382
  }
340
383
 
341
384
  /** what blocks a commit here, the enumeration hand-rolled hooks never had. */
342
- export function listGates(config: RepoConfig, root = process.cwd()): string[] {
385
+ export function listGates(config: RepoConfig, root = process.cwd(), checks: { id: string; budgetMs: number }[] = []): string[] {
343
386
  const h = config.hooks ?? {}
344
387
  const off = new Set(h.disable ?? [])
345
388
  const kw = h.keywordFile ?? KEYWORD_FILE
@@ -354,6 +397,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
354
397
  `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
355
398
  ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
356
399
  `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
400
+ ...checks.map((c) => `pre-commit ${c.id} — the check, onPreCommit, budget ${c.budgetMs}ms`),
357
401
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
358
402
  off.has('commit-message')
359
403
  ? `commit-msg commit-message (DISABLED by config) — dash punctuation + French; ai-attribution stays on, never disabled`
package/src/init-repo.ts CHANGED
@@ -100,7 +100,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
100
100
  // its .gitignore policy block (same fixer as `check --fix SK004`),
101
101
  // with the repo's own contributed lines honored
102
102
  const cfg = await loadRepoConfig(root)
103
- const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: cfg }))
103
+ const g = await gitignorePolicy.repair!(CheckCtx.open({ root, config: cfg }))
104
104
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
105
105
  // the repos that need ONE WAY IN most are the ones that already exist,
106
106
  // so a re-run REPAIRS rather than only seeding a fresh checkout
@@ -136,7 +136,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
136
136
  }
137
137
  // the .gitignore policy block (contract git policy) — same fixer as
138
138
  // `shipkit check --fix SK004`, one code path
139
- const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: defineRepo({ name }) }))
139
+ const g = await gitignorePolicy.repair!(CheckCtx.open({ root, config: defineRepo({ name }) }))
140
140
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
141
141
  await repairOneWayIn(root, defineRepo({ name }), p)
142
142
  await furnishEditor(root, { name }, p)
@@ -194,7 +194,7 @@ async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?
194
194
  // a file SK017 cannot read is still the repo's own: it is never
195
195
  // reported as missing, and it is never rewritten
196
196
  let hub: { changed: boolean; summary: string }
197
- try { hub = await vscodeHubTask.fix!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
197
+ try { hub = await vscodeHubTask.repair!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
198
198
  p('ok: .vscode/ is this repo\'s own, left untouched')
199
199
  return void p(`WARN .vscode/tasks.json does not parse (${e instanceof Error ? e.message : String(e)}): the hub task gets no problem matcher until it does`)
200
200
  }
@@ -208,14 +208,14 @@ async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?
208
208
  }
209
209
 
210
210
  /** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
211
- * (build/shipkit.md). Nothing here is a second implementation: rule SK001
211
+ * (build/shipkit/one-way-in.md). Nothing here is a second implementation: rule SK001
212
212
  * routes the package.json scripts, rule SK013 writes the harness policy, and
213
213
  * init just calls them so a wired repo is born enforced and a re-run repairs
214
214
  * one that was not. The deny is a RESTRICTION, which is why it lands
215
215
  * unconditionally where the scaffold's allow list stays opt in. */
216
216
  async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
217
- const pkg = await packageJsonShape.fix!(CheckCtx.open({ root, config }))
217
+ const pkg = await packageJsonShape.repair!(CheckCtx.open({ root, config }))
218
218
  if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
219
- const policy = await oneWayInPolicy.fix!(CheckCtx.open({ root, config }))
219
+ const policy = await oneWayInPolicy.repair!(CheckCtx.open({ root, config }))
220
220
  p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
221
221
  }
@@ -19,11 +19,12 @@ import { discoverLegacyChecks } from '../core/discover-legacy-checks.ts'
19
19
  import { checkFileRunBy } from '../core/legacy-shapes.ts'
20
20
  import { ruleCacheExtra } from '../rules/rule.ts'
21
21
  import { discoverChecks } from '../core/discover-checks.ts'
22
- import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom, type CheckEnv } from '../check.ts'
22
+ import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom, type CheckEnv, type CheckDef } from '../check.ts'
23
23
  import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/check-runner.ts'
24
- import { ttlMsOf } from '../core/wire.ts'
24
+ import { FOREVER, ttlMsOf } from '../core/wire.ts'
25
25
  import { budgetOf } from '../core/budget.ts'
26
- import { whenOfLegacyCheck, whenOfDisabledSlot, whenOfRule, whenOfTask, whenOfCheck, type WhenDecl } from '../core/when.ts'
26
+ import { repoOff, withRepoOff, whenSummary, type WhenDecl } from '../core/when.ts'
27
+ import { cacheText, filesText, NOWHERE, sourced, type CacheDecl, type ResolvedRuns, type Runs } from '../core/runs.ts'
27
28
  import type { CheckDecl } from './git-key.ts'
28
29
  import { populationOf } from '../problems/problem-line.ts'
29
30
  import { rawAccessInClosure, rawAccessLine } from '../core/raw-access.ts'
@@ -31,7 +32,6 @@ import type { RvtConfig } from '../ts/config.ts'
31
32
  import { LIB_ROOT } from '../core/lib-root.ts'
32
33
  import type { DiscoveredCheck } from '../core/discover-checks.ts'
33
34
  import { declaredTestUnits, inputsOutsideUnits, isTestFile } from '../suite/test-units.ts'
34
- import type { PlanDecl } from '../plan/cadence.ts'
35
35
 
36
36
  /** why an undeclared `run` check cannot be keyed on its reads, or null when it
37
37
  * can. A built-in is walked inside the toolkit, a repo check inside the repo
@@ -74,18 +74,6 @@ export async function allTasks(c: RepoConfig, root: string): Promise<FloorResolu
74
74
  /** one gate check with everything a reader asks about it, not only what the
75
75
  * key layer asks. The cache half stays in `decl` so `CheckDecl` never grows
76
76
  * fields the key layer must then decide to ignore. */
77
- /** a check's `planOff`, read now; one that throws stays planned, and says so */
78
- function planOffOf(id: string, fn: ((env: CheckEnv) => string | null) | undefined, env: CheckEnv): { planOff?: string } {
79
- if (fn == null) return {}
80
- try {
81
- const why = fn(env)
82
- return why == null ? {} : { planOff: why }
83
- } catch (e) {
84
- console.error(`WARN ${id} planOff threw, so it stays planned: ${(e as Error).message}`)
85
- return {}
86
- }
87
- }
88
-
89
77
  export type CheckRow = {
90
78
  decl: CheckDecl
91
79
  /** the display name an operator reads (its title, else the id) */
@@ -126,16 +114,16 @@ export type CheckRow = {
126
114
  * then re-runs the check. `shipkit checks --fixable` is the catalog of
127
115
  * everything in this repo that repairs itself. */
128
116
  fixable: boolean
129
- /** WHERE IT RUNS, the one reading of the `when` key, of the three
130
- * spellings it replaces, and of a floor slot turned off (core/when.ts) */
117
+ /** WHERE IT RUNS, every context resolved: what the check file declared,
118
+ * or what the code deriving the row wrote, with the repo's `off`
119
+ * override laid over it (core/runs.ts, core/when.ts) */
120
+ runs: ResolvedRuns
121
+ /** how its answer is kept, as one short cell (core/runs.ts cacheText) */
122
+ cache: string
123
+ /** the coarse word the runs read as, for the surfaces that show one */
131
124
  when: WhenDecl
132
125
  /** what it waits on, null when it waits on nothing */
133
126
  deps: string[] | null
134
- /** when the planner runs it on its own (check.ts `every`, `cron`,
135
- * `whenInactive`). Absent: nothing declared, the planner's defaults */
136
- plan?: PlanDecl
137
- /** why the plan leaves this check out in this repo (check.ts `planOff`) */
138
- planOff?: string
139
127
  /** whose check it is: `shipkit` = it ships with the toolkit (a built-in
140
128
  * SK check, the contracts pass, a floor slot nothing in the repo filled),
141
129
  * `repo` = this repo declared it (a package.json script behind a floor
@@ -178,7 +166,7 @@ function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?
178
166
  owner: row.owner ?? row.decl.id,
179
167
  blocking: row.blocking,
180
168
  build: row.build,
181
- when: row.when,
169
+ runs: row.runs,
182
170
  deps: row.deps ?? [],
183
171
  group: row.group,
184
172
  timeoutMs: row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS),
@@ -211,6 +199,59 @@ function rootTestKey(root: string, c: RepoConfig, floor: FloorResolution): { inp
211
199
  const taskDescription = (t: TaskDef): string =>
212
200
  describeFirst(t.description, t.cmd == null ? null : `\`${t.cmd.join(' ')}\` exits 0`, `the ${t.id} task ${t.cmd == null ? 'passes' : 'exits 0'}`)
213
201
 
202
+ /** the default range a repo row nothing can key runs on, so it is asked
203
+ * about once a day and never in step with its siblings */
204
+ const DAILY: [string, string] = ['20h', '28h']
205
+
206
+ /** the runs of a row with no file to declare them in (a floor slot, a config
207
+ * task, a legacy rule or script), written out in full with the reason. It
208
+ * keeps what the planner and the gate did with such a row before the
209
+ * declaration was explicit: a ttl is the cadence ('once' for a forever
210
+ * fact), a save moves a keyed row or a fact, a gate build step nothing keys
211
+ * reruns after every save burst, and any other row nothing keys runs about
212
+ * once a day */
213
+ function derivedRuns(o: { gate: boolean; build: boolean; inputs?: string[]; ttl?: string; kind: string; repair: boolean }): ResolvedRuns {
214
+ const keyed = o.inputs != null
215
+ const ttl = o.ttl != null && o.ttl !== '' ? o.ttl : null
216
+ const fact = ttl === FOREVER
217
+ const parked = o.build && !o.gate
218
+ const onClock: Runs['onClock'] = parked ? false : fact ? 'once' : ttl != null ? { every: ttl, inactive: 'wait' } : o.build || keyed ? false : { every: DAILY, inactive: 'wait' }
219
+ const onSave: Runs['onSave'] = parked ? false : fact || (keyed && ttl == null) ? 'moved' : ttl == null && o.build ? 'burst' : false
220
+ const r: Runs = { ...NOWHERE, onCi: o.gate, onFull: o.gate, onRelease: o.gate, onHealth: !o.build, onSave, onClock }
221
+ const fix = !o.repair ? 'none' : o.gate ? 'ci' : 'manual'
222
+ const clock = onClock === false ? '' : onClock === 'once' ? ', once while its fact is unknown' : `, on the clock every ${ttl ?? DAILY.join('..')}`
223
+ const save = onSave === 'moved' ? ', the daemon when its key moves' : onSave === 'burst' ? ', the daemon after every save burst since nothing keys it' : ''
224
+ const where = parked ? 'parked: only --with or --only names it' : `${o.gate ? 'every gate' : 'no gate'}${o.build ? ', never the health pass' : ' and the health pass'}${save}${clock}`
225
+ return sourced(r, fix, 'derived', `${o.kind}: ${where}`)
226
+ }
227
+
228
+ /** the cache half of a check file, as the key layer reads it */
229
+ function declOfCache(cache: CacheDecl, env: CheckEnv): { inputs?: string[]; ttl?: string; never?: string; key?: string; reads?: boolean } {
230
+ switch (cache.by) {
231
+ case 'files': return { inputs: cache.inputs, key: cache.key?.(env) }
232
+ case 'reads': return { reads: true }
233
+ case 'ttl': return { ttl: cache.span, key: cache.key?.(env) }
234
+ case 'fact': return { ttl: FOREVER, key: cache.key(env) }
235
+ case 'never': return { never: cache.why }
236
+ }
237
+ }
238
+
239
+ /** a legacy row's cache, as one cell */
240
+ const legacyCacheText = (d: { inputs?: string[]; ttl?: string; never?: string }): string =>
241
+ d.inputs != null ? filesText(d.inputs) : d.ttl != null && d.ttl !== '' ? (d.ttl === FOREVER ? 'fact' : `ttl ${d.ttl}`) : d.never != null ? `never: ${d.never}` : 'never: nothing declared'
242
+
243
+ /** a check file's own runs, resolved: its declaration, n/a where it does not
244
+ * apply, then the repo's override */
245
+ function fileRuns(def: CheckDef, id: string, c: RepoConfig, env: CheckEnv): ResolvedRuns {
246
+ const own = sourced(def.runs, def.fix, 'check file')
247
+ let na: string | null = null
248
+ if (typeof def.applies === 'function') {
249
+ try { na = def.applies(env) } catch (e) { console.error(`WARN ${id} applies threw, so it applies: ${(e as Error).message}`) }
250
+ }
251
+ const base = na == null ? own : { ...own, off: `n/a here: ${na}` }
252
+ return withRepoOff(base, repoOff(c, id))
253
+ }
254
+
214
255
  /** THE enumeration. Everything below reads it, nothing else walks. */
215
256
  export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts = {}): Promise<GateCheck[]> {
216
257
  const out: GateCheck[] = []
@@ -252,10 +293,13 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
252
293
  for (const raw of [...floor.tasks, ...(c.tasks ?? []).filter((t) => foldedInto.get(t.id) !== t)]) {
253
294
  const t: TaskDef = full ? { ...raw, env: { ...raw.env, [FULL_TESTS_ENV]: '1' } } : raw
254
295
  const own = t.id === 'test' && floor.tasks.includes(raw) ? rootTest : null
296
+ const inputs = own?.inputs ?? t.inputs
297
+ const kind = floor.tasks.includes(raw) ? 'a floor slot' : 'a repo.config.ts task'
298
+ const runs = withRepoOff(derivedRuns({ gate: t.optIn !== true, build: true, inputs, ttl: t.ttl, kind, repair: false }), repoOff(c, t.id))
255
299
  const row: CheckRow = {
256
300
  decl: {
257
301
  id: t.id,
258
- inputs: own?.inputs ?? t.inputs,
302
+ inputs,
259
303
  ttl: t.ttl,
260
304
  never: t.never,
261
305
  argv: t.cmd,
@@ -266,7 +310,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
266
310
  description: taskDescription(t),
267
311
  // an optIn task is off the default run, which is the same shape as
268
312
  // a check that opted out of the gate
269
- gate: t.optIn !== true,
313
+ gate: runs.off == null && runs.onCi,
270
314
  build: true,
271
315
  // a build step IS the build: it has no non-blocking mode
272
316
  blocking: true,
@@ -278,7 +322,9 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
278
322
  // a build step runs a TOOL, and repairing what a tool reports is
279
323
  // that tool's own job (a formatter, a codemod), never the gate's
280
324
  fixable: false,
281
- when: whenOfTask(c, t),
325
+ runs,
326
+ cache: legacyCacheText({ inputs, ttl: t.ttl, never: t.never }),
327
+ when: whenSummary(runs, repoOff(c, t.id), false),
282
328
  deps: t.deps ?? null,
283
329
  from: taskFrom(t.id),
284
330
  }
@@ -293,6 +339,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
293
339
  // no command, which is why it declares `never` rather than joining the
294
340
  // burn-down of checks nobody has keyed yet.
295
341
  for (const id of floor.disabled) {
342
+ const runs = sourced(NOWHERE, 'none', 'repo.config.ts', null, `defaults.${id}: false`)
296
343
  const row: CheckRow = {
297
344
  decl: { id, never: `turned off by defaults.${id}: false, so there is no command to key` },
298
345
  title: id,
@@ -306,7 +353,9 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
306
353
  budgetMs: budgetOf(id, undefined, { build: true, cmd: false, world: false }, c.budgets),
307
354
  optIn: false,
308
355
  fixable: false,
309
- when: whenOfDisabledSlot(id),
356
+ runs,
357
+ cache: `never: turned off by defaults.${id}: false`,
358
+ when: whenSummary(runs, { reason: `defaults.${id}: false`, source: 'defaults' }, false),
310
359
  deps: null,
311
360
  // the repo wrote the line that turned it off
312
361
  from: 'repo',
@@ -319,46 +368,44 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
319
368
  const configs = new Map<string, RvtConfig>()
320
369
  const texts = new Map<string, string>()
321
370
  for (const u of checks) {
322
- const w = whenOfCheck(c, u)
323
- // an in process body declaring nothing is keyed on what it reads
324
- // through its context (git-key.ts READS). The tier is part of that
325
- // question: a body can read the env, and a quick answer must never
326
- // stand in for a full one
327
- const undeclared = u.def.run != null && u.def.inputs == null && u.def.ttl == null && u.def.never == null
328
- // conservative: any module its body can reach that touches the disk or
329
- // a process around `c` keeps it off its reads (core/raw-access.ts)
330
- const raw = undeclared ? rawAccessOf(root, u, configs, texts) : null
331
- const reads = undeclared && raw == null
332
- const extra = checkCacheExtra({ id: u.id, cacheKey: u.def.cacheKey }, env, w)
371
+ const runs = fileRuns(u.def, u.id, c, env)
372
+ const cache = declOfCache(u.def.cache, env)
373
+ // a body keyed on what it reads through its context (git-key.ts
374
+ // READS). The tier is part of that question: a body can read the env,
375
+ // and a quick answer must never stand in for a full one. Any module
376
+ // its body can reach that touches the disk or a process around `c`
377
+ // contradicts the declaration (core/raw-access.ts), and says so loud
378
+ const raw = cache.reads === true && u.def.run != null ? rawAccessOf(root, u, configs, texts) : null
379
+ const extra = checkCacheExtra(cache.key, runs.off)
333
380
  const row: CheckRow = {
334
381
  decl: {
335
382
  id: u.id,
336
- inputs: u.def.inputs,
383
+ inputs: cache.inputs,
337
384
  code: u.rel == null ? undefined : [u.rel],
338
- ttl: u.def.ttl,
339
- never: u.def.never,
385
+ ttl: cache.ttl,
386
+ never: cache.never,
340
387
  argv: u.def.cmd,
341
- extra: reads ? `${extra ?? ''}|tier=${full ? 'full' : 'quick'}` : extra,
388
+ extra: cache.reads === true ? `${extra ?? ''}|tier=${full ? 'full' : 'quick'}` : extra,
342
389
  writes: u.def.writes,
343
- ...(reads ? { reads: true } : {}),
344
- ...(raw != null ? { uncacheable: raw } : {}),
390
+ ...(cache.reads === true && raw == null ? { reads: true } : {}),
391
+ ...(raw != null ? { declError: `declares cache reads, but ${raw}` } : {}),
345
392
  },
346
393
  title: u.title,
347
394
  description: u.description,
348
- gate: w.at !== 'health',
395
+ gate: runs.off == null && runs.onCi,
349
396
  build: u.def.build === true,
350
397
  blocking: u.def.blocking,
351
398
  owner: u.owner,
352
399
  group: u.def.group ?? null,
353
400
  timeoutMs: u.def.timeoutMs ?? null,
354
- budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: u.def.ttl != null || u.def.never != null }, c.budgets),
401
+ budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: cache.ttl != null || cache.never != null }, c.budgets),
355
402
  optIn: false,
356
- fixable: u.def.fix != null,
357
- when: w,
403
+ fixable: u.def.repair != null,
404
+ runs,
405
+ cache: cacheText(u.def.cache),
406
+ when: whenSummary(runs, repoOff(c, u.id), true),
358
407
  deps: u.def.deps ?? null,
359
408
  from: u.from,
360
- ...(u.def.every !== undefined || u.def.cron != null || u.def.whenInactive != null ? { plan: { every: u.def.every, cron: u.def.cron, whenInactive: u.def.whenInactive } } : {}),
361
- ...planOffOf(u.id, u.def.planOff, env),
362
409
  }
363
410
  const body: CheckBody = {
364
411
  ...(u.def.cmd == null ? {} : { cmd: u.def.cmd }),
@@ -367,11 +414,12 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
367
414
  // relative to the root, and an absolute one stands as written
368
415
  ...(u.def.cwd == null ? {} : { cwd: resolve(root, u.def.cwd) }),
369
416
  }
370
- out.push({ row, runnable: runnableOf(row, body, { fix: u.def.fix, pointer: u.def.cmd?.join(' ') ?? null }) })
417
+ out.push({ row, runnable: runnableOf(row, body, { fix: u.def.repair, pointer: u.def.cmd?.join(' ') ?? null }) })
371
418
  }
372
419
  // LEGACY: `rules: []`, read as a check whose body is its lint. Dies with
373
420
  // that config key once `shipkit fleet` shows no repo below 0.40.
374
421
  for (const r of c.rules ?? []) {
422
+ const runs = withRepoOff(derivedRuns({ gate: true, build: false, inputs: r.inputs, ttl: r.ttl, kind: 'a legacy repo.config.ts rule', repair: r.fix != null }), repoOff(c, r.id))
375
423
  const row: CheckRow = {
376
424
  decl: {
377
425
  id: r.id,
@@ -383,7 +431,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
383
431
  },
384
432
  title: r.title,
385
433
  description: describeFirst(r.title, `rule ${r.id} holds`),
386
- gate: true,
434
+ gate: runs.off == null && runs.onCi,
387
435
  build: false,
388
436
  blocking: r.blocking,
389
437
  owner: r.owner,
@@ -392,7 +440,9 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
392
440
  budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null || r.never != null }, c.budgets),
393
441
  optIn: false,
394
442
  fixable: r.fix != null,
395
- when: whenOfRule(c, r.id),
443
+ runs,
444
+ cache: legacyCacheText(r),
445
+ when: whenSummary(runs, repoOff(c, r.id), false),
396
446
  deps: null,
397
447
  from: 'repo',
398
448
  }
@@ -409,12 +459,14 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
409
459
  // file's own cache declaration wins over the task's
410
460
  const d = task == null ? found : { ...found, gate: found.gate || task.optIn !== true }
411
461
  const cached = d.inputs != null || d.ttl != null || d.never != null
462
+ const li = { inputs: d.inputs ?? (cached ? undefined : task?.inputs), ttl: d.ttl ?? (cached ? undefined : task?.ttl), never: d.never ?? (cached ? undefined : task?.never) }
463
+ const runs = withRepoOff(derivedRuns({ gate: d.gate, build: false, inputs: li.inputs, ttl: li.ttl, kind: 'a legacy check script', repair: false }), repoOff(c, d.name))
412
464
  const row: CheckRow = {
413
465
  decl: {
414
466
  id: d.name,
415
- inputs: d.inputs ?? (cached ? undefined : task?.inputs),
416
- ttl: d.ttl ?? (cached ? undefined : task?.ttl),
417
- never: d.never ?? (cached ? undefined : task?.never),
467
+ inputs: li.inputs,
468
+ ttl: li.ttl,
469
+ never: li.never,
418
470
  code: [rel],
419
471
  argv: ['bun', rel],
420
472
  // no `extra`, so WHERE it runs stays out of a legacy check's
@@ -425,7 +477,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
425
477
  },
426
478
  title: d.name,
427
479
  description: describeFirst(`\`bun ${rel}\` exits 0`, `${d.name} exits 0`),
428
- gate: d.gate,
480
+ gate: runs.off == null && runs.onCi,
429
481
  build: false,
430
482
  // the file wins, then the repo default, then nothing: an
431
483
  // unclassified check is an error, never a guess
@@ -437,7 +489,9 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
437
489
  optIn: false,
438
490
  // the reader is a source scanner, so it cannot carry a function
439
491
  fixable: false,
440
- when: whenOfLegacyCheck(c, d),
492
+ runs,
493
+ cache: legacyCacheText(li),
494
+ when: whenSummary(runs, repoOff(c, d.name), false),
441
495
  deps: task?.deps ?? null,
442
496
  from: 'repo',
443
497
  }
@@ -85,6 +85,9 @@ export type CheckDecl = {
85
85
  /** why an undeclared `run` check stays uncached: the first module its body
86
86
  * can reach that touches the disk or a process around the context */
87
87
  uncacheable?: string
88
+ /** the check's cache declaration contradicts its body (`reads` on a body
89
+ * that touches the disk around its context): a registration failure */
90
+ declError?: string
88
91
  }
89
92
 
90
93
  export type CheckKey = {
@@ -121,6 +124,7 @@ export type Registration = {
121
124
  * `inputs` on a `ttl` check is a registration FAIL: a check cannot both be
122
125
  * described by files on disk and be un-describable by them. */
123
126
  export function classify(d: CheckDecl): { population: Population; error?: string; reason?: string; decided?: boolean } {
127
+ if (d.declError != null) return { population: 'none', error: `check '${d.id}': ${d.declError}` }
124
128
  const hasInputs = (d.inputs ?? []).length > 0
125
129
  const hasTtl = d.ttl != null && d.ttl !== ''
126
130
  const hasNever = d.never != null && d.never !== ''
@@ -230,7 +234,7 @@ export class KeyLayer {
230
234
  // no key at all
231
235
  let toolchain: { id: string; uncacheable: string[] }
232
236
  try {
233
- toolchain = toolchainIdentity(root, o.cliEntry)
237
+ toolchain = { ...toolchainIdentity(root, o.cliEntry), uncacheable: [] }
234
238
  } catch (e) {
235
239
  toolchain = { id: 'unresolved', uncacheable: [`gate binary unreadable at ${o.cliEntry}: ${e instanceof Error ? e.message : String(e)}`] }
236
240
  }
@@ -156,7 +156,7 @@ export class Ledger {
156
156
  return k
157
157
  }
158
158
 
159
- private read(k: CheckKey): { payload: Stored } | null {
159
+ private read(k: CheckKey): { payload: Stored; at: number } | null {
160
160
  return this.answers.read<Stored>(NS, k.id, k.key!)
161
161
  }
162
162
 
@@ -168,7 +168,7 @@ export class Ledger {
168
168
 
169
169
  /** the stored answer for this check, or null when it must run. Counts what
170
170
  * it was asked, so the run can report hit/miss/uncacheable honestly. */
171
- replay(id: string): CheckOutcome | null {
171
+ replay(id: string): (CheckOutcome & { at: number }) | null {
172
172
  const k = this.key(id)
173
173
  if (k.key == null) { this.unkeyed++; return null }
174
174
  if (!this.reading) { this.misses++; return null }
@@ -177,7 +177,7 @@ export class Ledger {
177
177
  if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, () => this.view()) != null)) { this.misses++; return null }
178
178
  this.hits++
179
179
  this.replayed.push({ ns: NS, name: k.id, key: k.key })
180
- return e.payload
180
+ return { ...e.payload, at: e.at }
181
181
  }
182
182
 
183
183
  /** the stored read set of a READS check, as the last store wrote it */
@@ -305,6 +305,6 @@ export function inflightSlot(dir: string, ns: string, id: string, key: string):
305
305
  /** stamp a cached check's lines. The FIRST line carries the word, because
306
306
  * that is the line an agent reads, and a cached answer that renders
307
307
  * identically to one that ran is the one thing this design refuses to ship. */
308
- export function stampCached(lines: Line[], measuredMs: number): Line[] {
309
- return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} ${cachedStamp(measuredMs)}` } : l))
308
+ export function stampCached(lines: Line[], measuredMs: number, at: number, now: number = Date.now()): Line[] {
309
+ return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} ${cachedStamp(measuredMs, now - at)}` } : l))
310
310
  }