rvlib-shipkit 1.7.0 → 1.9.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 (44) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
  10. package/src/checks/SK033-global-contribution.check.ts +47 -0
  11. package/src/cli/cmd_Ci.ts +5 -4
  12. package/src/cli/cmd_GlobalContext.ts +47 -0
  13. package/src/cli/cmd_GlobalProcesses.ts +38 -1
  14. package/src/cli.ts +4 -1
  15. package/src/collections/providers.ts +1 -1
  16. package/src/core/check-runner.ts +13 -4
  17. package/src/core/discover-checks.ts +37 -8
  18. package/src/core/fix-scope.ts +5 -0
  19. package/src/core/floor-resolve.ts +2 -1
  20. package/src/core/git-root.ts +22 -12
  21. package/src/core/global-contribution.ts +42 -0
  22. package/src/core/one-lib.ts +4 -2
  23. package/src/core/run-ci.ts +12 -11
  24. package/src/core/slots.ts +36 -6
  25. package/src/home/global-context.ts +145 -0
  26. package/src/home/gui-app/ShipkitGui.swift +96 -0
  27. package/src/home/home-routes.ts +10 -0
  28. package/src/home/resources.ts +3 -0
  29. package/src/plan/planner.ts +21 -4
  30. package/src/plan/repo-source.ts +7 -3
  31. package/src/release/run-release.ts +17 -8
  32. package/src/release/unit-gate.ts +12 -5
  33. package/src/repo-config.ts +7 -0
  34. package/src/serve/hub-runtime.ts +34 -23
  35. package/src/serve/project-gutter.ts +5 -2
  36. package/src/serve/run-lanes.ts +138 -0
  37. package/src/smoke/run-smoke.ts +13 -6
  38. package/src/suite/judge-suites.ts +79 -11
  39. package/src/suite/reads-env.ts +8 -0
  40. package/src/suite/select.ts +510 -0
  41. package/src/suite/suite-cli.ts +213 -23
  42. package/src/suite/suite-reads-preload.ts +335 -0
  43. package/src/ts/typecheck-cache.ts +11 -0
  44. package/src/ts/typecheck-cli.ts +2 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,51 @@
1
+ ## 1.9.0
2
+
3
+ - **A commit in a linked worktree passes the pre-commit hook.** git sets GIT_DIR for a hook there, and the installed copy then asked git where the house sits and got the worktree back, so it never handed off to the house source. Then it ran itself and failed every built-in check with ENOENT on a `?v=` stamped path. A directory with its own `.git` is now asked with the locating variables stripped, and an installed copy that runs itself (CI, `SHIPKIT_NO_HOME`) loads its own check files.
4
+ - **A broken built-in check no longer aborts every repo's gate.** In a repo that only consumes the toolkit, a built-in that does not load or is refused is one non-blocking WARN that names the file. The repo that hosts the toolkit source still fails on it. Before, it threw a misleading "names the page that OWNS the invariant" error before any check ran.
5
+ - **`sk gp open <id>` probes before it trusts "up".** A process that died since pm last looked now starts before its window opens, instead of a window on a dead port.
6
+ - **A linked worktree of a house repo is no longer a house repo of its own.** The home, the global context and pm skip a sibling whose `.git` is a file, so its global processes no longer collide with the main checkout's ids and ports.
7
+ - **`sk ci --only <check>` leaves the tree alone.** A narrowed run no longer runs the fix pass unless `--fix` is named, so `bun run test`, `lint` and `typecheck`, which route through `--only`, never reformat a shared tree. Before, the formatter ran on every narrowed run and rewrote 478 files of one checkout, other agents' uncommitted work included. A whole gate still repairs first, and `--no-fix` still turns it off.
8
+
9
+ - **The machine pool admits fewer checks on a loaded machine.** A slot is a check, and a check is often a whole multi core tool, so eight slots on eight cores could put several times the cores to work. Past a 1 minute load of one per core the pool now admits slots in proportion to the load, never fewer than one: at load 20 on 8 cores it admits 3 of 8. A slot already held is never taken back. A waiting check prints the load and what the pool admits.
10
+
11
+ - **The plan holds save runs while someone gates the repo.** A check a save moved now also waits 10 minutes after the latest run a terminal, an agent or a click started in its repo. On one repo with several agents, most of the plan's test runs had started within those 10 minutes and measured a tree the agents were gating themselves. The run is held, not dropped, so the tree still gets one verdict once they stop. `sk planned` shows the hold as a `deferred:` line.
12
+ - **A suite over the CPU budget in its lone run runs alone once more, and the smaller figure stands.** On a busy Apple silicon machine the scheduler can spill a lone run onto the efficiency cores, where the same work burns up to 4x the CPU time, so a suite inside its budget failed one run in three and passed a minute later. A spill only raises CPU time, so the second run can clear a false red and never hide a real one. A wait or a suite with no CPU figure keeps its one run, and a green gate pays nothing.
13
+
14
+ - **The gate's test line says the command the suite runner ran.** A plain `bun test` body does not run as written under `sk ci`: the runner adds `--parallel=<n>` and `--no-isolate`. The line now reads `ok: test 412 tests (ran 1.5s) as \`bun test --parallel=7 --no-isolate\` (added --parallel=7 --no-isolate)`, so a reader of package.json knows that `bun run test` outside the gate runs another way. Per file ignores stay out of it.
15
+
16
+ - **A test phase killed at its ceiling names the suite that hung, and both things that hang a worker.** bun lists the suites still running when a phase is killed, and the parallel phase now reads that list, so the line says `with tests/x.test.ts still running`. It names a synchronous loop and `expect(p).resolves` or `.rejects` on a promise that never settles: bun's per test timeout stops neither, while a plain `await` of such a promise fails at the per test timeout. And a red suite is no longer judged on time: its time is its failure's, so a test that timed out no longer runs again alone and no longer adds a budget FAIL.
17
+ - **The gate typecheck keeps its own incremental cache, and a release can publish with bun.** The built-in typecheck passes `--incremental --tsBuildInfoFile <project dir>/node_modules/.cache/shipkit/<config>.tsbuildinfo` for every project, so a `tsc --watch` task or a raw script on the same tsconfig no longer writes the cache the gate reads. A config without `incremental` gets it too: rvlib-shipkit's own typecheck went from 0.81 s to 0.35 s warm. `release.publisher: 'bun'` makes `sk release` and `sk bump` run `bun publish --access public`, with the token from `release.npmTokenCommand` in `NPM_CONFIG_TOKEN`, for a repo that tests its tarball with `bun pm pack`. The default stays npm, and an unknown publisher fails `defineRepo`.
18
+
19
+ - **`alert`, `confirm` and `prompt` work in the shipkit app and in every process app.** The app window answered each one at once with no panel: `confirm` returned false and `prompt` null, so a page button behind one did nothing. Each now shows as a sheet on its window. The prompt's text field holds the default text, selected. Return is OK and Esc is Cancel. An app picks it up the next time `sk gui` or `sk gp open <id> --app` opens it, because a changed source rebuilds the app and quits the running copy.
20
+
21
+ - **The `prompt` and `seed` vars contract binds only the workflows a kit kind drives.** Loading the workflows folder no longer refuses a module that lacks them, so a workflow the repo drives with its own tools (portraits, voice, music) names its vars freely. The check runs when the atelier resolves the workflow for a kind, and the error names that kind.
22
+
23
+ - **`sk assets gen` works on a repo whose comfy-ts is newer than shipkit's.** The one comfy-ts instance of the process is now created through the copy the repo's workflow modules import, so a module gets the vars builder of its own version. Before, shipkit's older copy rooted it, and a module using a newer var kind failed every roll (`v.model is not a function`).
24
+
25
+ - **The og card draws accented names.** The default card names a font family list (Helvetica, Arial, DejaVu Sans, Liberation Sans, Noto Sans, then sans-serif). The generic sans-serif alone resolved on macOS to a face with no Latin-1 accents, so a French name rendered with a missing glyph box.
26
+
27
+ - **`sk smoke` serves canvas apps: a route takes `minChars`**, the visible text a settled route needs (default 40). A game that draws into one canvas declares `{ path: '/', waitFor: 'canvas', minChars: 0 }`, and a missing canvas still fails the route.
28
+
29
+ - **`serve: { assets: false }` hides the asset kit from the hub page and from `sk needs`**, for a repo whose own tool mounts `studioRoutes` and owns the picking. Workflows and posts stay.
30
+
31
+ - **`defineKit({ rollFormat: 'webp-lossless' })` stores new image rolls as lossless webp**, the same pixels at about 60 percent of the png bytes, encoded with sharp. Existing png rolls stay readable beside them.
32
+
33
+ ## 1.8.0
34
+
35
+ - **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.
36
+
37
+ - **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.
38
+
39
+ - **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.
40
+
41
+ - **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.
42
+
43
+ - **`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.
44
+
45
+ - **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.
46
+
47
+ - **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.
48
+
1
49
  ## 1.7.0
2
50
 
3
51
  - **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.9.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",
@@ -40,6 +40,19 @@ const varsOf = (wf: DefinedWorkflow): Record<string, AnyVar> => wf.vars as Recor
40
40
  export const isRollImage = (img: Pick<MediaImage, 'ComfyNode'>): boolean =>
41
41
  !/preview/i.test(img.ComfyNode?.class_type ?? '')
42
42
 
43
+ /** the ComfyTS class of the comfy-ts a bare import from `dir` reaches,
44
+ * shipkit's own when the repo carries none */
45
+ async function comfyTsOf(dir: string): Promise<typeof ComfyTS> {
46
+ let path: string
47
+ try {
48
+ path = Bun.resolveSync('comfy-ts', dir)
49
+ } catch {
50
+ return ComfyTS
51
+ }
52
+ const mod = (await import(path)) as { ComfyTS?: typeof ComfyTS }
53
+ return typeof mod.ComfyTS?.create === 'function' ? mod.ComfyTS : ComfyTS
54
+ }
55
+
43
56
  /** dir = the flat folder holding the <id>.cflow.ts workflow modules.
44
57
  * root pins `.comfy-ts/` (drafts, hosts cache) to the repo, cwd-independent. */
45
58
  export async function loadCflowWorkflows(root: string, dir: string): Promise<Map<string, LoadedWorkflow>> {
@@ -51,9 +64,12 @@ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map
51
64
  // module then returns this same instance. comfy-ts keeps ONE instance per
52
65
  // process, and the daemon serves every repo from one process, so only the
53
66
  // first repo roots it. What it roots is the host caches, shared by every
54
- // repo on the same host; each repo's drafts resolve from its own root
55
- if ((globalThis as { comfyts?: unknown }).comfyts == null) ComfyTS.create({ rootPath: root })
67
+ // repo on the same host; each repo's drafts resolve from its own root.
68
+ // The instance is created through the copy the modules import, never
69
+ // shipkit's own: a host hands its vars builder to defineWorkflow, so an
70
+ // older copy gives a newer module a v without its newer var kinds
56
71
  if (!existsSync(dir)) fail(`${dir}/ missing — ship at least one <id>.cflow.ts comfy-ts workflow module (copy from the rv-comfy template)`)
72
+ if ((globalThis as { comfyts?: unknown }).comfyts == null) (await comfyTsOf(dir)).create({ rootPath: root })
57
73
  const out = new Map<string, LoadedWorkflow>()
58
74
  for (const f of readdirSync(dir).filter((x) => x.endsWith('.cflow.ts')).sort()) {
59
75
  const file = join(dir, f)
@@ -65,24 +81,25 @@ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map
65
81
  // so letting them diverge would strand every per-asset draft
66
82
  if (wf.spec.id != null && wf.spec.id !== id) fail(`workflow file '${f}' declares id '${wf.spec.id}' — the file name IS the id, rename one`)
67
83
  wf.spec.id ??= id
68
- const vars = varsOf(wf)
69
- if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
70
- fail(`workflow '${id}' must declare vars.prompt (v.prompt) and vars.seed (v.seed) — shipkit drives both`)
71
84
  out.set(id, { id, root, file, wf })
72
85
  }
73
86
  if (out.size === 0) fail(`${dir}/ has no *.cflow.ts workflow modules — seed one from the rv-comfy template`)
74
87
  return out
75
88
  }
76
89
 
77
- /** Resolve a workflow: explicit id, else the sole loaded workflow. */
78
- export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workflow?: string; assetId?: string }): LoadedWorkflow {
79
- if (ref.workflow != null) {
80
- const lw = workflows.get(ref.workflow)
81
- if (lw == null) fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`)
82
- return lw
83
- }
84
- if (workflows.size === 1) return [...workflows.values()][0]!
85
- return fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
90
+ /** Resolve the workflow a kit kind drives: explicit id, else the sole loaded
91
+ * workflow. The atelier writes prompt and seed, so the contract binds here,
92
+ * never at folder load: a workflow no kind drives names its vars freely. */
93
+ export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workflow?: string; kind?: string; assetId?: string }): LoadedWorkflow {
94
+ const lw = ref.workflow != null
95
+ ? (workflows.get(ref.workflow) ?? fail(`asset '${ref.assetId ?? '?'}': unknown workflow '${ref.workflow}' (have: ${[...workflows.keys()].join(', ')})`))
96
+ : workflows.size === 1
97
+ ? [...workflows.values()][0]!
98
+ : fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
99
+ const vars = varsOf(lw.wf)
100
+ if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
101
+ fail(`kind '${ref.kind ?? '?'}' drives workflow '${lw.id}', which must declare vars.prompt (v.prompt) and vars.seed (v.seed) — the atelier writes both`)
102
+ return lw
86
103
  }
87
104
 
88
105
  /** Apply a name→value record onto a workflow's vars: strings go through
@@ -4,9 +4,9 @@
4
4
  // in both (before this, canvas draw lived only in the studio and the CLI
5
5
  // refused canvas kinds — A30). Comfy context is resolved LAZILY: canvas-only
6
6
  // kits never touch a comfy config or the box.
7
- import { mkdirSync } from 'fs'
7
+ import { mkdirSync, unlinkSync } from 'fs'
8
8
  import { join } from 'path'
9
- import type { FoundAsset } from '../kit-config.ts'
9
+ import type { FoundAsset, RollFormat } from '../kit-config.ts'
10
10
  import { generateComfyTs, loadCflowWorkflows, workflowFor, type ComfyTsRuntime, type OnProgress } from './comfyts-gen.ts'
11
11
  import { recordParents, type ParentContext } from './derived.ts'
12
12
  import { generateGemini } from './gemini-gen.ts'
@@ -16,11 +16,22 @@ export const CANVAS_SIZE = 1024
16
16
 
17
17
  export type ComfyResolver = () => Promise<ComfyTsRuntime>
18
18
 
19
+ /** re-encode freshly written png rolls in the kit's roll format, before any
20
+ * sidecar names them. `exact` keeps the colour under transparent pixels */
21
+ async function encodeRolls(saved: string[], format: RollFormat | undefined): Promise<void> {
22
+ if (format !== 'webp-lossless') return
23
+ const { default: sharp } = await import('sharp')
24
+ for (const png of saved.filter((p) => p.endsWith('.png'))) {
25
+ await sharp(png).webp({ lossless: true, exact: true }).toFile(png.replace(/\.png$/, '.webp'))
26
+ unlinkSync(png)
27
+ }
28
+ }
29
+
19
30
  /** Draw/generate one roll for `found` into destDir as v<seed>.png (canvas) or
20
31
  * v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
21
32
  export async function generateRoll(
22
33
  found: FoundAsset,
23
- args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress; context?: ParentContext },
34
+ args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress; context?: ParentContext; rollFormat?: RollFormat },
24
35
  resolveComfy: ComfyResolver,
25
36
  ): Promise<void> {
26
37
  const gen = found.kind.generate
@@ -31,7 +42,7 @@ export async function generateRoll(
31
42
  // resolves them (queue/CLI) so a blocker names the parent, loud
32
43
  if ((found.asset.from?.length ?? 0) > 0 && args.context == null)
33
44
  throw new Error(`'${found.asset.id}' derives from ${found.asset.from!.join(', ')} — parent context missing (are the parents picked?)`)
34
- await generateGemini({}, { prompt: args.prompt, model: gen.model, destDir: args.destDir, name: `v${args.seed}`, context: args.context?.images ?? [] })
45
+ await encodeRolls(await generateGemini({}, { prompt: args.prompt, model: gen.model, destDir: args.destDir, name: `v${args.seed}`, context: args.context?.images ?? [] }), args.rollFormat)
35
46
  if (args.context != null) await recordParents(args.destDir, args.seed, args.context.parents)
36
47
  return
37
48
  }
@@ -40,12 +51,14 @@ export async function generateRoll(
40
51
  const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
41
52
  await gen.draw(c.getContext('2d'), { ...found.asset, prompt: args.prompt })
42
53
  mkdirSync(args.destDir, { recursive: true })
43
- await Bun.write(join(args.destDir, `v${args.seed}.png`), c.toBuffer('image/png'))
54
+ const png = join(args.destDir, `v${args.seed}.png`)
55
+ await Bun.write(png, c.toBuffer('image/png'))
56
+ await encodeRolls([png], args.rollFormat)
44
57
  return
45
58
  }
46
59
  const rt = await resolveComfy()
47
- const lw = workflowFor(rt.workflows, { workflow: gen.workflow, assetId: found.asset.id })
48
- await generateComfyTs(rt, lw, { prompt: args.prompt, seed: args.seed, assetId: found.asset.id, draftId: found.kind.id, destDir: args.destDir, name: `v${args.seed}`, vars: gen.vars, onProgress: args.onProgress })
60
+ const lw = workflowFor(rt.workflows, { workflow: gen.workflow, kind: found.kind.id, assetId: found.asset.id })
61
+ await encodeRolls(await generateComfyTs(rt, lw, { prompt: args.prompt, seed: args.seed, assetId: found.asset.id, draftId: found.kind.id, destDir: args.destDir, name: `v${args.seed}`, vars: gen.vars, onProgress: args.onProgress }), args.rollFormat)
49
62
  }
50
63
 
51
64
  /** Memoized lazy comfy runtime for a resolved kit — loads the cflow modules on
@@ -127,14 +127,23 @@ export type KitConfig = {
127
127
  /** workflows = dir of comfy-ts .cflow.ts modules (each module owns its
128
128
  * host); heal = shell command shown in error hints, never executed */
129
129
  comfy?: { workflows: string; heal?: string }
130
+ /** absent: an image roll lands in the format its backend produced (png
131
+ * from comfy and canvas). 'webp-lossless' re-encodes every new image
132
+ * roll as lossless webp, the same pixels at about 60% of the bytes */
133
+ rollFormat?: RollFormat
130
134
  kinds: Kind[]
131
135
  }
132
136
 
137
+ export const ROLL_FORMATS = ['webp-lossless'] as const
138
+ export type RollFormat = (typeof ROLL_FORMATS)[number]
139
+
133
140
  export type ResolvedKit = KitConfig & { workspace: string }
134
141
 
135
142
  export function validateKit(k: KitConfig): string[] {
136
143
  const errs: string[] = []
137
144
  if (!Array.isArray(k.kinds) || k.kinds.length === 0) errs.push('kinds must be a non-empty array')
145
+ if (k.rollFormat != null && !(ROLL_FORMATS as readonly string[]).includes(k.rollFormat))
146
+ errs.push(`unknown rollFormat '${String(k.rollFormat)}' (${ROLL_FORMATS.join(' | ')}, or absent for the backend's own format)`)
138
147
  for (const key of Object.keys(k.comfy ?? {}))
139
148
  if (key !== 'workflows' && key !== 'heal')
140
149
  errs.push(`comfy config has unknown key '${key}'${key === 'host' ? " — host is gone, the workflow's _host.ts owns the box (comfy-ts migration)" : ''}`)
@@ -51,10 +51,16 @@ export function identityKinds(brand: Brand): Kind[] {
51
51
  ]
52
52
  }
53
53
 
54
+ // named faces first: the generic sans-serif can resolve to a face with no
55
+ // Latin-1 accents (macOS). Each family below carries them, and an absent one
56
+ // is skipped (macOS, Windows, then the common Linux faces)
57
+ const OG_FAMILIES = 'Helvetica, Arial, "DejaVu Sans", "Liberation Sans", "Noto Sans", sans-serif'
58
+ export const ogFont = (px: number, bold = false): string => `${bold ? 'bold ' : ''}${px}px ${OG_FAMILIES}`
59
+
54
60
  // shrink px until text fits maxW (og cards must never clip the tagline)
55
61
  const fitFont = (ctx: SKRSContext2D, text: string, px: number, maxW: number, bold = false): void => {
56
62
  for (; px > 12; px -= 2) {
57
- ctx.font = `${bold ? 'bold ' : ''}${px}px sans-serif`
63
+ ctx.font = ogFont(px, bold)
58
64
  if (ctx.measureText(text).width <= maxW) return
59
65
  }
60
66
  }
@@ -50,7 +50,7 @@ async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveCom
50
50
  for (let i = 0; i < count; i++) {
51
51
  const seed = randomSeed()
52
52
  try {
53
- await generateRoll(f!, { prompt, seed, destDir: dir, context }, resolveComfy)
53
+ await generateRoll(f!, { prompt, seed, destDir: dir, context, rollFormat: kit.rollFormat }, resolveComfy)
54
54
  io.print(`ok: ${assetId} v${seed}`)
55
55
  } catch (e) {
56
56
  failed++
@@ -196,7 +196,7 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
196
196
  const gen = kind.generate
197
197
  if (gen.backend !== 'comfy') continue
198
198
  try {
199
- const lw = workflowFor(wfs, { workflow: gen.workflow, assetId: kind.assets[0]?.id })
199
+ const lw = workflowFor(wfs, { workflow: gen.workflow, kind: kind.id, assetId: kind.assets[0]?.id })
200
200
  if (gen.vars != null) {
201
201
  for (const [, d] of lw.wf.entries()) d.reset()
202
202
  applyVarValues(lw.wf, gen.vars, `kind '${kind.id}'`)
@@ -215,7 +215,7 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
215
215
  for (const f of allAssets(kit).filter((x) => x.kind.generate.backend === 'comfy')) {
216
216
  let lw: LoadedWorkflow
217
217
  try {
218
- lw = workflowFor(wfs, { workflow: (f.kind.generate as ComfyGen).workflow, assetId: f.asset.id })
218
+ lw = workflowFor(wfs, { workflow: (f.kind.generate as ComfyGen).workflow, kind: f.kind.id, assetId: f.asset.id })
219
219
  } catch {
220
220
  continue // an unresolvable workflow already FAILed on its kind above
221
221
  }
@@ -110,7 +110,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
110
110
  for (const f of allAssets(kit).filter((x) => x.kind.generate.backend === 'comfy')) {
111
111
  try {
112
112
  const gen = f.kind.generate as { workflow?: string }
113
- const lw = workflowFor(workflows, { workflow: gen.workflow, assetId: f.asset.id })
113
+ const lw = workflowFor(workflows, { workflow: gen.workflow, kind: f.kind.id, assetId: f.asset.id })
114
114
  // the KIND's draft, shared by every asset of that kind
115
115
  if (ensureAssetDraft(lw, f.kind.id, await promptOf(f))) console.log(`ok: draft '${f.kind.id}' seeded → .comfy-ts/drafts/${lw.id}/${f.kind.id}.json`)
116
116
  } catch (e) {
@@ -127,7 +127,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
127
127
  return { workflows, heal: kit.comfy.heal }
128
128
  }
129
129
  const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
130
- generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
130
+ generateRoll(found, { prompt, seed, destDir, onProgress, context, rollFormat: kit.rollFormat }, resolveComfy))
131
131
 
132
132
  const queue = new StudioQueue(async (job, seed, onProgress) => {
133
133
  const found = findAsset(kit, job.assetId)
@@ -388,7 +388,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
388
388
  const rgen = running?.kind.generate
389
389
  if (workflows != null && rgen?.backend === 'comfy') {
390
390
  try {
391
- await workflowFor(workflows, { workflow: rgen.workflow, assetId: running!.asset.id }).wf.host.interrupt()
391
+ await workflowFor(workflows, { workflow: rgen.workflow, kind: running!.kind.id, assetId: running!.asset.id }).wf.host.interrupt()
392
392
  } catch (e) {
393
393
  queue.pushError(`interrupt: ${(e as Error).message} — current roll will finish, its job is still dropped`)
394
394
  }
@@ -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
package/src/cli/cmd_Ci.ts CHANGED
@@ -20,7 +20,8 @@ export class CiCommand extends Command {
20
20
  ['gate without the repair pass (checks can still write)', 'sk ci --no-fix'],
21
21
  ['leave repo config files alone', 'sk ci --no-fix-rules'],
22
22
  ['stop at the first failure', 'sk ci --bail'],
23
- ['one check + its dep closure', 'sk ci --only typecheck'],
23
+ ['one check + its dep closure, the tree left alone', 'sk ci --only typecheck'],
24
+ ['one check, repairing the mechanical slots first', 'sk ci --only typecheck --fix'],
24
25
  ['every check declaring one group', 'sk ci --only wiki'],
25
26
  ['run everything, take nothing from the cache', 'sk ci --fresh'],
26
27
  ['include an opt in check', 'sk ci --with up:itch'],
@@ -31,12 +32,12 @@ export class CiCommand extends Command {
31
32
 
32
33
  bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
33
34
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
34
- fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, the checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
35
+ fix = Option.Boolean('--fix', { description: 'repair the mechanical slots before gating. Default on for a whole gate and off for a --only run, which leaves the tree alone unless --fix is named. --no-fix skips the repair pass, the checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
35
36
  fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural autofixes of the built-in checks too. Default on. --no-fix-rules leaves repo config alone' })
36
37
  frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining checks: SK005 never touches package.json, and generated paths are never untracked. STATUS.md is still refreshed on disk, but it is gitignored, so a run leaves the tree clean' })
37
38
  skip = Option.Array('--skip', [], { description: 'skip a check group (repeatable)' })
38
39
  withIds = Option.Array('--with', [], { description: 'include a check the quick gate leaves out (repeatable)' })
39
- only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable). A group name runs every check declaring that group' })
40
+ only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable). A group name runs every check declaring that group. No repair pass unless --fix is named' })
40
41
  fresh = Option.Boolean('--fresh', false, { description: 'run every check: no answer comes from the cache, and what this run finds is stored fresh' })
41
42
  full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
42
43
 
@@ -68,7 +69,7 @@ export class CiCommand extends Command {
68
69
  return [
69
70
  ...(this.bail ? ['--bail'] : []),
70
71
  ...(this.json ? ['--json'] : []),
71
- ...(this.fix ? [] : ['--no-fix']),
72
+ ...(this.fix === true ? ['--fix'] : this.fix === false ? ['--no-fix'] : []),
72
73
  ...(this.fixRules ? [] : ['--no-fix-rules']),
73
74
  ...(this.frozen ? ['--frozen'] : []),
74
75
  ...this.skip.flatMap((v) => ['--skip', v]),
@@ -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)
@@ -413,7 +413,7 @@ export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit |
413
413
  contractsCollection(root, c),
414
414
  asksCollection(root, c),
415
415
  credsCollection(root, c, { verdicts: () => verdictsOf(checks) }),
416
- mediaKitCollection(root, kit),
416
+ mediaKitCollection(root, c.serve?.assets === false ? null : kit),
417
417
  socialPostsCollection(root),
418
418
  reflectionsCollection(root),
419
419
  testsCollection(root, c),
@@ -248,6 +248,12 @@ export function testsRan(lastLine: string): number | null {
248
248
  return m == null ? null : Number(m[1])
249
249
  }
250
250
 
251
+ /** the command the suite runner says it ran, read off the same headline
252
+ * (`…, as \`bun test --parallel=8\` (added --parallel=8)`, judge-suites ranAs) */
253
+ export function testsCommand(lastLine: string): string | null {
254
+ return /, (as `[^`]+`(?: \(added [^)]+\))?)/.exec(lastLine)?.[1] ?? null
255
+ }
256
+
251
257
  export type Selection = {
252
258
  /** what this run measures */
253
259
  selected: RunnableCheck[]
@@ -580,9 +586,12 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
580
586
  const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
581
587
  const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
582
588
  const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
589
+ // and the command the suite runner ran, which can differ from the script's body
590
+ const how = counted ? testsCommand(u.lastLine) : null
591
+ const as = how == null ? '' : ` ${how}`
583
592
  const composed = u.synthetic && !u.replayed && head != null
584
- ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}` }, ...u.lines.slice(1)]
585
- : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
593
+ ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}${as}` }, ...u.lines.slice(1)]
594
+ : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}${as}` }, ...u.lines.slice(1)] : u.lines
586
595
  // every line says where its answer came from: a replay carries its
587
596
  // cached stamp already, so one that ran here says so and what it cost
588
597
  const shown = u.synthetic || u.replayed || u.attached || u.startedAt === 0 || composed[0] == null
@@ -798,11 +807,11 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
798
807
  // attached wait runs nothing, so it holds none
799
808
  const slot = pool == null ? null : await takeSlot(pool, { repo: o.config.name, check: u.id }, {
800
809
  abort: () => interrupted || bailed,
801
- onWait: (held) => {
810
+ onWait: (held, why) => {
802
811
  if (slotWaitSaid) return
803
812
  slotWaitSaid = true
804
813
  const who = held.slice(0, 3).map((h) => `${h.repo}/${h.check} pid ${h.pid}`).join(', ')
805
- emit({ kind: 'ok', text: `waiting for a machine slot: ${held.length} of ${pool.size} held${who === '' ? '' : ` (${who}${held.length > 3 ? ', …' : ''})`}` })
814
+ emit({ kind: 'ok', text: `waiting for a machine slot: ${held.length} held, ${why}${who === '' ? '' : ` (${who}${held.length > 3 ? ', …' : ''})`}` })
806
815
  },
807
816
  })
808
817
  if (pool != null && slot == null) { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = []; u.unmeasured = true; return }