rvlib-shipkit 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +4 -2
  4. package/src/assets/kits/identity-kit.ts +15 -13
  5. package/src/assets/run-assets.ts +12 -2
  6. package/src/check.ts +6 -4
  7. package/src/checks/SK001-package-json-shape.check.ts +20 -2
  8. package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
  9. package/src/checks/SK014-prose-ste.check.ts +16 -6
  10. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  11. package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
  12. package/src/checks/SK028-readme.check.ts +6 -2
  13. package/src/cli/cmd_CrossRepoActivity.ts +4 -1
  14. package/src/collections/collection-types.ts +15 -0
  15. package/src/collections/providers.ts +6 -2
  16. package/src/contracts/apply-contracts.ts +9 -8
  17. package/src/contracts/contract-types.ts +8 -2
  18. package/src/contracts/contracts-engine.ts +34 -13
  19. package/src/contracts/manifests/identity.contract.ts +6 -5
  20. package/src/contracts/manifests/social.contract.ts +4 -2
  21. package/src/contracts/manifests/steam.contract.ts +7 -3
  22. package/src/contracts/web-surface.ts +59 -0
  23. package/src/core/budget.ts +19 -9
  24. package/src/core/check-runner.ts +26 -5
  25. package/src/core/discover-checks.ts +8 -5
  26. package/src/core/layout.ts +2 -0
  27. package/src/core/motherhome.ts +88 -66
  28. package/src/core/run-check.ts +2 -5
  29. package/src/core/run-ci.ts +3 -3
  30. package/src/core/wire.ts +38 -9
  31. package/src/home/activity-shape.ts +4 -1
  32. package/src/home/activity.ts +22 -15
  33. package/src/home/gui-app/ShipkitGui.swift +144 -25
  34. package/src/home/gui-app.ts +93 -30
  35. package/src/home/home-launchd.ts +10 -2
  36. package/src/home/home-routes.ts +2 -1
  37. package/src/home/home-start.ts +33 -5
  38. package/src/home/machine-stores.ts +4 -2
  39. package/src/home/open-app.ts +23 -10
  40. package/src/home/pm-config.ts +15 -11
  41. package/src/home/pm-inputs.ts +76 -0
  42. package/src/home/pm.ts +3 -3
  43. package/src/home/process-rows.ts +23 -6
  44. package/src/home/resources.ts +67 -15
  45. package/src/home/run-daemon.ts +6 -1
  46. package/src/home/run-pm.ts +29 -5
  47. package/src/home/widgets.ts +32 -3
  48. package/src/hooks/commit-message.ts +2 -3
  49. package/src/hooks/run-hooks.ts +12 -2
  50. package/src/ledger/gate-checks.ts +3 -3
  51. package/src/ledger/store.ts +11 -0
  52. package/src/ledger/world-cache.ts +17 -15
  53. package/src/repo-config.ts +6 -1
  54. package/src/serve/hub-app/activity-view.tsx +3 -3
  55. package/src/serve/hub-app/checks-svc.ts +2 -1
  56. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  57. package/src/serve/hub-app/col-tables.ts +115 -0
  58. package/src/serve/hub-app/cols-svc.ts +28 -0
  59. package/src/serve/hub-app/dock.css +1 -1
  60. package/src/serve/hub-app/home-st.ts +1 -1
  61. package/src/serve/hub-app/home-strip.css +32 -17
  62. package/src/serve/hub-app/home-strip.tsx +99 -89
  63. package/src/serve/hub-app/hub-app.tsx +7 -1
  64. package/src/serve/hub-app/hub-types.ts +6 -3
  65. package/src/serve/hub-app/hub.css +24 -2
  66. package/src/serve/hub-app/icons.tsx +4 -0
  67. package/src/serve/hub-app/keys.ts +6 -6
  68. package/src/serve/hub-app/machine-st.ts +26 -37
  69. package/src/serve/hub-app/palette-st.ts +5 -5
  70. package/src/serve/hub-app/palette-view.tsx +1 -1
  71. package/src/serve/hub-app/project-list.tsx +3 -3
  72. package/src/serve/hub-app/repo-st.ts +1 -1
  73. package/src/serve/hub-app/runs-tabs.ts +1 -1
  74. package/src/serve/hub-app/timeline-view.tsx +14 -11
  75. package/src/serve/project-gutter.ts +1 -2
  76. package/src/serve/warm-cell.ts +49 -16
  77. package/src/suite/judge-suites.ts +42 -17
  78. package/src/suite/suite-cli.ts +5 -8
  79. package/src/wiki/lint-md.ts +2 -0
  80. package/src/wiki/scan-md.ts +37 -10
@@ -2,18 +2,14 @@
2
2
  // and the global processes every house repo declares in its `.shipkit.ts`
3
3
  // files), read in a short child process so pm itself never imports a repo
4
4
  // file. That graph would make pm large and reload it on every edit there.
5
+ // The answer names the paths it read (`inputs`) and their stamp, so pm runs
6
+ // the child again only once a stat of one of them moved.
5
7
  //
6
- // bun pm-config.ts <root> prints { index, processes, errors } as json on stdout
8
+ // bun pm-config.ts <root> prints a PmConfig as json on stdout
7
9
  import { homedir } from 'os'
8
- import type { GlobalProcessDecl } from './global-units.ts'
10
+ import { houseInputs, stampOf, type PmConfig } from './pm-inputs.ts'
9
11
 
10
- export type PmConfig = {
11
- /** the house wiki index the repo includes, null when it includes none */
12
- index: string | null
13
- processes: GlobalProcessDecl[]
14
- /** the unit files that would not load */
15
- errors: string[]
16
- }
12
+ export type { PmConfig }
17
13
 
18
14
  if (import.meta.main) {
19
15
  const root = process.argv[2]
@@ -22,14 +18,22 @@ if (import.meta.main) {
22
18
  process.exit(2)
23
19
  }
24
20
  const { houseRepos } = await import('../serve/project-gutter.ts')
21
+ const { repoUnitFiles } = await import('../core/discover-checks.ts')
25
22
  const house = houseRepos(root, homedir())
26
- const out: PmConfig = { index: house.index, processes: [], errors: [] }
23
+ const repos = house.index == null ? [] : house.repos
24
+ const files = new Map<string, string[]>()
25
+ const inputs = houseInputs(house.home, house.index, repos.map((r) => r.path), (r, dirs) => {
26
+ const f = repoUnitFiles(r, dirs)
27
+ files.set(r, f)
28
+ return f
29
+ })
30
+ const out: PmConfig = { index: house.index, processes: [], errors: [], inputs, stamp: stampOf(inputs) }
27
31
  if (house.index != null) {
28
32
  // a unit file imports the lib by name: this copy, like the daemon's
29
33
  const { useOneLib } = await import('../core/one-lib.ts')
30
34
  const { scanGlobalUnits } = await import('./global-units.ts')
31
35
  useOneLib()
32
- const u = await scanGlobalUnits(house.repos.map((r) => ({ name: r.name, path: r.path })))
36
+ const u = await scanGlobalUnits(repos.map((r) => ({ name: r.name, path: r.path })), { files: (r) => files.get(r) ?? [] })
33
37
  out.processes = u.processes
34
38
  out.errors = u.errors
35
39
  }
@@ -0,0 +1,76 @@
1
+ // what pm stamps to know whether the house side moved, without running the
2
+ // child that reads it (pm-config.ts): the answer's shape, the paths it
3
+ // depends on, and their stamp. Imports nothing a repo file does, so pm's own
4
+ // graph stays small.
5
+ import { readdirSync, statSync } from 'fs'
6
+ import { dirname, join } from 'path'
7
+ import { UNIT_SUFFIX } from '../core/check-file-names.ts'
8
+ import type { GlobalProcessDecl } from './global-units.ts'
9
+
10
+ export type PmConfig = {
11
+ /** the house wiki index the repo includes, null when it includes none */
12
+ index: string | null
13
+ processes: GlobalProcessDecl[]
14
+ /** the unit files that would not load */
15
+ errors: string[]
16
+ /** every path whose stat can change this answer (houseInputs) */
17
+ inputs: string[]
18
+ /** stampOf(inputs), taken before the unit files were imported */
19
+ stamp: string
20
+ }
21
+
22
+ /** the files a repo carries its includes in. Here, not in project-gutter.ts
23
+ * which reads them, so pm stamps them without loading that graph */
24
+ export const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
25
+
26
+ /** what the answer depends on, as paths to stat: the dir holding the repos
27
+ * (a repo added or removed), every sibling's CLAUDE files (a repo starting
28
+ * to include the house), the index, each house repo's config (archived),
29
+ * and every dir and unit file the unit walk read. A helper a unit file
30
+ * imports is not in it: an edit there shows once the unit file moves */
31
+ export function houseInputs(home: string, index: string | null, repos: string[], unitFiles: (root: string, dirs: string[]) => string[]): string[] {
32
+ const parent = dirname(home)
33
+ const out = [parent]
34
+ let names: string[] = []
35
+ try { names = readdirSync(parent) } catch { /* the stat of parent says when it appears */ }
36
+ for (const n of names.sort()) if (!n.startsWith('.')) for (const f of CLAUDE_FILES) out.push(join(parent, n, f))
37
+ if (index != null) out.push(index)
38
+ for (const r of repos) {
39
+ out.push(join(r, 'repo.config.ts'))
40
+ const dirs: string[] = []
41
+ const files = unitFiles(r, dirs)
42
+ out.push(...dirs, ...files)
43
+ }
44
+ return out
45
+ }
46
+
47
+ /** a dir's entries that can change the answer: its subdirs and its unit
48
+ * files. A temp file coming and going moves the dir's mtime, not this */
49
+ const listings = new Map<string, { mtime: number; names: string }>()
50
+ function dirNames(dir: string, mtime: number): string {
51
+ const hit = listings.get(dir)
52
+ if (hit?.mtime === mtime) return hit.names
53
+ let names = ''
54
+ try {
55
+ names = readdirSync(dir, { withFileTypes: true })
56
+ .filter((e) => !e.name.startsWith('.') && (e.isDirectory() || e.name.endsWith(UNIT_SUFFIX)))
57
+ .map((e) => e.name)
58
+ .sort()
59
+ .join('/')
60
+ } catch { /* gone between the stat and the read: the next stat says so */ }
61
+ listings.set(dir, { mtime, names })
62
+ return names
63
+ }
64
+
65
+ /** every path as one string: a file by its mtime, a dir by the entries
66
+ * that matter (read again only when its mtime moved), -1 when missing */
67
+ export function stampOf(paths: string[]): string {
68
+ return paths.map((p) => {
69
+ try {
70
+ const st = statSync(p)
71
+ return st.isDirectory() ? dirNames(p, st.mtimeMs) : st.mtimeMs
72
+ } catch {
73
+ return -1
74
+ }
75
+ }).join(',')
76
+ }
package/src/home/pm.ts CHANGED
@@ -154,9 +154,9 @@ export class Pm {
154
154
  if (act === 'start') report(r.set('up'))
155
155
  else if (act === 'stop') report(r.set('down'))
156
156
  else if (act === 'restart') report(r.restart())
157
- else if (act === 'open' || act === 'app') {
157
+ else if (act === 'open' || act === 'chrome' || act === 'app') {
158
158
  if (r.decl.url == null) return new Response(`${r.decl.id} declares no url to open`, { status: 400 })
159
- report(r.open(act === 'app' ? 'app' : 'browser'))
159
+ report(r.open(act === 'open' ? 'browser' : act))
160
160
  } else if (act === 'takeover') {
161
161
  const v = r.takeOverView
162
162
  if (v == null) return new Response(`${r.decl.id} does not run outside the supervisor: nothing to take over`, { status: 409 })
@@ -166,7 +166,7 @@ export class Pm {
166
166
  if (!r.decl.actions.some((a) => a.id === b.action)) return new Response(`${r.decl.id} declares no action '${String(b.action)}'`, { status: 404 })
167
167
  if (r.running != null) return new Response(`${r.decl.id} is already running its action '${r.running}'`, { status: 409 })
168
168
  report(r.action(String(b.action)))
169
- } else return new Response('act must be start, stop, restart, open, app, takeover or action', { status: 400 })
169
+ } else return new Response('act must be start, stop, restart, open, chrome, app, takeover or action', { status: 400 })
170
170
  return Response.json(r.view())
171
171
  }
172
172
  return new Response('not found', { status: 404 })
@@ -9,8 +9,9 @@ import type { CorePorts, PmAnswer } from './pm.ts'
9
9
  import type { ProcessIcon, ResourceView } from './resources.ts'
10
10
  import type { ProcStatus } from './supervise.ts'
11
11
 
12
- /** a status as pm reports it, or unknown while pm does not answer */
13
- export type RowStatus = ProcStatus | 'unknown'
12
+ /** a status as pm reports it, unknown while pm does not answer, silent
13
+ * while pm runs it but the page gets no answer from it */
14
+ export type RowStatus = ProcStatus | 'unknown' | 'silent'
14
15
 
15
16
  /** the dot a row wears: green up, red crashed, grey stopped or unknown, orange on its way */
16
17
  export type RowDot = 'up' | 'crashed' | 'down' | 'starting' | 'unknown'
@@ -46,6 +47,8 @@ export type ProcessRow = {
46
47
  actions: { id: string; label: string }[]
47
48
  running: string | null
48
49
  icon: ProcessIcon | null
50
+ /** its own app is being built or opened */
51
+ appBusy: boolean
49
52
  }
50
53
 
51
54
  /** a repo's processes, or shipkit's own (`core`, always the first group) */
@@ -55,7 +58,7 @@ export type ProcessGroup = { repo: string; core?: true; rows: ProcessRow[] }
55
58
  export const CORE_GROUP = 'shipkit'
56
59
 
57
60
  export const dotOf = (s: RowStatus): RowDot =>
58
- s === 'up' ? 'up' : s === 'crashed' ? 'crashed' : s === 'starting' || s === 'stopping' ? 'starting' : s === 'down' ? 'down' : 'unknown'
61
+ s === 'up' ? 'up' : s === 'crashed' || s === 'silent' ? 'crashed' : s === 'starting' || s === 'stopping' ? 'starting' : s === 'down' ? 'down' : 'unknown'
59
62
 
60
63
  const hostOf = (url: string | null): string | null => (url == null ? null : url.replace(/^https?:\/\//, '').replace(/\/$/, ''))
61
64
 
@@ -85,6 +88,7 @@ export function rowOf(r: ResourceView): ProcessRow {
85
88
  actions: r.actions,
86
89
  running: r.running,
87
90
  icon: r.icon,
91
+ appBusy: r.appBusy === true,
88
92
  }
89
93
  }
90
94
 
@@ -113,6 +117,7 @@ export function declaredRow(d: GlobalProcessDecl): ProcessRow {
113
117
  takeOver: null,
114
118
  actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
115
119
  running: null,
120
+ appBusy: false,
116
121
  // an image is pm's to serve, and pm does not answer
117
122
  icon: d.icon == null || /\.[a-z0-9]+$/i.test(d.icon) ? null : { text: d.icon },
118
123
  }
@@ -143,6 +148,10 @@ export type CoreOpts = {
143
148
  daemonVersion?: string | null
144
149
  /** where each listens, when pm does not say (an older pm, or pm down) */
145
150
  ports?: CorePorts | null
151
+ /** one of shipkit's own not answering the page, and why: its row says
152
+ * no answer whatever pm last reported. pm silent, every other row is
153
+ * the last answer's with its state unknown, so the table keeps its lines */
154
+ silent?: Partial<Record<'pm' | 'daemon' | 'web', string>>
146
155
  }
147
156
 
148
157
  function coreRow(id: 'pm' | 'daemon' | 'web', o: { status: RowStatus; pid: number | null; since: number | null; note: string | null; tail: string[]; version: string | null; port: number | null; restart: boolean }): ProcessRow {
@@ -150,7 +159,7 @@ function coreRow(id: 'pm' | 'daemon' | 'web', o: { status: RowStatus; pid: numbe
150
159
  return {
151
160
  id, label: id, repo: CORE_GROUP, file: '', status: o.status, dot: dotOf(o.status), url, host: hostOf(url), pid: o.pid, since: o.since, note: o.note,
152
161
  description: CORE_ABOUT[id], where: null, lastLog: o.tail.at(-1) ?? null, tail: o.tail,
153
- canStop: false, canRestart: o.restart, core: true, version: o.version, takeOver: null, actions: [], running: null, icon: null,
162
+ canStop: false, canRestart: o.restart, core: true, version: o.version, takeOver: null, actions: [], running: null, icon: null, appBusy: false,
154
163
  }
155
164
  }
156
165
 
@@ -171,13 +180,20 @@ export function coreGroup(pm: PmAnswer | null, o: CoreOpts = {}): ProcessGroup {
171
180
  rows.push(coreRow(id, { status: c.status, pid: c.pid, since: c.since, note: note === '' ? null : note, tail: c.tail, version, port, restart: true }))
172
181
  }
173
182
  }
174
- return { repo: CORE_GROUP, core: true, rows }
183
+ const s = o.silent ?? {}
184
+ return { repo: CORE_GROUP, core: true, rows: rows.map((r) => {
185
+ const why = s[r.id as 'pm' | 'daemon' | 'web']
186
+ return why != null && why !== '' ? { ...r, status: 'silent', dot: 'crashed', note: why } : s.pm ? stale(r) : r
187
+ }) }
175
188
  }
176
189
 
177
190
  /** what the page's table holds, from pm's answer: the core group, then the
178
191
  * global processes by repo */
179
192
  export const processGroups = (pm: PmAnswer | null, order: string[], o: CoreOpts = {}): ProcessGroup[] =>
180
- [coreGroup(pm, o), ...groupRows((pm?.resources ?? []).map(rowOf), order)]
193
+ [coreGroup(pm, o), ...groupRows((pm?.resources ?? []).map((r) => (o.silent?.pm ? stale(rowOf(r)) : rowOf(r))), order)]
194
+
195
+ /** a row from an answer pm can no longer confirm: its state unknown, nothing to click */
196
+ const stale = (r: ProcessRow): ProcessRow => ({ ...r, status: 'unknown', dot: 'unknown', canStop: false, canRestart: false, takeOver: null, running: null, appBusy: false })
181
197
 
182
198
  /** the declared icon, else the repo's own, else none */
183
199
  export const processIcon = (r: { icon: ProcessIcon | null }, repoIcon: string | null): ProcessIcon | null =>
@@ -200,6 +216,7 @@ export function stateWords(r: Pick<ProcessRow, 'status' | 'since'>, now: number)
200
216
  case 'crashed': return age == null ? 'crashed' : `crashed ${age} ago`
201
217
  case 'down': return 'stopped'
202
218
  case 'unknown': return 'unknown (pm down)'
219
+ case 'silent': return 'no answer'
203
220
  default: return r.status
204
221
  }
205
222
  }
@@ -1,14 +1,15 @@
1
1
  // the machine's global processes (ComfyUI, a TTS server, an app panel), each
2
2
  // declared once in a repo's `<name>.shipkit.ts` as `globalProcess({...})`
3
3
  // (shipkit-files.ts) and owned by pm: a switch, a restart, its log, its
4
- // action buttons, and its url opened in the default browser or as a shipkit
5
- // app window.
4
+ // action buttons, and its url opened in the default browser, in Chrome, or
5
+ // as its own app (home/gui-app.ts builds that app in its repo on first use).
6
6
  //
7
7
  // two kinds: with no `down`, the command IS the service, supervised like a
8
8
  // hub (restarted on a crash, signalled on stop). With `down`, the command is
9
9
  // a bring-up that exits (an idempotent ladder script), `down` its reverse,
10
10
  // and the probe is the only truth.
11
- import { mkdirSync, openSync, closeSync } from 'fs'
11
+ import { closeSync, existsSync, mkdirSync, openSync } from 'fs'
12
+ import { homedir } from 'os'
12
13
  import { dirname, join } from 'path'
13
14
  import { fileURLToPath } from 'url'
14
15
  import type { GlobalProcessDecl } from './global-units.ts'
@@ -47,8 +48,14 @@ export type ResourceView = {
47
48
  actions: { id: string; label: string }[]
48
49
  /** the action running now, or null */
49
50
  running: string | null
51
+ /** its own app is being built or opened: the page shows a spinner on
52
+ * that button. Absent from an older pm */
53
+ appBusy?: boolean
50
54
  }
51
55
 
56
+ /** where a url opens: the default browser, Chrome, or the process's own app */
57
+ export type OpenHow = 'browser' | 'chrome' | 'app'
58
+
52
59
  /** an emoji, or the url pm serves a declared image file at */
53
60
  export type ProcessIcon = { text: string } | { src: string }
54
61
 
@@ -83,17 +90,19 @@ export const runOnce: RunOnce = async (cmd, cwd, log) => {
83
90
  }
84
91
  }
85
92
 
86
- /** opens a shipkit app window on a url; what it did lands in the log */
87
- export type OpenApp = (url: string, log: string) => void
93
+ /** opens a process's own app, building it first when it is missing or
94
+ * stale; what it did lands in the log. Resolves with the exit code */
95
+ export type OpenApp = (d: GlobalProcessDecl, log: string) => Promise<number>
88
96
 
89
97
  /** its own process: what building the app needs never loads in pm */
90
98
  const OPEN_APP = fileURLToPath(new URL('./open-app.ts', import.meta.url))
91
99
 
92
- const openApp: OpenApp = (url, log) => {
100
+ const openApp: OpenApp = async (d, log) => {
93
101
  mkdirSync(dirname(log), { recursive: true })
94
102
  const fd = openSync(log, 'a')
95
103
  try {
96
- Bun.spawn([process.execPath, OPEN_APP, url], { stdio: ['ignore', fd, fd] })
104
+ const spec = { id: d.id, label: d.label, url: d.url, icon: d.icon, root: d.root }
105
+ return await Bun.spawn([process.execPath, OPEN_APP, '--process', JSON.stringify(spec)], { stdio: ['ignore', fd, fd] }).exited
97
106
  } finally {
98
107
  closeSync(fd)
99
108
  }
@@ -113,6 +122,26 @@ const browse: Browse = (url, log) => {
113
122
  }
114
123
  }
115
124
 
125
+ const CHROME_APPS = ['/Applications/Google Chrome.app', join(homedir(), 'Applications', 'Google Chrome.app')]
126
+
127
+ /** the command that opens a url in Chrome, else in the default browser */
128
+ export function chromeCmd(url: string, o: { platform?: string; has?: (path: string) => boolean } = {}): string[] {
129
+ const platform = o.platform ?? process.platform
130
+ const has = o.has ?? existsSync
131
+ if (platform === 'darwin') return CHROME_APPS.some(has) ? ['open', '-a', 'Google Chrome', url] : ['open', url]
132
+ return platform === 'win32' ? ['cmd', '/c', 'start', 'chrome', url] : ['xdg-open', url]
133
+ }
134
+
135
+ const chrome: Browse = (url, log) => {
136
+ mkdirSync(dirname(log), { recursive: true })
137
+ const fd = openSync(log, 'a')
138
+ try {
139
+ Bun.spawn(chromeCmd(url), { stdio: ['ignore', fd, fd] })
140
+ } finally {
141
+ closeSync(fd)
142
+ }
143
+ }
144
+
116
145
  /** the function action runner: imports the unit file, calls the action */
117
146
  const ACTION_RUNNER = fileURLToPath(new URL('./run-unit-action.ts', import.meta.url))
118
147
 
@@ -120,10 +149,12 @@ export type ResourceOpts = {
120
149
  probe?: HttpProbe
121
150
  run?: RunOnce
122
151
  deps?: SuperviseDeps
123
- /** the app window */
152
+ /** the process's own app */
124
153
  open?: OpenApp
125
154
  /** the default browser */
126
155
  browse?: Browse
156
+ /** Chrome */
157
+ chrome?: Browse
127
158
  /** the pids listening on a local port, and the process group of a pid */
128
159
  holders?: (port: number) => number[]
129
160
  groupOf?: (pid: number) => number | null
@@ -150,8 +181,10 @@ export class Resource {
150
181
  private busy = false
151
182
  /** the last bring-up exited non zero */
152
183
  private failed = false
153
- /** open it once it answers: in the default browser, or as an app window */
154
- private openWhenUp: 'browser' | 'app' | null = null
184
+ /** open it once it answers */
185
+ private openWhenUp: OpenHow | null = null
186
+ /** its own app is being built or opened */
187
+ appBusy = false
155
188
  /** the status a tick last saw, and since when, for the bring-up kind */
156
189
  private seen: ProcStatus = 'down'
157
190
  private seenAt: number
@@ -248,9 +281,9 @@ export class Resource {
248
281
  await this.set('up')
249
282
  }
250
283
 
251
- /** its url in the default browser, or as a shipkit app window: at once
284
+ /** its url in the default browser, in Chrome, or as its own app: at once
252
285
  * while it answers, else it starts and opens once it does */
253
- async open(how: 'browser' | 'app'): Promise<void> {
286
+ async open(how: OpenHow): Promise<void> {
254
287
  if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
255
288
  if (this.status === 'up') {
256
289
  this.show(how, this.decl.url)
@@ -260,9 +293,27 @@ export class Resource {
260
293
  await this.set('up')
261
294
  }
262
295
 
263
- private show(how: 'browser' | 'app', url: string): void {
264
- if (how === 'app') (this.o.open ?? openApp)(url, this.log)
265
- else (this.o.browse ?? browse)(url, this.log)
296
+ private show(how: OpenHow, url: string): void {
297
+ if (how === 'browser') (this.o.browse ?? browse)(url, this.log)
298
+ else if (how === 'chrome') (this.o.chrome ?? chrome)(url, this.log)
299
+ else if (!this.appBusy) void this.showApp()
300
+ }
301
+
302
+ /** off the request path: the build takes seconds the first time, and
303
+ * the page follows `appBusy` on its poll */
304
+ private async showApp(): Promise<void> {
305
+ this.appBusy = true
306
+ let failed: string | null = null
307
+ try {
308
+ const code = await (this.o.open ?? openApp)(this.decl, this.log)
309
+ if (code !== 0) failed = `opening ${this.decl.label} as an app exited ${code}: see the log`
310
+ } catch (e) {
311
+ failed = `opening ${this.decl.label} as an app: ${(e as Error).message}`
312
+ } finally {
313
+ this.appBusy = false
314
+ }
315
+ // kept beside what the supervisor says, like an action's outcome
316
+ if (failed != null) this.note = this.actionNote = failed
266
317
  }
267
318
 
268
319
  /** one declared button. A function action runs in its own bun process
@@ -328,6 +379,7 @@ export class Resource {
328
379
  icon: processIconOf(d),
329
380
  actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
330
381
  running: this.running,
382
+ appBusy: this.appBusy,
331
383
  }
332
384
  }
333
385
  }
@@ -23,6 +23,7 @@ import { Planner } from '../plan/planner.ts'
23
23
  import { machineCatchUp } from '../plan/catch-up.ts'
24
24
  import { serveDaemon } from './serve-daemon.ts'
25
25
  import { startSweeps } from './machine-stores.ts'
26
+ import { landMotherhome } from '../core/motherhome.ts'
26
27
  import { Widgets } from './widgets.ts'
27
28
 
28
29
  const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
@@ -109,7 +110,11 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
109
110
  // nothing grows unbound: every store of every repo it loads, and the
110
111
  // machine's, swept on boot and hourly (core/retention.ts)
111
112
  const only = onlyRepos()
112
- const sweeps = startSweeps({ roots: () => house.repos.filter((r) => only == null || only.has(r.name)).map((r) => r.path), dir, printErr: io.printErr })
113
+ const sweeps = startSweeps({
114
+ roots: () => house.repos.filter((r) => only == null || only.has(r.name)).map((r) => r.path), dir, printErr: io.printErr,
115
+ // the rows every gate filed land here, one commit a day at most
116
+ also: (now) => { for (const n of landMotherhome({ now })) io.printErr(n) },
117
+ })
113
118
  const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, io })
114
119
  io.print(` ${house.repos.length} repos, state in ${dir}, reloads on ${code.files} lib files`)
115
120
  for (const sig of ['SIGINT', 'SIGTERM'] as const)
@@ -11,16 +11,37 @@ import { reloadOnOwnCode } from './lib-watch.ts'
11
11
  import { Pm, PM_TICK_MS, type Role } from './pm.ts'
12
12
  import { Resources } from './resources.ts'
13
13
  import type { HomeIo } from './run-daemon.ts'
14
- import type { PmConfig } from './pm-config.ts'
14
+ import { stampOf, type PmConfig } from './pm-inputs.ts'
15
15
 
16
16
  const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
17
17
  const PM_CONFIG = fileURLToPath(new URL('./pm-config.ts', import.meta.url))
18
18
 
19
19
  const RUN_PM = fileURLToPath(import.meta.url)
20
20
 
21
- /** how often pm reads the house repos' global processes again */
21
+ /** how often pm stats what its last read of the house side saw */
22
22
  export const PM_RESCAN_MS = 15_000
23
23
 
24
+ /** pm's reread of the house side: the child runs again only once a stat of
25
+ * a path the last answer read moved. A few ms of stats per poll, against a
26
+ * child that imports every unit file */
27
+ export class PmConfigWatch {
28
+ private last: PmConfig | null = null
29
+
30
+ constructor(private readonly read: () => Promise<PmConfig>) {}
31
+
32
+ seed(c: PmConfig): void {
33
+ this.last = c
34
+ }
35
+
36
+ /** the new answer, or null when nothing it read moved */
37
+ async poll(): Promise<PmConfig | null> {
38
+ if (this.last != null && stampOf(this.last.inputs) === this.last.stamp) return null
39
+ const c = await this.read()
40
+ this.last = c
41
+ return c
42
+ }
43
+ }
44
+
24
45
  /** replace this process's image with pm run from this file: same pid, same
25
46
  * cwd, same env, none of cli.ts loaded. False when the runtime has no
26
47
  * execve, and the caller runs pm in process */
@@ -98,13 +119,16 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
98
119
  pm.resources.errors = c.errors
99
120
  for (const e of c.errors) io.printErr(`WARN ${e}`)
100
121
  // a unit file added, edited or removed in any house repo: read again,
101
- // in its child process, and the unchanged processes keep their state
122
+ // in its child process, and the unchanged processes keep their state.
123
+ // Nothing moved, no child
124
+ const watch = new PmConfigWatch(() => readPmConfig(root))
125
+ watch.seed(c)
102
126
  let reading = false
103
127
  const reread = setInterval(() => {
104
128
  if (reading) return
105
129
  reading = true
106
- void readPmConfig(root)
107
- .then((n) => pm.resources.sync(n.processes, n.errors))
130
+ void watch.poll()
131
+ .then((n) => { if (n != null) pm.resources.sync(n.processes, n.errors) })
108
132
  .catch((e) => io.printErr(`WARN pm could not read the global processes again: ${(e as Error).message}`))
109
133
  .finally(() => { reading = false })
110
134
  }, PM_RESCAN_MS)
@@ -2,7 +2,8 @@
2
2
  // rendered by the daemon on each one's own clock and kept as html the page
3
3
  // reads at /api/home/widgets.json. The files are read again every
4
4
  // WIDGET_RESCAN_MS, a walk of every repo that costs a few tens of ms, and an
5
- // unchanged file is never imported twice.
5
+ // unchanged file is never imported twice. All of it only while a reader
6
+ // asked within WIDGET_WATCH_MS: nobody looking, nothing scans or renders.
6
7
  //
7
8
  // the one constraint a reader breaks first: a render runs in the daemon, so
8
9
  // it is bounded (WIDGET_RENDER_MS) and never awaited by the daemon's tick.
@@ -11,6 +12,8 @@ import { scanGlobalUnits, type LoadedWidget, type RepoRef } from './global-units
11
12
  export const WIDGET_EVERY_MS = 5_000
12
13
  export const WIDGET_RENDER_MS = 5_000
13
14
  export const WIDGET_RESCAN_MS = 15_000
15
+ /** a read within this long keeps the clocks running */
16
+ export const WIDGET_WATCH_MS = 30_000
14
17
 
15
18
  export type WidgetView = {
16
19
  repo: string
@@ -38,6 +41,8 @@ export class Widgets {
38
41
  private slots = new Map<string, Slot>()
39
42
  private scannedAt = -Infinity
40
43
  private scanning = false
44
+ private readAt: number | null = null
45
+ private rendering = new Set<Promise<void>>()
41
46
  /** the unit files that would not load */
42
47
  errors: string[] = []
43
48
 
@@ -47,9 +52,14 @@ export class Widgets {
47
52
  return (this.d.now ?? Date.now)()
48
53
  }
49
54
 
55
+ private get watched(): boolean {
56
+ return this.readAt != null && this.now() - this.readAt < WIDGET_WATCH_MS
57
+ }
58
+
50
59
  /** one look: read the files again when due, render every widget due. Never
51
- * waits on a render */
60
+ * waits on a render, and does nothing while no reader is watching */
52
61
  async tick(repos: RepoRef[]): Promise<void> {
62
+ if (!this.watched) return
53
63
  const now = this.now()
54
64
  if (!this.scanning && now - this.scannedAt >= WIDGET_RESCAN_MS) {
55
65
  this.scanning = true
@@ -73,7 +83,24 @@ export class Widgets {
73
83
  this.scanning = false
74
84
  }
75
85
  }
76
- for (const s of this.slots.values()) if (!s.busy && this.now() >= s.due) void this.render(s)
86
+ for (const s of this.slots.values()) {
87
+ if (s.busy || this.now() < s.due) continue
88
+ const p = this.render(s)
89
+ this.rendering.add(p)
90
+ void p.finally(() => this.rendering.delete(p))
91
+ }
92
+ }
93
+
94
+ /** a reader's views: the first one in a while waits on a scan and on the
95
+ * renders it starts (each bounded), so it never reads hours old html */
96
+ async look(repos: RepoRef[]): Promise<WidgetView[]> {
97
+ const cold = !this.watched
98
+ this.readAt = this.now()
99
+ if (cold) {
100
+ await this.tick(repos)
101
+ await Promise.all(this.rendering)
102
+ }
103
+ return this.views()
77
104
  }
78
105
 
79
106
  /** one render, bounded, its result or its error kept */
@@ -96,7 +123,9 @@ export class Widgets {
96
123
  }
97
124
  }
98
125
 
126
+ /** the last html of each; a read that keeps the clocks running */
99
127
  views(): WidgetView[] {
128
+ this.readAt = this.now()
100
129
  return [...this.slots.values()].map((s) => s.view)
101
130
  }
102
131
  }
@@ -1,6 +1,5 @@
1
- // the commit-message predicate, shared by the commit-msg hook (blocks at
2
- // write time) and SK009 (audits history). one implementation, or the two
3
- // drift and the drift only shows when a repo goes public.
1
+ // the commit-message predicate of the commit-msg hook, which blocks at write
2
+ // time: the one guard against AI attribution (SK009 checks it is armed).
4
3
  //
5
4
  // mechanical text rules only, so this stays publishable.
6
5
 
@@ -53,7 +53,8 @@ export type HooksConfig = {
53
53
  languageAllow?: string[]
54
54
  /** disable a built-in gate, visible in `hooks list`, never silent.
55
55
  * 'commit-language' lifts ONLY the French check, keeping the dash and
56
- * AI-attribution guards armed. */
56
+ * AI-attribution guards armed. 'commit-message' lifts the dash and French
57
+ * checks; the AI-attribution guard is never lifted. */
57
58
  disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
58
59
  }
59
60
 
@@ -309,6 +310,13 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
309
310
  ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
310
311
  ],
311
312
  })
313
+ } else {
314
+ // AI attribution is the one message rule no config lifts: this hook is
315
+ // the guard against it in every repo (identity/how-we-commit.md)
316
+ const offenses = commitMessageOffenses(message).filter((o) => o.kind === 'attribution')
317
+ out.push(offenses.length === 0
318
+ ? { id: 'ai-attribution', ok: true, lines: ['ok: no AI attribution in the commit message'] }
319
+ : { id: 'ai-attribution', ok: false, lines: [`FAIL: commit message carries AI attribution`, ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`)] })
312
320
  }
313
321
  // the leak guard also reads the MESSAGE, a private name pasted into a
314
322
  // commit body is as public as one pasted into a file
@@ -347,7 +355,9 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
347
355
  ...((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)` : ''}`] : []),
348
356
  `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`,
349
357
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
350
- `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
358
+ off.has('commit-message')
359
+ ? `commit-msg commit-message (DISABLED by config) — dash punctuation + French; ai-attribution stays on, never disabled`
360
+ : `commit-msg commit-message — dash punctuation + AI attribution (never disabled) + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
351
361
  `commit-msg leak-guard${leakState} — the message text`,
352
362
  ...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
353
363
  ]
@@ -351,7 +351,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
351
351
  owner: u.owner,
352
352
  group: u.def.group ?? null,
353
353
  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 }, c.budgets),
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),
355
355
  optIn: false,
356
356
  fixable: u.def.fix != null,
357
357
  when: w,
@@ -389,7 +389,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
389
389
  owner: r.owner,
390
390
  group: null,
391
391
  timeoutMs: null,
392
- budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null }, c.budgets),
392
+ budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null || r.never != null }, c.budgets),
393
393
  optIn: false,
394
394
  fixable: r.fix != null,
395
395
  when: whenOfRule(c, r.id),
@@ -433,7 +433,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
433
433
  owner: rel,
434
434
  group: task?.group ?? null,
435
435
  timeoutMs: task?.timeoutMs ?? null,
436
- budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null }, c.budgets),
436
+ budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null || d.never != null }, c.budgets),
437
437
  optIn: false,
438
438
  // the reader is a source scanner, so it cannot carry a function
439
439
  fixable: false,