rvlib-shipkit 1.2.0 → 1.3.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 (138) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +20 -6
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  18. package/src/checks/SK024-identity-colors.check.ts +5 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Ci.ts +5 -0
  25. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  26. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  27. package/src/cli/cmd_Keys.ts +17 -3
  28. package/src/cli/cmd_Overview.ts +98 -0
  29. package/src/cli/cmd_Planned.ts +117 -0
  30. package/src/cli.ts +9 -1
  31. package/src/collections/checks.ts +3 -2
  32. package/src/collections/providers.ts +1 -1
  33. package/src/collections/test-time.ts +10 -1
  34. package/src/core/agent-spawn.ts +46 -0
  35. package/src/core/archived.ts +82 -0
  36. package/src/core/canonical-scripts.ts +12 -3
  37. package/src/core/check-runner.ts +40 -12
  38. package/src/core/discover-checks.ts +2 -0
  39. package/src/core/file-stamp.ts +20 -0
  40. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  41. package/src/core/identity-color.ts +1 -1
  42. package/src/core/repo-facts.ts +14 -0
  43. package/src/core/retention.ts +14 -5
  44. package/src/core/retired-checks.ts +7 -0
  45. package/src/core/run-check.ts +6 -2
  46. package/src/core/run-ci.ts +43 -12
  47. package/src/core/run-fix.ts +7 -2
  48. package/src/core/run-fixers.ts +10 -6
  49. package/src/core/wire.ts +11 -0
  50. package/src/fleet/scan-fleet.ts +2 -1
  51. package/src/home/activity-shape.ts +135 -0
  52. package/src/home/activity.ts +249 -0
  53. package/src/home/global-units.ts +3 -2
  54. package/src/home/home-routes.ts +32 -5
  55. package/src/home/pm.ts +37 -4
  56. package/src/home/process-rows.ts +205 -0
  57. package/src/home/resources.ts +35 -8
  58. package/src/home/run-daemon.ts +6 -6
  59. package/src/home/run-pm.ts +1 -0
  60. package/src/home/serve-web.ts +6 -3
  61. package/src/home/supervise.ts +16 -1
  62. package/src/home/widget-text.ts +119 -0
  63. package/src/hooks/run-hooks.ts +20 -3
  64. package/src/ledger/db.ts +1 -1
  65. package/src/ledger/gate-checks.ts +17 -3
  66. package/src/ledger/house-runs.ts +43 -0
  67. package/src/ledger/live-timeline.ts +4 -19
  68. package/src/ledger/run-log.ts +39 -10
  69. package/src/ledger/store.ts +19 -2
  70. package/src/ledger/world-cache.ts +51 -4
  71. package/src/plan/cadence.ts +21 -5
  72. package/src/plan/catch-up.ts +37 -0
  73. package/src/plan/plan-timeline.ts +99 -0
  74. package/src/plan/planner.ts +138 -13
  75. package/src/plan/repo-source.ts +120 -8
  76. package/src/release/run-release.ts +9 -2
  77. package/src/release/unit-resolve.ts +8 -12
  78. package/src/repo-config.ts +11 -3
  79. package/src/serve/house-runs.ts +53 -0
  80. package/src/serve/hub-app/activity-st.ts +168 -0
  81. package/src/serve/hub-app/activity-view.tsx +218 -0
  82. package/src/serve/hub-app/activity.css +78 -0
  83. package/src/serve/hub-app/admin-svc.ts +41 -0
  84. package/src/serve/hub-app/admin-view.tsx +42 -0
  85. package/src/serve/hub-app/chart-view.ts +12 -5
  86. package/src/serve/hub-app/check-words.ts +2 -0
  87. package/src/serve/hub-app/controls.css +32 -0
  88. package/src/serve/hub-app/cycle-button.tsx +36 -0
  89. package/src/serve/hub-app/dock-anim.ts +25 -0
  90. package/src/serve/hub-app/dock-st.ts +329 -0
  91. package/src/serve/hub-app/dock-view.tsx +68 -0
  92. package/src/serve/hub-app/dock.css +35 -0
  93. package/src/serve/hub-app/hash-route.ts +7 -1
  94. package/src/serve/hub-app/header-bar.tsx +79 -104
  95. package/src/serve/hub-app/home-st.ts +49 -12
  96. package/src/serve/hub-app/home-strip.css +65 -22
  97. package/src/serve/hub-app/home-strip.tsx +228 -97
  98. package/src/serve/hub-app/home.html +4 -0
  99. package/src/serve/hub-app/hub-app.tsx +96 -160
  100. package/src/serve/hub-app/hub.css +77 -37
  101. package/src/serve/hub-app/icons.tsx +23 -3
  102. package/src/serve/hub-app/index.html +4 -0
  103. package/src/serve/hub-app/keys.ts +42 -7
  104. package/src/serve/hub-app/live-svc.ts +24 -64
  105. package/src/serve/hub-app/machine-st.ts +64 -1
  106. package/src/serve/hub-app/md.ts +38 -5
  107. package/src/serve/hub-app/next-run.ts +1 -1
  108. package/src/serve/hub-app/palette-st.ts +11 -7
  109. package/src/serve/hub-app/palette-view.tsx +47 -0
  110. package/src/serve/hub-app/pill-row.tsx +65 -0
  111. package/src/serve/hub-app/planned-st.ts +21 -85
  112. package/src/serve/hub-app/planned-view.tsx +12 -7
  113. package/src/serve/hub-app/planned.css +5 -2
  114. package/src/serve/hub-app/project-list.css +43 -21
  115. package/src/serve/hub-app/project-list.tsx +122 -23
  116. package/src/serve/hub-app/projects-svc.ts +120 -12
  117. package/src/serve/hub-app/readme-svc.ts +38 -0
  118. package/src/serve/hub-app/readme-view.tsx +19 -0
  119. package/src/serve/hub-app/readme.css +11 -0
  120. package/src/serve/hub-app/repo-sections.tsx +61 -0
  121. package/src/serve/hub-app/repo-st.ts +25 -6
  122. package/src/serve/hub-app/runs-tabs.ts +1 -1
  123. package/src/serve/hub-app/sidebar.tsx +84 -0
  124. package/src/serve/hub-app/tests-view.tsx +31 -4
  125. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  126. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  127. package/src/serve/hub-app/timeline-svc.ts +2 -2
  128. package/src/serve/hub-app/timeline-view.tsx +20 -3
  129. package/src/serve/hub-runtime.ts +17 -9
  130. package/src/serve/other-repos.ts +8 -45
  131. package/src/serve/project-gutter-route.ts +100 -20
  132. package/src/serve/project-gutter.ts +33 -11
  133. package/src/serve/readme-route.ts +57 -0
  134. package/src/serve/serve-hub.ts +13 -8
  135. package/src/serve/tracked-watch.ts +33 -1
  136. package/src/shipkit-files.ts +20 -4
  137. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  138. package/src/checks/contracts.check.ts +0 -20
@@ -8,6 +8,13 @@
8
8
  // calls tick() on its own beat (the daemon's tick, or one interval in a hub of
9
9
  // its own), and only the save debounce holds a timer, injected. It decides
10
10
  // WHEN, never what is true: every verdict comes from the repo's own runner.
11
+ //
12
+ // what keeps it off the cpu: a save burst is asked about once the repo is
13
+ // quiet (QUIET_MS, or MAX_WAIT_MS into a burst that never stops), a check a
14
+ // save moved waits COST_FACTOR times its last measured run, and nothing it
15
+ // starts on its own starts while the load per core is above LOAD_MAX.
16
+ import { availableParallelism, loadavg } from 'os'
17
+ import { CATCH_UP_BEAT_MS, resumeCatchUp, type CatchUpStore } from './catch-up.ts'
11
18
  import { nextDue, resolveCadence, resolveWhenInactive, stablePoint, type EveryValue, type LastResult, type PlanDecl } from './cadence.ts'
12
19
 
13
20
  export type PlanReason = 'changed' | 'scheduled' | 'late' | 'asked'
@@ -15,6 +22,8 @@ export type PlanReason = 'changed' | 'scheduled' | 'late' | 'asked'
15
22
  /** one check as the plan sees it */
16
23
  export type PlanCheck = {
17
24
  id: string
25
+ /** the name an operator reads, when the host knows one */
26
+ title?: string
18
27
  plan: PlanDecl
19
28
  /** a save can make it due: its answer is keyed on the tree, or it is a
20
29
  * gate check nothing can key */
@@ -34,6 +43,10 @@ export type PlanSource = {
34
43
  changed: () => Promise<string[]>
35
44
  /** run these checks now, in the background; resolves to the exit code */
36
45
  run: (ids: string[], why: PlanReason) => Promise<number>
46
+ /** at join: did the owner edit something after the repo's last run. A
47
+ * yes counts as a save, so a burst a restart in place cut off settles
48
+ * and is asked about like any other */
49
+ pending?: () => Promise<boolean>
37
50
  }
38
51
 
39
52
  export type PlanState = 'running' | 'due' | 'late' | 'next' | 'idle'
@@ -41,6 +54,8 @@ export type PlanState = 'running' | 'due' | 'late' | 'next' | 'idle'
41
54
  export type PlanRow = {
42
55
  repo: string
43
56
  check: string
57
+ /** the name an operator reads, when the host knows one */
58
+ title: string | null
44
59
  state: PlanState
45
60
  /** why it is due or planned, null for a check that waits for a save */
46
61
  reason: PlanReason | null
@@ -61,8 +76,32 @@ export type PlanTimers = { set: (fn: () => void, ms: number) => unknown; clear:
61
76
 
62
77
  const realTimers: PlanTimers = { set: (fn, ms) => setTimeout(fn, ms), clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) }
63
78
 
64
- /** how long a save burst must be quiet before the plan asks what it moved */
65
- export const QUIET_MS = 250
79
+ /** how long a repo must be quiet (no tracked write) before the plan asks
80
+ * what a save burst moved: an agent's edit, test, edit loop stays inside it,
81
+ * so the loop costs one question and one run */
82
+ export const QUIET_MS = 60_000
83
+
84
+ /** a repo written to without a pause is still asked this often */
85
+ export const MAX_WAIT_MS = 10 * 60_000
86
+
87
+ /** a check a save moved runs at most once per this many times its last
88
+ * measured run: a 30s suite at most every 10 minutes, a 200ms lint at once */
89
+ export const COST_FACTOR = 20
90
+
91
+ /** above this 1 minute load average per core, the plan starts nothing on its
92
+ * own: every core already has a process waiting for it */
93
+ export const LOAD_MAX = 1
94
+
95
+ /** the machine's 1 minute load average per core */
96
+ export const machineLoad = (): number => loadavg()[0]! / Math.max(1, availableParallelism())
97
+
98
+ /** a decided fact whose probe could not answer retries after this, doubled
99
+ * per skip in a row, up to ONCE_RETRY_MAX_MS. From its run on record, so a
100
+ * restart changes nothing */
101
+ export const ONCE_RETRY_MS = 3_600_000
102
+ export const ONCE_RETRY_MAX_MS = 24 * 3_600_000
103
+
104
+ export const onceRetry = (streak: number): number => Math.min(ONCE_RETRY_MAX_MS, ONCE_RETRY_MS * 2 ** Math.max(0, Math.min(10, streak - 1)))
66
105
 
67
106
  /** how often the last results are read again when nothing ran here */
68
107
  export const LAST_EVERY_MS = 15_000
@@ -76,6 +115,10 @@ type Repo = {
76
115
  lastRead: number
77
116
  marks: Map<string, Mark>
78
117
  timer: unknown
118
+ /** when the save burst the timer waits on began */
119
+ burstAt: number | null
120
+ /** when that timer fires */
121
+ settleAt: number | null
79
122
  detecting: Promise<void> | null
80
123
  again: boolean
81
124
  error: string | null
@@ -92,17 +135,30 @@ export class Planner {
92
135
  private readonly now: () => number
93
136
  private readonly timers: PlanTimers
94
137
  private readonly quietMs: number
138
+ private readonly maxWaitMs: number
139
+ private readonly load: () => number
140
+ private readonly cores: number
141
+ private holdReason: string | null = null
142
+ private readonly catchUp: CatchUpStore | null
143
+ private beatAt: number
95
144
  /** is this repo active: an agent works in it, VS Code has it open, or it is flagged */
96
145
  active: (repo: string) => boolean = () => true
97
146
 
98
- /** when this plan started: what was overdue then is spread, never fired at once */
147
+ /** when this plan's catch up window started: what was overdue then is
148
+ * spread, never fired at once. With a store, a restart in place keeps
149
+ * the window it had (catch-up.ts) */
99
150
  private readonly bootAt: number
100
151
 
101
- constructor(private o: { now?: () => number; timers?: PlanTimers; quietMs?: number; onError?: (repo: string, e: Error) => void } = {}) {
152
+ constructor(private o: { now?: () => number; timers?: PlanTimers; quietMs?: number; maxWaitMs?: number; load?: () => number; cores?: number; catchUp?: CatchUpStore; onError?: (repo: string, e: Error) => void } = {}) {
102
153
  this.now = o.now ?? Date.now
103
- this.bootAt = this.now()
154
+ this.catchUp = o.catchUp ?? null
155
+ this.bootAt = this.catchUp != null ? resumeCatchUp(this.now(), this.catchUp) : this.now()
156
+ this.beatAt = this.now()
157
+ this.cores = o.cores ?? availableParallelism()
104
158
  this.timers = o.timers ?? realTimers
105
159
  this.quietMs = o.quietMs ?? QUIET_MS
160
+ this.maxWaitMs = o.maxWaitMs ?? MAX_WAIT_MS
161
+ this.load = o.load ?? machineLoad
106
162
  }
107
163
 
108
164
  private fail(repo: string, e: unknown): void {
@@ -113,9 +169,14 @@ export class Planner {
113
169
  async add(src: PlanSource): Promise<void> {
114
170
  const old = this.repos.get(src.repo)
115
171
  if (old?.timer != null) this.timers.clear(old.timer)
116
- const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, detecting: null, again: false, error: null }
172
+ const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
117
173
  this.repos.set(src.repo, r)
118
174
  await this.reload(src.repo)
175
+ try {
176
+ if (this.repos.get(src.repo) === r && (await src.pending?.()) === true) this.touched(src.repo)
177
+ } catch (e) {
178
+ this.fail(src.repo, e)
179
+ }
119
180
  }
120
181
 
121
182
  /** read its checks again, after a config edit */
@@ -142,6 +203,20 @@ export class Planner {
142
203
  return this.repos.has(repo)
143
204
  }
144
205
 
206
+ /** the check ids a repo declares today; null while the plan does not
207
+ * hold them (not loaded, still loading, or its checks failed to load) */
208
+ declared(repo: string): ReadonlySet<string> | null {
209
+ const r = this.repos.get(repo)
210
+ if (r == null || r.error != null || r.checks.length === 0) return null
211
+ return new Set(r.checks.map((c) => c.id))
212
+ }
213
+
214
+ /** each declared check's title by id, null while `declared` is */
215
+ titles(repo: string): ReadonlyMap<string, string> | null {
216
+ if (this.declared(repo) == null) return null
217
+ return new Map(this.repos.get(repo)!.checks.map((c) => [c.id, c.title ?? c.id]))
218
+ }
219
+
145
220
  private readLast(r: Repo, force = false): void {
146
221
  const now = this.now()
147
222
  if (!force && now - r.lastRead < LAST_EVERY_MS) return
@@ -163,13 +238,22 @@ export class Planner {
163
238
  }
164
239
 
165
240
  /** a file moved in this repo: once the burst is quiet, the checks whose
166
- * answer it moved turn `changed` */
241
+ * answer it moved turn `changed`. A burst past the wait cap keeps the
242
+ * timer it has, so a repo never quiet is still asked */
167
243
  touched(repo: string): void {
168
244
  const r = this.repos.get(repo)
169
245
  if (r == null) return
170
- if (r.timer != null) this.timers.clear(r.timer)
246
+ const now = this.now()
247
+ r.burstAt ??= now
248
+ if (r.timer != null) {
249
+ if (now - r.burstAt >= this.maxWaitMs) return
250
+ this.timers.clear(r.timer)
251
+ }
252
+ r.settleAt = now + this.quietMs
171
253
  r.timer = this.timers.set(() => {
172
254
  r.timer = null
255
+ r.burstAt = null
256
+ r.settleAt = null
173
257
  void this.detect(repo)
174
258
  }, this.quietMs)
175
259
  }
@@ -224,12 +308,20 @@ export class Planner {
224
308
  const cad = resolveCadence(c.plan, c.fallback, last, s, `${r.src.repo}\0${c.id}`)
225
309
  const wi = resolveWhenInactive(c.plan, last, s)
226
310
  const error = cad.error ?? wi.error
227
- const base = { repo: r.src.repo, check: c.id, rule: cad.rule, inactive: wi.rule, error, last, active }
311
+ const base = { repo: r.src.repo, check: c.id, title: c.title ?? null, rule: cad.rule, inactive: wi.rule, error, last, active }
228
312
  if (this.running?.repo === r.src.repo && this.running.ids.has(c.id)) return { ...base, state: 'running', reason: null, dueAt: this.running.since }
229
313
  if (m.askedAt != null) return { ...base, state: 'due', reason: 'asked', dueAt: m.askedAt }
230
- if (m.changedAt != null) return { ...base, state: 'due', reason: 'changed', dueAt: m.changedAt }
314
+ if (m.changedAt != null) {
315
+ // a check pays for its own runs: one save cannot run it again
316
+ // before COST_FACTOR times its last measured run has passed
317
+ const spaced = last?.cost != null ? last.cost.at + COST_FACTOR * last.cost.ms : -Infinity
318
+ const at = Math.max(m.changedAt, spaced)
319
+ return { ...base, state: at > now ? 'next' : 'due', reason: 'changed', dueAt: at }
320
+ }
231
321
  const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
232
- const raw = nextDue(cad.cadence, Number.isFinite(lastAt) ? lastAt : null, now)
322
+ // a decided fact that could not answer: its retry comes from its run
323
+ // on record, never from a boot
324
+ const raw = cad.cadence.kind === 'once' && last?.unknown === true ? Math.max(last.at + onceRetry(last.streak), m.attemptAt != null ? m.attemptAt + onceRetry(last.streak) : -Infinity) : nextDue(cad.cadence, Number.isFinite(lastAt) ? lastAt : null, now)
233
325
  // overdue before the plan started (a machine asleep, a day off): each
234
326
  // pair lands at its own stable point of the catch up window
235
327
  const due = raw != null && raw < this.bootAt ? this.bootAt + Math.round(stablePoint(`catch up\0${r.src.repo}\0${c.id}`) * CATCH_UP_MS) : raw
@@ -257,6 +349,17 @@ export class Planner {
257
349
  return [...this.repos.values()].filter((r) => r.error != null).map((r) => ({ repo: r.src.repo, error: r.error! }))
258
350
  }
259
351
 
352
+ /** what holds work back right now, one line each: the repos whose save
353
+ * burst is settling, and the load that held the last look */
354
+ held(): string[] {
355
+ const now = this.now()
356
+ const out: string[] = []
357
+ for (const r of this.repos.values())
358
+ if (r.settleAt != null) out.push(`settling: ${r.src.repo}, ${Math.max(0, Math.ceil((r.settleAt - now) / 1_000))}s`)
359
+ if (this.holdReason != null) out.push(this.holdReason)
360
+ return out
361
+ }
362
+
260
363
  /** a batch is in flight */
261
364
  busy(): boolean {
262
365
  return this.running != null
@@ -266,9 +369,23 @@ export class Planner {
266
369
  * runs every row of its that is due, as one background run. Resolves when
267
370
  * that batch is started, never when it ends */
268
371
  tick(): { repo: string; ids: string[]; reason: PlanReason } | null {
372
+ this.beat()
269
373
  if (this.running != null) return null
270
- const due = this.rows().filter((x) => x.state === 'due')
271
- if (due.length === 0) return null
374
+ let due = this.rows().filter((x) => x.state === 'due')
375
+ if (due.length === 0) {
376
+ this.holdReason = null
377
+ return null
378
+ }
379
+ // a loaded machine runs only what someone asked for
380
+ const load = this.load()
381
+ if (load > LOAD_MAX) {
382
+ due = due.filter((x) => x.reason === 'asked')
383
+ if (due.length === 0) {
384
+ this.holdReason = `held: load ${(load * this.cores).toFixed(1)} on ${this.cores} cores`
385
+ return null
386
+ }
387
+ }
388
+ this.holdReason = null
272
389
  due.sort((a, b) => ORDER[a.reason!] - ORDER[b.reason!] || (a.dueAt ?? 0) - (b.dueAt ?? 0))
273
390
  const first = due[0]!
274
391
  const r = this.repos.get(first.repo)!
@@ -292,6 +409,14 @@ export class Planner {
292
409
  return { repo: first.repo, ids, reason: first.reason! }
293
410
  }
294
411
 
412
+ /** the catch up window's last beat, at most once a minute */
413
+ private beat(): void {
414
+ const now = this.now()
415
+ if (this.catchUp == null || now - this.beatAt < CATCH_UP_BEAT_MS) return
416
+ this.beatAt = now
417
+ this.catchUp.set({ start: this.bootAt, seen: now })
418
+ }
419
+
295
420
  /** resolves once no batch is in flight */
296
421
  async idle(): Promise<void> {
297
422
  while (this.running != null) await new Promise((res) => setTimeout(res, 5))
@@ -9,9 +9,15 @@
9
9
  // save elsewhere never re-runs it.
10
10
  import { gateInventory, type CheckRow } from '../ledger/gate-checks.ts'
11
11
  import { Ledger } from '../ledger/ledger.ts'
12
+ import { answersDb } from '../ledger/store.ts'
13
+ import { hasWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
14
+ import { FOREVER, FOREVER_MS } from '../core/wire.ts'
15
+ import { ONCE, type LastResult } from './cadence.ts'
16
+ import { openDb } from '../ledger/db.ts'
12
17
  import { readLastResults } from '../ledger/run-log.ts'
13
18
  import { gateBinary } from '../ledger/toolchain-identity.ts'
14
19
  import { populationOf } from '../problems/problem-line.ts'
20
+ import { movedSince } from '../serve/tracked-watch.ts'
15
21
  import type { RepoConfig } from '../repo-config.ts'
16
22
  import type { PlanCheck, PlanReason, PlanSource } from './planner.ts'
17
23
 
@@ -19,30 +25,100 @@ import type { PlanCheck, PlanReason, PlanSource } from './planner.ts'
19
25
  * check, an off one and an opt in build step wait for whoever names them */
20
26
  export function plannable(row: CheckRow): boolean {
21
27
  if (row.when.at !== 'always' && row.when.at !== 'health') return false
28
+ // nothing to do in this repo on a clock (check.ts planOff)
29
+ if (row.planOff != null) return false
22
30
  if (row.optIn) return false
23
31
  return !(row.build && row.when.at === 'health')
24
32
  }
25
33
 
26
- /** one inventory row as a plan check. A world check keeps its ttl as its
27
- * cadence; a build step waits for a save; any other check with no cadence
28
- * runs about once a day, at its own stable point of a 20h to 28h range, so
29
- * checks never fire together */
30
- /** a check that declares no cadence runs about once a day, never in step */
34
+ /** a REPO check nothing can key and that declares no cadence runs about
35
+ * once a day, never in step, until it declares what it depends on. No
36
+ * built-in may land here (tests/plan-default-range.test.ts) */
31
37
  const DEFAULT_EVERY: [string, string] = ['20h', '28h']
32
38
 
39
+ /** why the plan cannot tell when this check's answer moves, or null when it
40
+ * can: a key, a ttl, a build step or a declared cadence says it */
41
+ export function planGap(row: CheckRow): string | null {
42
+ const pop = populationOf(row.decl)
43
+ if (pop === 'content' || pop === 'reads' || pop === 'world' || row.build) return null
44
+ if (row.plan?.every !== undefined || row.plan?.cron != null) return null
45
+ const why = row.decl.never != null && row.decl.never !== '' ? `never cached (${row.decl.never})` : (row.decl.uncacheable ?? 'declares neither inputs nor ttl')
46
+ return `${why}, and no every: nothing says when its answer moves`
47
+ }
48
+
49
+ /** one inventory row as a plan check. A world check keeps its ttl as its
50
+ * cadence; a build step and a check keyed on its files wait for a save (its
51
+ * answer replays until a file moves, so a clock re-asks nothing); a repo
52
+ * check with no cadence runs at its own stable point of the default range,
53
+ * and a built-in with none waits for a save and shows on the burn-down */
33
54
  export function planCheckOf(row: CheckRow): PlanCheck {
34
55
  const pop = populationOf(row.decl)
35
56
  const keyed = pop === 'content' || pop === 'reads'
36
57
  const ttl = row.decl.ttl != null && row.decl.ttl !== '' ? row.decl.ttl : null
37
- const fallback = ttl != null ? { value: ttl, why: 'its ttl' } : row.build ? { value: null, why: 'a build step' } : { value: DEFAULT_EVERY, why: 'the default range' }
58
+ const fallback =
59
+ ttl === FOREVER ? { value: ONCE, why: 'a decided fact: ttl forever' }
60
+ : ttl != null ? { value: ttl, why: 'its ttl' }
61
+ : row.build ? { value: null, why: 'a build step' }
62
+ : keyed ? { value: null, why: 'keyed on its files' }
63
+ : row.from === 'repo' ? { value: DEFAULT_EVERY, why: 'the default range' }
64
+ : { value: null, why: 'a built-in declaring nothing, see shipkit keys --burn-down' }
65
+ // a gate check nothing can key waits for a save only when no cadence
66
+ // says when to run it: a range or an every it declares is the rule
67
+ const clocked = fallback.value != null || row.plan?.every !== undefined || row.plan?.cron != null
38
68
  return {
39
69
  id: row.decl.id,
70
+ title: row.title,
40
71
  plan: row.plan ?? {},
41
- onChange: pop !== 'world' && (keyed || row.gate),
72
+ // a decided fact is keyed on its declaration, so a save can move it
73
+ onChange: isForever(row) || (pop !== 'world' && (keyed || (row.gate && !clocked))),
42
74
  fallback,
43
75
  }
44
76
  }
45
77
 
78
+ const isForever = (row: CheckRow): boolean => row.decl.ttl === FOREVER
79
+
80
+ /** the key a decided fact's answer is stored under: its declared value */
81
+ const factKey = (r: CheckRow): string => worldKeyOf({ ttlMs: FOREVER_MS, toolchain: '', code: '', rotation: r.decl.extra ?? '', frozen: r.decl.writes === true ? false : null })
82
+
83
+ /** a decided fact with no answer stored for its current key: its declaration
84
+ * moved, it never ran, or the db was lost. The plan runs these once */
85
+ export function unknownFacts(rows: CheckRow[], cache: WorldCache): Set<string> {
86
+ const out = new Set<string>()
87
+ for (const r of rows) if (isForever(r) && !hasWorld(cache, r.decl.id, factKey(r), FOREVER_MS)) out.add(r.decl.id)
88
+ return out
89
+ }
90
+
91
+ /** each check's last MEASURED run from the record: when it finished and how
92
+ * long it took. A replayed run is left out, it cost nothing */
93
+ export function readLastCosts(path: string): Record<string, { at: number; ms: number }> {
94
+ const opened = openDb(path, { create: false })
95
+ if (opened == null) return {}
96
+ const out: Record<string, { at: number; ms: number }> = {}
97
+ try {
98
+ const rows = opened.db.query(`select id, max(at) as at, ms from runs where replayed = 0 group by id`).all() as { id: string; at: number; ms: number }[]
99
+ for (const r of rows) out[r.id] = { at: Number(r.at), ms: Number(r.ms) }
100
+ } catch {
101
+ return {}
102
+ } finally {
103
+ try { opened.db.close() } catch { /* already gone */ }
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** when the latest run on record started, whoever ran it, or null */
109
+ export function lastStarted(path: string): number | null {
110
+ const opened = openDb(path, { create: false })
111
+ if (opened == null) return null
112
+ try {
113
+ const r = opened.db.query('select max(started) as at from invocations').get() as { at: number | null } | null
114
+ return r?.at == null ? null : Number(r.at)
115
+ } catch {
116
+ return null
117
+ } finally {
118
+ try { opened.db.close() } catch { /* already gone */ }
119
+ }
120
+ }
121
+
46
122
  export type RepoSourceDeps = {
47
123
  repo: string
48
124
  root: string
@@ -59,13 +135,38 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
59
135
  /** the content key each check had at the last question, so a red one
60
136
  * counts as changed only when its key moves */
61
137
  const seen = new Map<string, string>()
138
+ /** the decided facts with no answer for their key, asked again at every
139
+ * load and save: their last result counts as none, so they are due */
140
+ let unknown = new Set<string>()
141
+ /** the decided facts whose declaration moved since the last load */
142
+ const moved = new Set<string>()
143
+ const facts = new Map<string, string>()
144
+ const cache = (): WorldCache => ({ db: answersDb(d.root), ns: d.config().name, now: Date.now })
62
145
  return {
63
146
  repo: d.repo,
64
147
  checks: async () => {
65
148
  rows = (await gateInventory(d.root, d.config(), { tier: 'quick' })).filter(plannable)
149
+ unknown = rows.some(isForever) ? unknownFacts(rows, cache()) : new Set()
150
+ for (const r of rows.filter(isForever)) {
151
+ const k = factKey(r)
152
+ const before = facts.get(r.decl.id)
153
+ if (before != null && before !== k) moved.add(r.decl.id)
154
+ facts.set(r.decl.id, k)
155
+ }
66
156
  return rows.map(planCheckOf)
67
157
  },
68
- last: () => (d.record === '' ? {} : readLastResults(d.record)),
158
+ last: () => {
159
+ const all: Record<string, LastResult> = d.record === '' ? {} : readLastResults(d.record)
160
+ // a fact with no answer runs; one whose probe could not answer
161
+ // retries on a backoff from its skip, never at each boot
162
+ for (const id of unknown) {
163
+ if (all[id]?.status === 'skip') all[id] = { ...all[id]!, unknown: true }
164
+ else delete all[id]
165
+ }
166
+ const costs = d.record === '' ? {} : readLastCosts(d.record)
167
+ for (const [id, cost] of Object.entries(costs)) if (all[id] != null) all[id] = { ...all[id]!, cost }
168
+ return all
169
+ },
69
170
  changed: async () => {
70
171
  const decls = rows.map((r) => r.decl)
71
172
  if (decls.length === 0) return []
@@ -74,6 +175,13 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
74
175
  for (const r of rows) {
75
176
  const c = planCheckOf(r)
76
177
  if (!c.onChange) continue
178
+ if (isForever(r)) {
179
+ // only an edited declaration: a probe that could not answer
180
+ // (gh down) must not run again at every save
181
+ if (moved.has(c.id) && unknown.has(c.id)) out.push(c.id)
182
+ moved.delete(c.id)
183
+ continue
184
+ }
77
185
  const k = ledger.layer.keyFor(c.id)
78
186
  if (k.key == null) {
79
187
  out.push(c.id)
@@ -87,6 +195,10 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
87
195
  }
88
196
  return out
89
197
  },
198
+ pending: async () => {
199
+ const since = d.record === '' ? null : lastStarted(d.record)
200
+ return since != null && (await movedSince(d.root, since))
201
+ },
90
202
  run: async (ids, why) => {
91
203
  const byId = new Map(rows.map((r) => [r.decl.id, r]))
92
204
  const gate = ids.filter((id) => byId.get(id)?.gate === true)
@@ -27,8 +27,11 @@ export type ReleaseConfig = {
27
27
  tag?: string
28
28
  /** the ROOT unit's versioned changelog (default 'CHANGELOG.md') */
29
29
  changelog?: string
30
- /** publish the ROOT unit to npm. Default: its package.json is not private.
31
- * A private package NEVER publishes, whatever this says. */
30
+ /** publish the ROOT unit to npm, declared and never inferred: a root whose
31
+ * package.json is not private refuses to release until this says true or
32
+ * false. A private package NEVER publishes, whatever this says. True makes
33
+ * the root package.json a public contract. What each check does with it:
34
+ * wiki/build/shipkit/built-in-checks.md */
32
35
  npm?: boolean
33
36
  /** argv printing an npm auth token on stdout, as in a secret-manager read.
34
37
  * Absent = rely on ambient npm auth. Never put a token in config.
@@ -122,6 +125,10 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
122
125
  // real repositories) are exactly the ones a publish must not be wrong
123
126
  // about. Publishing behind a partial suite is a green nobody measured.
124
127
  tests: 'full',
128
+ // a release log is read once and kept as evidence: the tier it asked
129
+ // for, the cache reads it refused and the units it left to their own
130
+ // release are all part of what it proves
131
+ verbose: true,
125
132
  // a check that cannot block cannot change the outcome, so a release
126
133
  // never probes the world: publishing a lib does not depend on whether
127
134
  // a DNS provider answers, and asking spends an API call per publish.
@@ -165,9 +165,7 @@ export function resolveUnits(root: string, config: RepoConfig, io: UnitReader):
165
165
  tagTemplate: rc.tag ?? ROOT_TAG,
166
166
  changelogRel: rc.changelog ?? 'CHANGELOG.md',
167
167
  npm: rc.npm,
168
- // the root IS the repo: its package.json private flag is the whole
169
- // declaration, and a public root package publishes by default
170
- npmWhenUndeclared: 'from-private-flag',
168
+ undeclaredFix: 'add `release: { npm: true }` to repo.config.ts to publish it',
171
169
  githubRelease: rc.githubRelease ?? true,
172
170
  tsconfigRel: rc.tsconfig,
173
171
  checkFolderName: false,
@@ -204,7 +202,7 @@ export function resolveUnits(root: string, config: RepoConfig, io: UnitReader):
204
202
  tagTemplate: decl.tag ?? SUB_TAG,
205
203
  changelogRel: decl.changelog ?? `${decl.dir}/CHANGELOG.md`,
206
204
  npm: decl.npm,
207
- npmWhenUndeclared: 'refuse',
205
+ undeclaredFix: 'add `npm: true` to this units[] entry to publish it',
208
206
  githubRelease: decl.githubRelease ?? false,
209
207
  tsconfigRel: decl.tsconfig,
210
208
  // only the DEFAULT tag renders the package name into a git tag, so
@@ -226,11 +224,9 @@ function build(p: {
226
224
  tagTemplate: string
227
225
  changelogRel: string
228
226
  npm: boolean | undefined
229
- /** how an UNDECLARED npm target resolves. The root IS the repo, so its
230
- * package.json private flag is the whole declaration. A sub-unit has no
231
- * such declaration, so a non-private package with no `npm` key is
232
- * ambiguous and REFUSES. */
233
- npmWhenUndeclared: 'from-private-flag' | 'refuse'
227
+ /** where this unit's npm declaration goes, for the refusal a non-private
228
+ * package with no `npm` key gets */
229
+ undeclaredFix: string
234
230
  githubRelease: boolean
235
231
  tsconfigRel: string | undefined
236
232
  checkFolderName: boolean
@@ -256,10 +252,10 @@ function build(p: {
256
252
  // about must refuse, not quietly resolve to no-publish. A silent false
257
253
  // here is a lib that bumps, tags and cuts a release while publishing
258
254
  // nothing, and the only trace is one note line in a long log.
259
- if (p.npm == null && !isPrivate && p.npmWhenUndeclared === 'refuse')
260
- errors.push(at('package.json is not private and this units[] entry does not say `npm` — add `npm: true` to publish it, or `private: true` to its package.json to keep it internal'))
255
+ if (p.npm == null && !isPrivate)
256
+ errors.push(at(`package.json is not private and no npm target is declared: ${p.undeclaredFix}, or \`private: true\` to its package.json to keep it internal`))
261
257
  if (errors.length > 0) return { error: errors }
262
- const declaredNpm = p.npm ?? (p.npmWhenUndeclared === 'from-private-flag' && !isPrivate)
258
+ const declaredNpm = p.npm === true
263
259
  const tsconfig = p.tsconfigRel != null ? join(p.root, p.tsconfigRel) : join(p.dir, 'tsconfig.json')
264
260
  return {
265
261
  unit: {
@@ -185,8 +185,16 @@ export type RepoConfig = {
185
185
  * lint and prints a skip line carrying the reason. `defineRepo` throws on
186
186
  * an unknown id or a blank reason */
187
187
  ruleExempt?: Record<string, string>
188
- /** declared github visibility INTENT (absent = 'private') — rule SK007
189
- * fails loud when `gh repo view` disagrees in either direction */
188
+ /** the repo is done for now: nothing processes it. The house discovery
189
+ * leaves it out, so it has no rail row, no hub (no file watch, no plan
190
+ * rows, no automatic gates), no processes or widgets, no identity fill,
191
+ * no activity row, and fleet skips it. Only the archived page lists it.
192
+ * The button in the repo header and the page's unarchive toggle it, and
193
+ * only this key moves (core/archived.ts) */
194
+ archived?: boolean
195
+ /** declared github visibility INTENT (absent = 'private'): public means
196
+ * strangers read the tracked tree. What each check does with it:
197
+ * wiki/build/shipkit/built-in-checks.md */
190
198
  visibility?: 'private' | 'public'
191
199
  /** the reviewer agent type this repo declares: `.claude/agents/<name>.md`,
192
200
  * whose system prompt points at `brief` (an md file holding the standing
@@ -287,7 +295,7 @@ export type RepoConfig = {
287
295
  logDir?: string
288
296
  }
289
297
 
290
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
298
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
291
299
 
292
300
  const KEBAB = /^[a-z0-9][a-z0-9-]*$/
293
301
  const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
@@ -0,0 +1,53 @@
1
+ // The dock's data on the server: every house repo's record, read for the
2
+ // live window and shaped into one view (ledger/house-runs.ts). One instance
3
+ // serves every page that asks, the home's daemon and a hub serving on its own
4
+ // alike.
5
+ //
6
+ // the one constraint a reader breaks first: this is asked every few seconds
7
+ // while a dock is open, so nothing here may cost a record read when nothing
8
+ // moved. A repo's record is opened only when its db or its write ahead log
9
+ // changed since the last read (the window only slides, and the shaping drops
10
+ // what slid out), and one shaped answer serves every request inside SHARE_MS.
11
+ import { existsSync } from 'fs'
12
+ import { parallelCap } from '../core/parallel.ts'
13
+ import { shapeHouse, type HouseRecord, type HouseView } from '../ledger/house-runs.ts'
14
+ import { LIVE_HISTORY_MS } from '../ledger/live-timeline.ts'
15
+ import { readWindowReadonly } from '../ledger/run-log.ts'
16
+ import { recordOf, recordStamp } from './project-gutter.ts'
17
+
18
+ /** how long one shaped answer serves every page that asks */
19
+ export const SHARE_MS = 1_000
20
+
21
+ export type HouseRepo = { name: string; path: string }
22
+
23
+ export class HouseRuns {
24
+ private records = new Map<string, { stamp: string; rec: HouseRecord }>()
25
+ private shaped: { at: number; view: HouseView } | null = null
26
+ /** records opened since this instance was built: what a test measures */
27
+ reads = 0
28
+
29
+ constructor(private list: () => HouseRepo[], private now: () => number = Date.now, private cap: () => number = parallelCap) {}
30
+
31
+ view(): HouseView {
32
+ const now = this.now()
33
+ if (this.shaped != null && now - this.shaped.at < SHARE_MS) return this.shaped.view
34
+ const since = now - LIVE_HISTORY_MS
35
+ const next = new Map<string, { stamp: string; rec: HouseRecord }>()
36
+ for (const r of this.list()) {
37
+ const path = recordOf(r.path)
38
+ if (path == null || !existsSync(path)) continue
39
+ const stamp = recordStamp(path)
40
+ let hit = this.records.get(r.path)
41
+ if (hit == null || hit.stamp !== stamp) {
42
+ this.reads++
43
+ hit = { stamp, rec: { repo: r.name, ...readWindowReadonly(path, since) } }
44
+ } else if (hit.rec.repo !== r.name) hit = { stamp, rec: { ...hit.rec, repo: r.name } }
45
+ next.set(r.path, hit)
46
+ }
47
+ // a repo gone from the list takes its rows with it
48
+ this.records = next
49
+ const view = shapeHouse({ now, cap: this.cap(), repos: [...next.values()].map((x) => x.rec) })
50
+ this.shaped = { at: now, view }
51
+ return view
52
+ }
53
+ }