rvlib-shipkit 1.7.0 → 1.8.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 1.8.0
2
+
3
+ - **The quick tier can run only the suites a change reaches** (opt in: `tests: { select: true }`, or `SHIPKIT_TEST_SELECT=1` for one run). It keeps the snapshot of the last run and its red suites, and runs those plus every suite whose import closure, or whose recorded runtime reads, a change since then reaches. What it cannot see it runs: an unclaimed change under a code root, a spawn it does not know, a module that threw while loading, a run config file. The full tier still runs every suite.
4
+
5
+ - **Runs of one repo in the home share, then overlap.** A terminal `sk ci` or `sk check` identical to one in flight on the same tree attaches to it, prints its lines and exits with its code. Runs without the repair pass start at once beside the runs in flight, bounded by the machine slot pool. A run with the repair pass still goes alone. Before, every run of a repo queued behind the one in flight, so a one second gate could wait minutes.
6
+
7
+ - **The house context: one generated file every house agent loads.** `sk global-context` builds `generated/global-context.md` beside the house index. It lists every global process with the commands that drive it, every house repo, and each repo's `agent/global-contribution.md`. The daemon rewrites it within a minute of a source moving. A process or repo description joins by its first line only, and every item is scanned with the banned keywords of the commit leak guard: a hit leaves it out and says so. It exists only for a house whose index includes `@generated/global-context.md`. `--write` writes it and `--check` fails when it is stale.
8
+
9
+ - **SK033: every house repo carries `agent/global-contribution.md`.** It is what the repo offers every other agent, capped at 40 lines and 3 KB with no heading above `####`, and `sk ci` writes a comment-only stub when it is missing. It applies only where the house index loads the generated context. It is cached on the file, so the check body runs again only when the file moves. SK027 no longer calls the file an orphan, because the house index links it.
10
+
11
+ - **`sk gp open <id>` opens a global process's url** in the default browser, in Chrome (`--chrome`) or as its own app (`--app`), the overview's open buttons in the console. A process that is down starts first.
12
+
13
+ - **The quick tier honours the worker count a repo names.** A `--parallel=<n>` in the repo's own `bun test` body is now a ceiling over the machine pool's share: the parallel phase takes the smaller of the two, as the one `--parallel` on its line. Before, the runner appended its own count after the body, and bun reads the last one, so a repo that measured fewer workers as faster still ran on every core of an idle machine. A bare `--parallel` keeps the pool's count.
14
+
15
+ - **Declared serial suites and the full tier stay one at a time when the repo's body names `--parallel`.** The runner passed the body as written to the serial phase, to the full tier and to a suspect's alone rerun. So a body naming `--parallel=7` ran its declared serial suites, the ones that cannot share a run, in seven workers, and its full tier too, which runs one suite at a time because its suites spawn git and whole gates. Only the quick tier's parallel phase names a worker count now, and the runner keeps the body's `--parallel` off every other line.
16
+
1
17
  ## 1.7.0
2
18
 
3
19
  - **Cmd-F in the shipkit app shows a find bar now.** Since the app shipped, the Find menu did nothing visible: NSTextFinder never shows its bar for a web view. The app now draws its own bar on WebKit's find API. Return and cmd-G go to the next match, shift-return and shift-cmd-G to the previous one, Esc closes the bar, and it says "no match" when the page does not hold the text. Rebuilt apps pick it up at their next launch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -3,9 +3,11 @@
3
3
  // a dangling ref there fails. A link that leaves agent/ but stays in the repo
4
4
  // is checked on disk; a pointer out of the repo (a house wiki) is backticked,
5
5
  // never a bare → or @. A repo without agent/index.md skips.
6
+ // agent/global-contribution.md is no orphan: the house index links it (SK033).
6
7
  import { existsSync, readFileSync } from 'fs'
7
8
  import { basename, join } from 'path'
8
9
  import { check } from '../check.ts'
10
+ import { CONTRIBUTION_FILE } from '../core/global-contribution.ts'
9
11
  import { lintMd } from '../wiki/lint-md.ts'
10
12
 
11
13
  export const agentWikiLinks = check({
@@ -22,8 +24,10 @@ export const agentWikiLinks = check({
22
24
  run: (c) => {
23
25
  if (!existsSync(join(c.root, 'agent/index.md'))) return void c.lines.push({ kind: 'skip', text: 'no agent/ wiki in this repo' })
24
26
  const lint = lintMd({ root: join(c.root, 'agent'), index: 'index.md', boundary: c.root })
25
- const linked = lint.pages.filter((p) => !lint.orphans.includes(p))
26
- for (const f of lint.orphans) {
27
+ // the global contribution is the house index's to link (SK033), never agent/'s
28
+ const orphans = lint.orphans.filter((f) => `agent/${f}` !== CONTRIBUTION_FILE)
29
+ const linked = lint.pages.filter((p) => !orphans.includes(p))
30
+ for (const f of orphans) {
27
31
  const by = namingPage(c.root, f, linked)
28
32
  const where = by != null ? `${by}, which already names it (turn that mention into the link)` : 'agent/index.md or the page of its topic'
29
33
  c.fail(`agent/${f}: orphan, unreachable from agent/index.md: add a markdown link to it from ${where}; delete it only if the page is dead`)
@@ -0,0 +1,47 @@
1
+ // SK033 global contribution (meta/agent-folder.md): every house repo carries
2
+ // agent/global-contribution.md, what it offers every agent of every house
3
+ // repo (a house whose index loads @generated/global-context.md), which the house concatenates into the context each session loads
4
+ // (home/global-context.ts). A new one is a comment only, so it contributes
5
+ // nothing until an agent writes to it. Keyed on the file itself: once it is
6
+ // there, the body runs again only when the file moves. Its page is linked from
7
+ // the house index, never from agent/index.md, so SK027 leaves it out.
8
+ import { homedir } from 'os'
9
+ import { check, type FixResult } from '../check.ts'
10
+ import { CONTRIBUTION_FILE, CONTRIBUTION_STUB, CONTEXT_INCLUDE, contributionBody, contributionOver, loadsContext } from '../core/global-contribution.ts'
11
+ import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
12
+
13
+ /** why a repo gives SK033 nothing to do, or null in a repo whose house
14
+ * index loads the generated context */
15
+ export function notHouse(root: string, home: string = homedir()): string | null {
16
+ const index = houseIndex(homeRepo(root), home)
17
+ if (index == null) return 'this repo includes no house index, so no house context reads it'
18
+ if (!loadsContext(index)) return `its house index does not load the generated context (no ${CONTEXT_INCLUDE} line), so nothing reads a contribution`
19
+ return null
20
+ }
21
+
22
+ export const globalContribution = check({
23
+ id: 'SK033',
24
+ blocking: true,
25
+ title: 'global contribution',
26
+ description: 'agent/global-contribution.md exists and fits in the house context',
27
+ owner: 'meta/agent-folder.md',
28
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
29
+ cache: { by: 'files', inputs: [CONTRIBUTION_FILE], key: null },
30
+ fix: 'ci',
31
+ applies: (env) => notHouse(env.root),
32
+ run: (c) => {
33
+ const text = c.read(CONTRIBUTION_FILE)
34
+ if (text == null) return void c.fail(`${CONTRIBUTION_FILE} is missing: every house repo carries one, empty until it has something to offer`, 'shipkit ci writes the empty one')
35
+ const body = contributionBody(text)
36
+ const over = contributionOver(body)
37
+ if (over != null) return void c.fail(`${CONTRIBUTION_FILE} is ${over}: every session of every house repo loads it, so the house context cuts it`, 'keep what another repo\'s agent can act on, move the rest to agent/')
38
+ c.ok(body === '' ? `${CONTRIBUTION_FILE} is there, contributing nothing yet` : `${CONTRIBUTION_FILE} contributes ${body.split('\n').length} line(s)`)
39
+ },
40
+ repair: (c): FixResult => {
41
+ if (c.read(CONTRIBUTION_FILE) != null) return { changed: false, summary: `${CONTRIBUTION_FILE} is there: its size is the author's to fix` }
42
+ c.write(CONTRIBUTION_FILE, CONTRIBUTION_STUB)
43
+ return { changed: true, summary: `wrote the empty ${CONTRIBUTION_FILE}` }
44
+ },
45
+ })
46
+
47
+ export default globalContribution
@@ -0,0 +1,47 @@
1
+ // shipkit global-context: the house's generated context, printed, written, or
2
+ // checked against the file on disk. houseContext is the one computation, the
3
+ // daemon's writer calls it too.
4
+ import { existsSync, readFileSync } from 'fs'
5
+ import { Command, Option } from 'clipanion'
6
+ import { houseContext, writeIfChanged } from '../home/global-context.ts'
7
+
8
+ export class GlobalContextCommand extends Command {
9
+ static override paths = [['global-context']]
10
+
11
+ static override usage = Command.Usage({
12
+ category: 'Home',
13
+ description: 'the context every house agent loads: global processes, house repos, each repo\'s global contribution',
14
+ details: 'one md file beside the house index (generated/global-context.md), which the index includes, gitignored. The daemon rewrites it when a source moves; this command prints it, writes it, or fails when the file on disk is stale',
15
+ examples: [
16
+ ['print what the file would hold', 'sk global-context'],
17
+ ['write it beside the house index', 'sk global-context --write'],
18
+ ['fail when the file on disk is stale', 'sk global-context --check'],
19
+ ],
20
+ })
21
+
22
+ write = Option.Boolean('--write', false, { description: 'write it beside the house index, only when it changed' })
23
+ check = Option.Boolean('--check', false, { description: 'exit 1 when the file on disk differs from what the sources give' })
24
+
25
+ async execute(): Promise<number> {
26
+ const c = await houseContext(process.cwd())
27
+ if (c == null) {
28
+ this.context.stderr.write('FAIL: this repo includes no house index in its CLAUDE.md, so there is no house to read\n')
29
+ return 1
30
+ }
31
+ if (this.check) {
32
+ const now = existsSync(c.path) ? readFileSync(c.path, 'utf8') : null
33
+ if (now === c.text) {
34
+ this.context.stdout.write(`ok: ${c.path} is current\n`)
35
+ return 0
36
+ }
37
+ this.context.stdout.write(`FAIL: ${c.path} is ${now == null ? 'missing' : 'stale'}\n fix: shipkit global-context --write\n`)
38
+ return 1
39
+ }
40
+ if (this.write) {
41
+ this.context.stdout.write(writeIfChanged(c.path, c.text) ? `ok: wrote ${c.path}\n` : `ok: ${c.path} unchanged\n`)
42
+ return 0
43
+ }
44
+ this.context.stdout.write(c.text)
45
+ return 0
46
+ }
47
+ }
@@ -161,7 +161,7 @@ export type SwitchAct = 'start' | 'stop' | 'restart'
161
161
 
162
162
  export type SwitchDeps = {
163
163
  /** pm's answer to the switch: null when it took it, else why not */
164
- post: (id: string, act: SwitchAct) => Promise<string | null>
164
+ post: (id: string, act: SwitchAct | 'open' | 'chrome' | 'app') => Promise<string | null>
165
165
  /** the process as pm sees it now, null when pm does not answer */
166
166
  view: (id: string) => Promise<ResourceView | null>
167
167
  sleep: (ms: number) => Promise<void>
@@ -266,3 +266,40 @@ export class GlobalProcessRestartCommand extends SwitchCommand {
266
266
  examples: [['restart ComfyUI, so it rescans its models', 'sk gp restart comfyui']],
267
267
  })
268
268
  }
269
+
270
+ /** where `sk gp open` sends the url, by flag, as pm's resource api names it */
271
+ export const openAct = (o: { chrome: boolean; app: boolean }): 'open' | 'chrome' | 'app' => (o.app ? 'app' : o.chrome ? 'chrome' : 'open')
272
+
273
+ export class GlobalProcessOpenCommand extends Command {
274
+ static override paths = [['global-processes', 'open'], ['gp', 'open']]
275
+ static override usage = Command.Usage({
276
+ category: 'Home',
277
+ description: 'open a global process\'s url in the browser, Chrome or its app, starting it first when down',
278
+ details: 'the overview\'s open buttons in the console. pm opens the url at once while the process answers, else starts it and opens once its probe answers. Exit 1 when pm refuses (no such process, no url declared)',
279
+ examples: [
280
+ ['the comfy panel in the default browser', 'sk gp open comfy-panel'],
281
+ ['in Chrome', 'sk gp open comfy-panel --chrome'],
282
+ ['as its own app in the dock', 'sk gp open comfy-panel --app'],
283
+ ],
284
+ })
285
+
286
+ id = Option.String({ required: true, name: 'id' })
287
+ chrome = Option.Boolean('--chrome', false, { description: 'open it in Chrome' })
288
+ app = Option.Boolean('--app', false, { description: 'open it as its own app, built on first use' })
289
+
290
+ async execute(): Promise<number> {
291
+ if (this.chrome && this.app) {
292
+ this.context.stderr.write('--chrome and --app name two places: pick one\n')
293
+ return 2
294
+ }
295
+ const act = openAct(this)
296
+ const refused = await pmSwitchDeps(() => {}).post(this.id, act)
297
+ if (refused != null) {
298
+ this.context.stdout.write(`FAIL open ${this.id}: ${refused}\n`)
299
+ return 1
300
+ }
301
+ const where = act === 'app' ? 'as its own app' : act === 'chrome' ? 'in Chrome' : 'in the default browser'
302
+ this.context.stdout.write(`ok: ${this.id} opens ${where}; a process that was down starts first (shipkit gp shows it)\n`)
303
+ return 0
304
+ }
305
+ }
package/src/cli.ts CHANGED
@@ -11,9 +11,10 @@ import { CheckCommand } from './cli/cmd_Check.ts'
11
11
  import { CommandsCommand } from './cli/cmd_Commands.ts'
12
12
  import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
13
13
  import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
14
- import { GlobalProcessesCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
14
+ import { GlobalProcessesCommand, GlobalProcessOpenCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
15
15
  import { PlannedCommand } from './cli/cmd_Planned.ts'
16
16
  import { InsightsCommand } from './cli/cmd_Insights.ts'
17
+ import { GlobalContextCommand } from './cli/cmd_GlobalContext.ts'
17
18
  import { OverviewCommand } from './cli/cmd_Overview.ts'
18
19
  import { CiCommand } from './cli/cmd_Ci.ts'
19
20
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
@@ -98,9 +99,11 @@ export function buildCli(): Cli {
98
99
  cli.register(GlobalProcessStartCommand)
99
100
  cli.register(GlobalProcessStopCommand)
100
101
  cli.register(GlobalProcessRestartCommand)
102
+ cli.register(GlobalProcessOpenCommand)
101
103
  cli.register(CrossRepoActivityCommand)
102
104
  cli.register(PlannedCommand)
103
105
  cli.register(InsightsCommand)
106
+ cli.register(GlobalContextCommand)
104
107
  cli.register(OverviewCommand)
105
108
  cli.register(ChecksCommand)
106
109
  cli.register(WhenCommand)
@@ -0,0 +1,42 @@
1
+ // a house repo's global contribution: `agent/global-contribution.md`, what the
2
+ // repo offers every agent of every house repo (commands, how to run them).
3
+ // The house's generated context (home/global-context.ts) concatenates them,
4
+ // and every session of every house repo loads it, so each line costs tokens
5
+ // everywhere: CONTRIBUTION_MAX_LINES and CONTRIBUTION_MAX_BYTES bound it.
6
+
7
+ import { readFileSync } from 'fs'
8
+
9
+ export const CONTRIBUTION_FILE = 'agent/global-contribution.md'
10
+ /** the line a house index carries to load the generated context */
11
+ export const CONTEXT_INCLUDE = '@generated/global-context.md'
12
+ export const CONTRIBUTION_MAX_LINES = 40
13
+ export const CONTRIBUTION_MAX_BYTES = 3_000
14
+
15
+ /** what a new repo gets: a comment only, so it contributes nothing yet */
16
+ export const CONTRIBUTION_STUB = `<!-- global contribution: what this repo offers every agent in every house repo, such as the commands another repo can run and what they produce. The house concatenates every repo's file into one context loaded in every session, so keep it to what another repo's agent can act on: at most ${CONTRIBUTION_MAX_LINES} lines, no heading above ####. Empty contributes nothing. This comment is dropped. -->
17
+ `
18
+
19
+ /** the text that joins the context: html comments dropped, trimmed */
20
+ export const contributionBody = (text: string): string => text.replace(/<!--[\s\S]*?-->/g, '').trim()
21
+
22
+ /** whether a house index loads the generated context: only then does a
23
+ * contribution reach anyone */
24
+ export function loadsContext(index: string): boolean {
25
+ try {
26
+ return readFileSync(index, 'utf8').split('\n').some((l) => l.trim() === CONTEXT_INCLUDE)
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ /** why a body breaks the house context, or null: over its bounds, or a
33
+ * heading that would outrank the `### <repo>` it sits under */
34
+ export function contributionOver(body: string): string | null {
35
+ const lines = body === '' ? 0 : body.split('\n').length
36
+ const heading = body.replace(/^```[\s\S]*?^```/gm, '').split('\n').find((l) => /^#{1,3}\s/.test(l))
37
+ if (heading != null) return `holding the heading "${heading.slice(0, 40)}", above ####`
38
+ const bytes = Buffer.byteLength(body)
39
+ if (lines > CONTRIBUTION_MAX_LINES) return `${lines} lines, over the ${CONTRIBUTION_MAX_LINES} line cap`
40
+ if (bytes > CONTRIBUTION_MAX_BYTES) return `${bytes} bytes, over the ${CONTRIBUTION_MAX_BYTES} byte cap`
41
+ return null
42
+ }
@@ -0,0 +1,145 @@
1
+ // the house's generated context: every global process with the commands that
2
+ // drive it, every house repo, and each repo's global contribution, as ONE md
3
+ // file the house index includes, so every agent of every house repo loads it.
4
+ // It lives beside the index (`generated/global-context.md`), gitignored: its
5
+ // sources are the repos on this disk, so it is this machine's, never history.
6
+ //
7
+ // the one constraint a reader breaks first: it is in every session's context,
8
+ // so a contribution over its cap is cut here, and SK033 fails in its repo.
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
10
+ import { homedir } from 'os'
11
+ import { dirname, join } from 'path'
12
+ import { CONTRIBUTION_FILE, CONTRIBUTION_MAX_BYTES, CONTRIBUTION_MAX_LINES, contributionBody, contributionOver, loadsContext } from '../core/global-contribution.ts'
13
+ import { KEYWORD_FILE } from '../core/layout.ts'
14
+ import { evaluateLeak, globalKeywordFile, maskPattern, parsePatterns } from '../hooks/leak-guard.ts'
15
+ import { homeRepo, houseRepos } from '../serve/project-gutter.ts'
16
+ import { scanGlobalUnits, type GlobalProcessDecl } from './global-units.ts'
17
+
18
+ /** how often the daemon looks whether a source moved */
19
+ export const GLOBAL_CONTEXT_EVERY_MS = 60_000
20
+
21
+ export type ContextRepo = { name: string; path: string; description: string | null; contribution: string }
22
+ export type ContextProcess = Pick<GlobalProcessDecl, 'id' | 'label' | 'repo' | 'url' | 'description'>
23
+
24
+ /** the banned keyword a text matches, masked, or null: every item is
25
+ * scanned before it joins, since every session everywhere loads the file */
26
+ export type Leak = (text: string) => string | null
27
+
28
+ /** a repo description's longest form here: the rest is the repo's own */
29
+ export const DESCRIPTION_MAX = 160
30
+
31
+ /** where a house keeps it: beside its index */
32
+ export const globalContextPath = (index: string): string => join(dirname(index), 'generated', 'global-context.md')
33
+
34
+ /** a body cut to the caps, with the line that says so */
35
+ function capped(r: ContextRepo, body: string): string {
36
+ const over = contributionOver(body)
37
+ if (over == null) return body
38
+ // a heading above #### would outrank the `### <repo>` it sits under
39
+ let cut = body.replace(/^#{1,3}(?=\s)/gm, '####').split('\n').slice(0, CONTRIBUTION_MAX_LINES).join('\n')
40
+ while (Buffer.byteLength(cut) > CONTRIBUTION_MAX_BYTES) cut = cut.slice(0, cut.lastIndexOf('\n'))
41
+ return `${cut}\n\n(cut: ${r.name}/${CONTRIBUTION_FILE} is ${over}, and SK033 fails there until it fits)`
42
+ }
43
+
44
+ /** a description's first line, at most DESCRIPTION_MAX characters */
45
+ const short = (d: string): string => {
46
+ const l = d.split('\n')[0]!.trim()
47
+ return l.length <= DESCRIPTION_MAX ? l : `${l.slice(0, DESCRIPTION_MAX - 1)}…`
48
+ }
49
+
50
+ const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
51
+
52
+ /** a description's first line only: the rest (a login, a how to) is the
53
+ * hub's to show, never every session's */
54
+ const firstLine = (d: string | null): string => {
55
+ const l = d?.split('\n')[0]?.trim() ?? ''
56
+ return l === '' ? '' : `: ${cell(l)}`
57
+ }
58
+
59
+ /** the file's text. Pure: the same sources always give the same bytes, so a
60
+ * write happens only when a source moved */
61
+ export function globalContextText(o: { repos: ContextRepo[]; processes: ContextProcess[]; errors: string[]; leak?: Leak }): string {
62
+ const left: string[] = []
63
+ /** the item when it matches no banned keyword, else null and a line saying so */
64
+ const clean = (where: string, text: string): string | null => {
65
+ const hit = o.leak?.(text) ?? null
66
+ if (hit == null) return text
67
+ left.push(`- ${where}: matches the banned keyword ${hit}`)
68
+ return null
69
+ }
70
+ const out: string[] = [
71
+ '# global context: this machine, for every agent',
72
+ '',
73
+ `Generated by \`sk global-context --write\` from every house repo (the daemon rewrites it when a source moves). Never edit it: a repo adds to it through its own \`${CONTRIBUTION_FILE}\`.`,
74
+ '',
75
+ '## Global processes',
76
+ '',
77
+ ]
78
+ if (o.processes.length === 0) out.push('No house repo declares one.')
79
+ else {
80
+ out.push('Drive each by id: `sk gp start|stop|restart <id>` waits for its probe, `sk gp open <id> [--chrome|--app]` opens its url and starts it first when it is down, `sk gp` shows where each stands.', '')
81
+ out.push('| id | process | repo | url |', '|---|---|---|---|')
82
+ for (const p of [...o.processes].sort((a, b) => a.repo.localeCompare(b.repo) || a.id.localeCompare(b.id))) {
83
+ const row = clean(`${p.repo} process ${p.id}`, `| \`${p.id}\` | ${cell(p.label)}${firstLine(p.description)} | ${p.repo} | ${p.url ?? 'no url: start, stop, restart only'} |`)
84
+ if (row != null) out.push(row)
85
+ }
86
+ }
87
+ out.push('', '## House repos', '')
88
+ for (const r of o.repos) {
89
+ const line = clean(`${r.name} repo line`, `- **${r.name}** \`${r.path}\`${r.description == null ? '' : `: ${short(r.description)}`}`)
90
+ if (line != null) out.push(line)
91
+ }
92
+ const given = o.repos
93
+ .map((r) => ({ r, body: contributionBody(r.contribution) }))
94
+ .filter((x) => x.body !== '' && clean(`${x.r.name}/${CONTRIBUTION_FILE}`, x.body) != null)
95
+ out.push('', '## What each repo offers', '')
96
+ if (given.length === 0) out.push(`No repo contributes yet: each writes its own \`${CONTRIBUTION_FILE}\`.`)
97
+ for (const { r, body } of given) out.push(`### ${r.name}`, '', capped(r, body), '')
98
+ if (left.length > 0) out.push('', '## Left out', '', ...left)
99
+ if (o.errors.length > 0) out.push('', '## Could not read', '', ...o.errors.map((e) => `- ${e}`))
100
+ return `${out.join('\n').trimEnd()}\n`
101
+ }
102
+
103
+ /** one repo's line and contribution, read from disk */
104
+ export function contextRepo(r: { name: string; path: string }, home: string = homedir()): ContextRepo {
105
+ let description: string | null = null
106
+ try {
107
+ const d = (JSON.parse(readFileSync(join(r.path, 'package.json'), 'utf8')) as { description?: unknown }).description
108
+ if (typeof d === 'string' && d.trim() !== '') description = d.trim()
109
+ } catch { /* no package.json, or one that does not parse: no line */ }
110
+ const f = join(r.path, CONTRIBUTION_FILE)
111
+ return { name: r.name, path: r.path.startsWith(`${home}/`) ? `~${r.path.slice(home.length)}` : r.path, description, contribution: existsSync(f) ? readFileSync(f, 'utf8') : '' }
112
+ }
113
+
114
+ /** writes the text when it differs from what the file holds; true when it wrote */
115
+ export function writeIfChanged(path: string, text: string): boolean {
116
+ if (existsSync(path) && readFileSync(path, 'utf8') === text) return false
117
+ mkdirSync(dirname(path), { recursive: true })
118
+ writeFileSync(path, text)
119
+ return true
120
+ }
121
+
122
+ /** the leak scan over the machine's banned keywords and the house repo's own,
123
+ * the lists the commit leak guard reads; null when neither exists */
124
+ export function houseLeak(houseRoot: string): Leak | null {
125
+ const files = [globalKeywordFile(), join(houseRoot, KEYWORD_FILE)].filter((f): f is string => f != null && existsSync(f))
126
+ if (files.length === 0) return null
127
+ const patterns = parsePatterns(files.map((f) => readFileSync(f, 'utf8')).join('\n'))
128
+ return (text) => {
129
+ const hit = evaluateLeak([{ where: 'global context', content: text }], patterns, []).hits[0]
130
+ return hit == null ? null : maskPattern(hit.pattern)
131
+ }
132
+ }
133
+
134
+ /** THE computation, for the house the repo at `root` belongs to: where the
135
+ * file goes and what it holds. The cli and the daemon both call it. Null
136
+ * when `root` includes no house index, or one that does not load the file */
137
+ export async function houseContext(root: string, home: string = homedir()): Promise<{ path: string; text: string } | null> {
138
+ const h = houseRepos(homeRepo(root), home)
139
+ if (h.index == null || !loadsContext(h.index)) return null
140
+ const units = await scanGlobalUnits(h.repos.map((r) => ({ name: r.name, path: r.path })))
141
+ return {
142
+ path: globalContextPath(h.index),
143
+ text: globalContextText({ repos: h.repos.map((r) => contextRepo(r, home)), processes: units.processes, errors: units.errors, leak: houseLeak(h.home) ?? undefined }),
144
+ }
145
+ }
@@ -18,6 +18,7 @@ import type { Planner, PlanRow } from '../plan/planner.ts'
18
18
  import type { Widgets, WidgetView } from './widgets.ts'
19
19
  import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
20
20
  import { devRepos, InsightsCache, type Scope } from './insights.ts'
21
+ import { GLOBAL_CONTEXT_EVERY_MS, houseContext, writeIfChanged } from './global-context.ts'
21
22
  import { HouseRuns } from '../serve/house-runs.ts'
22
23
 
23
24
  /** how far back a repo's git page lists its commits */
@@ -126,6 +127,7 @@ export function homeRoutes(d: HomeDeps) {
126
127
  }
127
128
 
128
129
  let active = new Set<string>()
130
+ let contextAt = -Infinity
129
131
  if (d.plan != null) d.plan.active = (name) => active.has(name)
130
132
 
131
133
  async function tick(): Promise<void> {
@@ -133,6 +135,14 @@ export function homeRoutes(d: HomeDeps) {
133
135
  active = activeSet(rows, d.state.data.active, rows.find((r) => r.path === d.root)?.name ?? d.name)
134
136
  // the plan's clock: one look per tick, and it starts at most one batch
135
137
  d.plan?.tick()
138
+ // the house's generated context: rewritten only when a source moved,
139
+ // and never held on
140
+ if (Date.now() - contextAt >= GLOBAL_CONTEXT_EVERY_MS) {
141
+ contextAt = Date.now()
142
+ void houseContext(d.root)
143
+ .then((c) => { if (c != null && writeIfChanged(c.path, c.text)) console.log(`ok: wrote ${c.path}`) })
144
+ .catch((e) => console.error(`WARN the global context could not be written: ${(e as Error).message}`))
145
+ }
136
146
  // a widget render never holds the tick back
137
147
  void d.widgets?.tick(repos.map((r) => ({ name: r.name, path: r.path }))).catch((e) => console.error(`WARN the widgets broke: ${(e as Error).message}`))
138
148
  await d.hubs.tick(active)
@@ -41,6 +41,7 @@ import { autoGatePool, takeSlot } from '../core/slots.ts'
41
41
  import { liveJobs, startJob } from '../core/jobs.ts'
42
42
  import { followJob } from '../core/job-follow.ts'
43
43
  import { RUN_JOB } from '../home/home-words.ts'
44
+ import { RunLanes, runKey, treeStamp, writesTree } from './run-lanes.ts'
44
45
  import { OtherRepos } from './other-repos.ts'
45
46
  import { HouseRuns } from './house-runs.ts'
46
47
  import { recordStamp } from './project-gutter.ts'
@@ -185,9 +186,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
185
186
  const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
186
187
  // every run this process has going, whoever started it
187
188
  let active = 0
188
- // ONE run at a time, whoever asked: two gates over one tree would measure
189
- // the same checks twice and race each other's repair pass
190
- let chain: Promise<unknown> = Promise.resolve()
189
+ // every run, whoever asked: an identical one attaches, a repair pass goes
190
+ // alone, the rest overlap under the machine slot pool (run-lanes.ts)
191
+ const lanes = new RunLanes(() => treeStamp(root))
192
+ // the gate jobs this runtime started: one beside another is the lanes' call
193
+ const mine = new Set<string>()
191
194
  // the latest verdict of every check, printed as one problem frame after
192
195
  // each run for the vscode problems pane (problems/problem-line.ts)
193
196
  const board = new ProblemBoard()
@@ -215,13 +218,22 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
215
218
  }
216
219
  }
217
220
  // a job still running here, started before this image or by a terminal's --detach
218
- while (liveJobs(root).length > 0) await Bun.sleep(jobs.pollMs ?? 250)
221
+ const foreign = (): boolean => liveJobs(root).some((j) => !mine.has(j.id))
222
+ if (foreign()) tap.line('note: a gate job this hub did not start is running here, so this one starts when it ends')
223
+ while (foreign()) await Bun.sleep(jobs.pollMs ?? 250)
219
224
  const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background, ...(env == null ? {} : { env }) } })
225
+ mine.add(j.id)
220
226
  tap.job?.(j.id)
221
- return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
227
+ try {
228
+ return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
229
+ } finally {
230
+ mine.delete(j.id)
231
+ }
222
232
  }
223
233
  const runChecks: HubDeps['runChecks'] = (tap, o) => {
224
- const run = chain.then(async () => {
234
+ // only a whole gate the hub was asked for runs the repair pass (hubGateOpts)
235
+ const writes = o?.auto !== true && o?.gate === true && (o.only == null || o.only.length === 0)
236
+ return lanes.submit({ key: null, writes, print: tap.line }, async () => {
225
237
  active++
226
238
  // a run the hub starts on its own takes the machine's background
227
239
  // turn, once it is this hub's turn, never while it queues
@@ -243,27 +255,25 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
243
255
  for (const l of board.frame()) io.print(l)
244
256
  }
245
257
  })
246
- chain = run.catch(() => {})
247
- return run
248
258
  }
249
259
  // a `shipkit ci` or `shipkit check` from a terminal, run HERE (home/via-hub.ts):
250
- // in the same one-at-a-time chain as the hub's own runs, feeding the same
251
- // problems frame, recorded as the cli's
260
+ // in the same lanes as the hub's own runs, feeding the same problems
261
+ // frame, recorded as the cli's
252
262
  const runCli: HubDeps['runCli'] = (kind, opts, print) => {
253
- // never on a --json run: its output is the json and nothing else
254
- if ((active > 0 || (jobs != null && liveJobs(root).length > 0)) && opts.json !== true) print('note: this hub has a run in flight, so this one starts when it ends')
255
- const run = chain.then(async () => {
263
+ // a --json run prints the json and nothing else, so it never shares
264
+ const key = opts.json === true ? null : runKey(kind, opts)
265
+ // `check --fix` runs in process (home/via-hub.ts), so a check here never repairs
266
+ const writes = kind === 'ci' && writesTree(opts)
267
+ return lanes.submit({ key, writes, print, quiet: opts.json === true }, async (tee) => {
256
268
  active++
257
269
  try {
258
270
  await refresh()
259
- return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: print, check: onBoard, job: (id) => print(`${RUN_JOB}${id}`) }, false)
271
+ return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: tee, check: onBoard, job: (id) => tee(`${RUN_JOB}${id}`) }, false)
260
272
  } finally {
261
273
  active--
262
274
  for (const l of board.frame()) io.print(l)
263
275
  }
264
276
  })
265
- chain = run.catch(() => {})
266
- return run
267
277
  }
268
278
  const runIn = async (tap: CheckTap, o: Parameters<HubDeps['runChecks']>[1], background: boolean): Promise<number> => {
269
279
  const only = o?.only != null && o.only.length > 0 ? o.only : undefined
@@ -287,11 +297,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
287
297
  if (adopted.length > 0) {
288
298
  io.print(`ok: following ${adopted.length} gate job(s) still running: ${adopted.map((j) => j.id).join(', ')}`)
289
299
  active++
290
- const run = Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).finally(() => {
291
- active--
292
- for (const l of board.frame()) io.print(l)
293
- })
294
- chain = run.catch(() => {})
300
+ void lanes.submit({ key: null, writes: true, print: () => {} }, () =>
301
+ Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).then(() => 0).finally(() => {
302
+ active--
303
+ for (const l of board.frame()) io.print(l)
304
+ })).catch(() => {})
295
305
  }
296
306
  }
297
307
  // resolved once: it shells out to git, and the page asks on every poll