rvlib-shipkit 1.5.0 → 1.6.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 (78) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +12 -8
  4. package/src/assets/server/studio-server.ts +14 -2
  5. package/src/check.ts +5 -8
  6. package/src/checks/SK001-package-json-shape.check.ts +5 -4
  7. package/src/checks/SK003-claude-md-shape.check.ts +2 -2
  8. package/src/checks/SK030-cache-audit.check.ts +34 -0
  9. package/src/checks/SK031-trace-audit.check.ts +39 -0
  10. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  11. package/src/cli/cmd_Assets.ts +10 -6
  12. package/src/cli/cmd_CacheAudit.ts +43 -0
  13. package/src/cli/cmd_CrossRepoActivity.ts +2 -1
  14. package/src/cli/cmd_GlobalProcesses.ts +4 -3
  15. package/src/cli/cmd_Home.ts +3 -2
  16. package/src/cli/cmd_Keys.ts +12 -1
  17. package/src/cli/cmd_Overview.ts +1 -1
  18. package/src/cli/cmd_Serve.ts +2 -1
  19. package/src/cli/cmd_TraceAudit.ts +62 -0
  20. package/src/cli.ts +4 -0
  21. package/src/collections/collection-types.ts +3 -0
  22. package/src/collections/providers.ts +5 -3
  23. package/src/core/app-icon.ts +154 -0
  24. package/src/core/audit-report.ts +21 -0
  25. package/src/core/cache-audit.ts +72 -0
  26. package/src/core/canonical-scripts.ts +1 -1
  27. package/src/core/check-ctx.ts +14 -5
  28. package/src/core/check-runner.ts +6 -3
  29. package/src/core/floor-resolve.ts +5 -7
  30. package/src/core/git-root.ts +13 -0
  31. package/src/core/ignored-block.ts +94 -0
  32. package/src/core/ignored-inputs.ts +47 -0
  33. package/src/core/jobs.ts +9 -2
  34. package/src/core/one-lib.ts +59 -4
  35. package/src/core/read-set.ts +97 -9
  36. package/src/core/run-ci.ts +6 -1
  37. package/src/core/run-fixers.ts +8 -2
  38. package/src/core/trace-audit.ts +120 -0
  39. package/src/home/gui-app.ts +24 -27
  40. package/src/home/home-start.ts +11 -5
  41. package/src/home/open-app.ts +2 -2
  42. package/src/home/via-hub.ts +14 -0
  43. package/src/ledger/db.ts +2 -13
  44. package/src/ledger/gate-checks.ts +21 -3
  45. package/src/ledger/git-key.ts +148 -30
  46. package/src/ledger/git-scan.ts +5 -2
  47. package/src/ledger/ledger.ts +131 -16
  48. package/src/ledger/store.ts +14 -0
  49. package/src/ledger/toolchain-identity.ts +210 -25
  50. package/src/problems/extract.ts +3 -0
  51. package/src/repo-config.ts +18 -1
  52. package/src/serve/hub-app/admin-svc.ts +36 -3
  53. package/src/serve/hub-app/admin-view.tsx +33 -1
  54. package/src/serve/hub-app/col-card.tsx +1 -44
  55. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  56. package/src/serve/hub-app/col-tables.ts +27 -0
  57. package/src/serve/hub-app/home-st.ts +1 -1
  58. package/src/serve/hub-app/home-strip.css +5 -9
  59. package/src/serve/hub-app/home-strip.tsx +4 -17
  60. package/src/serve/hub-app/hub-app.tsx +18 -4
  61. package/src/serve/hub-app/hub.css +24 -20
  62. package/src/serve/hub-app/keys.ts +10 -2
  63. package/src/serve/hub-app/processes-view.tsx +16 -9
  64. package/src/serve/hub-app/project-list.css +4 -1
  65. package/src/serve/hub-app/project-list.tsx +6 -3
  66. package/src/serve/hub-app/projects-svc.ts +10 -0
  67. package/src/serve/hub-app/repo-sections.ts +31 -17
  68. package/src/serve/hub-app/repo-st.ts +24 -5
  69. package/src/serve/hub-app/sidebar.tsx +5 -5
  70. package/src/serve/hub-app/tree-build.ts +2 -0
  71. package/src/serve/hub-app/tree-row.tsx +18 -5
  72. package/src/serve/hub-app/tree-svc.ts +27 -7
  73. package/src/serve/hub-runtime.ts +4 -2
  74. package/src/serve/project-gutter-route.ts +22 -1
  75. package/src/suite/judge-suites.ts +6 -0
  76. package/src/trace/interpose.c +655 -0
  77. package/src/trace/read-trace.ts +485 -0
  78. package/src/trace/tracer.ts +282 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,49 @@
1
+ ## 1.6.0
2
+
3
+ - **lint, typecheck, test and every task that declares nothing are cached now.** Such a task is keyed on the whole work tree, plus the gitignored paths the new `ignoredInputs` lists for it (`{ test: ['dist/**'] }`, `'*'` for every task; a path by content, `dir/**` by the names under it), and the lockfile, the installed packages, the bun and node versions and the toolkit code behind it. `sk trace-audit` runs each gate task once under a file tracer and names the gitignored paths it reads that its key misses. Tracing is on wherever it can run (Apple silicon macOS): SK031 audits about daily, `sk trace-audit --disable` opts a machine out and `SHIPKIT_TRACE=0` one run, and each finding joins the key of the task that read it at once, in every worktree of the repo. SK032 and the fix pass of `sk ci` then write the findings into their own block of `repo.config.ts`, once the repo pins 1.6.0 or later. The tracer never decides a verdict. It needs Apple silicon macOS: on Linux and in CI nothing audits, and SK030's daily sample is the only net there.
4
+
5
+ - **Every check that did not replay says why**, one line per reason: `ran: src/a.ts changed (lint, typecheck)`, `ran: nothing stored for it yet`, `ran, not kept: it reached the world through …`. A check its author declared never cached says so only under `--verbose`.
6
+
7
+ - **A cache key carries the code behind the answer, not the whole toolkit.** A check is keyed on its own module graph and the runner's, and a repo task on the toolkit modules the repo's config imports, so a toolkit edit re-runs only the checks it can change.
8
+
9
+ - **A command loads the image and comfy libraries only when it runs**: `sk --help` went from 540 ms to 220 ms.
10
+
11
+ - **SK030 audits the cache.** About once a day, while somebody works in the repo, it re-runs a sample of the checks the cache would answer right now, fresh, and compares. A cached green that a fresh run disagrees with fails, naming the check whose key misses a path its body reads, and the stale answer is dropped so it is never served again. `sk cache-audit` is the same audit on demand (`--sample <n>`, `--only <id>`, `--json`).
12
+
13
+ ## Unreleased (patch)
14
+
15
+ - **The hub tree groups assets by kit category.** Under media, each category is a row that folds, and its label opens the category page. An asset row reads by its kit label (`mon/brindon/front`), never its file id.
16
+
17
+ - **SK001 no longer asks a comfy repo for a `comfy:up` script.** The box is a global process that `sk gp start comfyui` starts from any repo, so a per-repo script only copied that command. A `.shipkit/comfy/` folder now asks for nothing, and SK001 no longer probes it.
18
+
19
+ - **Every repo's tree has the same top rows.** A section the repo has nothing in (no process, no contract, no secret, no asks, no reflections, no tests, no media, no wiki) stays in the list, greyed, its title saying why. The processes row still opens its page, which says the repo declares none. The others open nothing, and the arrows step over them. Plan, git and admin still exist only on the home.
20
+
21
+ - **Every command starts faster.** A command that draws images or talks to ComfyUI loads canvas, sharp and comfy-ts when it runs, instead of every command loading them at startup (canvas alone read the system fonts). A fully cached `sk ci` drops from about 1.2s to 0.7s, `sk --help` from 540ms to 220ms.
22
+
23
+ - **Every check that did not come from the cache says why.** Above the verdict, one line per reason: `ran: src changed (lint, test)` names what moved since the last stored answer, `ran: nothing stored for it yet`, `ran, not kept: it reached the world through \`git check-ignore -q\`` for an answer that can never be stored, and `never cached: <reason>` for a check no key describes. A check that can never be kept was counted like a key that moved.
24
+
25
+ - **Every repo's media page loads its comfy workflows, not only the first one's.** comfy-ts keeps one instance per process, rooted at one repo, and the daemon serves every repo from one process: the second repo to load its workflows got "conflicts with the existing instance". The instance is now the first repo's, holding only the host caches every repo on that host shares, and each repo's drafts resolve under its own `.comfy-ts/drafts/`.
26
+
27
+ - **Editing shipkit no longer re-runs every check in every repo.** The cache key hashed the toolkit's whole source, and a repo that links it hashed the linked package again, tests included, so a css tweak of the hub or a new test re-ran every cached check everywhere. A key now carries the runner (the modules that run a check and store its answer, and the templates a check reads), the check's own module closure (modules it runs by path included), and for a check running the repo's code, the toolkit modules the repo imports. An edit no answer depends on moves no key, and `sk keys --why` lists the toolkit files behind a key.
28
+
29
+ - **⌘↑ and ⌘↓ open the repo above or below in the project list**, from any page, round both ends. In a text field they still move the caret. The list's foot hints them, and its sort button now just says sort, the order it is in moving to its title.
30
+
31
+ - **An app icon fills its tile, on the repo's colour.** The shipkit app and every process app drew the repo's mark as it was, transparent margin included, so a small mark was a dot on the dock. The icon now cuts the mark to its own box (faint alpha noise, which generated images carry over the whole canvas, counts as margin) and sets it on a rounded plate in the identity colour it stands out on most, on the macOS icon grid. A mark that already fills its box, a photo or a tile, becomes the plate. New colours rebuild the app at its next open.
32
+
33
+ - **The identity colours moved to the admin page, with a preview of the app icon.** The two pickers left the strip. The admin page opens on an identity card: the app icon drawn in the colours being picked (`/api/projects/app-icon/<repo>`, the same drawing the app build uses), and the repo name on a dark and on a light page. The card reads a repo by the name the project list knows, so a repo whose config names it otherwise (screenplay, named screenplay-shed) shows its own colours and icon.
34
+
35
+ - **A broken comfy setup no longer takes the whole media page down.** The workflows load only when a kind generates with comfy. When they fail to load, the media API still mounts: the rolls, the picks and every other kind stay served, and the reason shows in the page errors and in every comfy roll that fails.
36
+
37
+ - **`@napi-rs/canvas` and `comfy-ts` are one copy per process, like the toolkit.** A repo file that imports its own install gets the host copy. A brand that loaded an image with its own canvas copy and drew it on the toolkit's canvas failed ("Value is not one of these types"), and workflows written against an older comfy-ts failed to load.
38
+
39
+ - **A failing test no longer paints the last test file red.** Bun repeats every failure in a recap after the last file of its report, and the hub read those lines as failures of that file: the tests page and the problems pane blamed an innocent suite. The recap now closes the report.
40
+
41
+ - **The arrows walk the repo tree from anywhere on the page.** Up and down move to the next row and open its page at once, with no need to click the tree first. Left and right fold, unfold and climb as before. In the filter box, up and down walk the rows the filter keeps, and left and right stay the text caret's. With no cursor yet, they start from the section of the page shown. A branch with no page of its own only takes the cursor. A walk with the arrows is one history entry, so a held arrow never floods the browser history and back returns to where the walk started. While an asset is open, left and right stay the atelier's roll keys.
42
+
43
+ - **No repo page is a card grid.** Every collection without a table of its own (contracts, reflections, posts, workflows) is now a list, one line per item, red first. The processes page is the overview's process table for that repo, with the opened process's description and log under it. The tables sit on a panel box for contrast, and the page title and group headers are larger and in plain ink.
44
+
45
+ - **The secrets page is a list.** One line per secret, never a card: a dot, the name, missing, minor or present, and its sentence. Missing ones come first.
46
+
1
47
  ## 1.5.0
2
48
 
3
49
  - **Every check declares where it runs and how it caches, with no default.** A check file carries `runs` (`onPreCommit`, `onCi`, `onFull`, `onRelease`, `onHealth`, `onSave`, `onClock`), `cache` (`files`, `reads`, `ttl`, `fact` or `never`, each with its one parameter), `fix` (`none`, `ci` or `manual`, the function itself is now `repair`) and `applies`. `check()` refuses a missing key, a retired key (`when`, `inputs`, `ttl`, `never`, `every`, `cron`, `whenInactive`, `planOff`, `cacheKey`) and the combinations that contradict each other. The gate, the fix pass, the release preflight and the planner read the declared axes; a release never applies a check repair. Every built-in carries its own placement: SK024, SK017 and SK028 run in the health pass only, and SK005, SK006, SK011, SK014, SK016 and SK022 are out of the quick gate. A check file that does not load is one failing row instead of a dead inventory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "1.5.0",
3
+ "version": "1.6.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",
@@ -12,7 +12,8 @@ import { ComfyTS, type AnyVar, type ComfyExecution, type DefinedWorkflow, type M
12
12
  export type GenProgress = { value: number; max: number; node: string | null }
13
13
  export type OnProgress = (p: GenProgress) => void
14
14
 
15
- export type LoadedWorkflow = { id: string; file: string; wf: DefinedWorkflow }
15
+ /** root: the repo the workflow belongs to, which holds its drafts */
16
+ export type LoadedWorkflow = { id: string; root: string; file: string; wf: DefinedWorkflow }
16
17
  export type ComfyTsRuntime = { workflows: Map<string, LoadedWorkflow>; heal?: string }
17
18
 
18
19
  // function declaration, not const — TS only narrows on never-calls to
@@ -47,9 +48,11 @@ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map
47
48
  // explicit COMFY_TS_QUIET=0 from the caller.
48
49
  process.env.COMFY_TS_QUIET ??= '1'
49
50
  // before the first module import: a bare ComfyTS.create() inside the
50
- // module then returns this same instance. A pre-existing instance with a
51
- // different root throws loud — a real misconfiguration.
52
- ComfyTS.create({ rootPath: root })
51
+ // module then returns this same instance. comfy-ts keeps ONE instance per
52
+ // process, and the daemon serves every repo from one process, so only the
53
+ // 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 })
53
56
  if (!existsSync(dir)) fail(`${dir}/ missing — ship at least one <id>.cflow.ts comfy-ts workflow module (copy from the rv-comfy template)`)
54
57
  const out = new Map<string, LoadedWorkflow>()
55
58
  for (const f of readdirSync(dir).filter((x) => x.endsWith('.cflow.ts')).sort()) {
@@ -65,7 +68,7 @@ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map
65
68
  const vars = varsOf(wf)
66
69
  if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
67
70
  fail(`workflow '${id}' must declare vars.prompt (v.prompt) and vars.seed (v.seed) — shipkit drives both`)
68
- out.set(id, { id, file, wf })
71
+ out.set(id, { id, root, file, wf })
69
72
  }
70
73
  if (out.size === 0) fail(`${dir}/ has no *.cflow.ts workflow modules — seed one from the rv-comfy template`)
71
74
  return out
@@ -96,8 +99,9 @@ export function applyVarValues(wf: DefinedWorkflow, vars: Record<string, unknown
96
99
  }
97
100
  }
98
101
 
99
- // ── drafts — comfy-ts disk format, path rule taken from the ComfyTS instance
100
- // so it cannot drift: .comfy-ts/drafts/<wfId>/<draftId>.json.
102
+ // ── drafts — comfy-ts disk format, .comfy-ts/drafts/<wfId>/<draftId>.json
103
+ // under the workflow's own repo, never the process instance's root (which is
104
+ // the first repo's in the daemon).
101
105
  //
102
106
  // The draft id is the KIND, never the asset: ONE `creature` draft behind all
103
107
  // 107 creatures, so tuning the loras once tunes them for the whole kind. The
@@ -107,7 +111,7 @@ export function applyVarValues(wf: DefinedWorkflow, vars: Record<string, unknown
107
111
  //
108
112
  // Shipkit does its own IO here because comfy-ts exports no drafts API yet;
109
113
  // swap when it lands.
110
- const draftDir = (lw: LoadedWorkflow): string => ComfyTS.create().resolveFromDrafts(lw.id)
114
+ const draftDir = (lw: LoadedWorkflow): string => join(lw.root, '.comfy-ts', 'drafts', lw.id)
111
115
  export const draftPath = (lw: LoadedWorkflow, name: string): string => join(draftDir(lw), `${name}.json`)
112
116
 
113
117
  export function readAssetDraft(lw: LoadedWorkflow, draftId: string): Record<string, unknown> | null {
@@ -49,7 +49,18 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
49
49
  const workspace = join(root, kit.workspace)
50
50
  const dirOf = (id: string): string => assetDir(workspace, id)
51
51
  let workflows: Map<string, LoadedWorkflow> | null = null
52
- if (kit.comfy != null) workflows = await loadCflowWorkflows(root, join(root, kit.comfy.workflows))
52
+ // only a comfy kind needs them, and a broken load costs only comfy
53
+ // generation: the rolls, the picks and every other kind stay served, and
54
+ // the reason rides in /api/state errors and in every comfy roll that fails
55
+ let comfyErr: string | null = null
56
+ if (kit.comfy != null && kit.kinds.some((k) => k.generate.backend === 'comfy')) {
57
+ try {
58
+ workflows = await loadCflowWorkflows(root, join(root, kit.comfy.workflows))
59
+ } catch (e) {
60
+ comfyErr = `comfy workflows not loaded: ${(e as Error).message}`
61
+ console.log(`WARN ${comfyErr}`)
62
+ }
63
+ }
53
64
 
54
65
  // custom panel scripts fail-fast here too (tests/embedders may bypass loadKit)
55
66
  const missingPanels = missingPanelScripts(kit, root)
@@ -111,6 +122,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
111
122
  // workflows are pre-loaded above (fail-fast for a comfy studio); the
112
123
  // resolver just hands the shared roll generator the ready runtime.
113
124
  const resolveComfy = async (): Promise<ComfyTsRuntime> => {
125
+ if (comfyErr != null) throw new Error(comfyErr)
114
126
  if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
115
127
  return { workflows, heal: kit.comfy.heal }
116
128
  }
@@ -243,7 +255,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
243
255
  derived: parentIdsOf(f).length > 0, stale: await isStale(kit, workspace, f),
244
256
  }
245
257
  }))
246
- return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
258
+ return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: [...(comfyErr == null ? [] : [comfyErr]), ...queue.errors.slice(-5)], assets })
247
259
  }
248
260
 
249
261
  if (pathname === '/api/asset') {
package/src/check.ts CHANGED
@@ -238,14 +238,11 @@ export type TaskDef = {
238
238
  timeoutMs?: number
239
239
  /** CONTENT population: the repo-relative paths this task reads. Declaring
240
240
  * them is what makes it keyable (ledger/git-key.ts). Undeclared is the
241
- * DEFAULT and means never cached, so forgetting one costs speed and
242
- * never correctness. A list MISSING a path the command reads is the one
243
- * that costs correctness: the key cannot move when that path changes, so
244
- * a cached answer comes back over a tree it no longer describes. `['.']` is the
245
- * whole TRACKED tree, and it is NOT a catch-all: an ignored path enters a
246
- * key by name and never by content, so a task reading a gitignored
247
- * fixture, a built dir or a .env under declares with `['.']` exactly as
248
- * it would with a narrow list. */
241
+ * DEFAULT: the task is keyed on the whole work tree plus the repo's
242
+ * `ignoredInputs` (build/shipkit/cache.md). A list MISSING a path
243
+ * the command reads is the one that costs correctness: the key cannot
244
+ * move when that path changes, so a cached answer comes back over a tree
245
+ * it no longer describes. */
249
246
  inputs?: string[]
250
247
  /** WORLD population: it reads the network, a keychain, $PATH or the clock,
251
248
  * so nothing on disk describes its answer and a clean pass is remembered
@@ -125,13 +125,14 @@ export function evaluatePackageJson(t: CheckCtx): void {
125
125
  //
126
126
  // a shipkit verb is never on this list. `shipkit ci`, `check`, `contracts`,
127
127
  // `tui`, `mv`, `assets`, `asks` are typed as themselves, and a package.json
128
- // row that only forwards to one is a second name for one capability
128
+ // row that only forwards to one is a second name for one capability. nor is
129
+ // a global process (ComfyUI, TTS): `shipkit gp start <id>` starts it from any
130
+ // repo, so a per repo script would only copy it
129
131
  function requiredScripts(c: CheckCtx): Array<{ name: string; why: string }> {
130
132
  const required: Array<{ name: string; why: string }> = [
131
133
  // watch-mode serve — the ONE shipkit surface the bare verb cannot give
132
134
  { name: 'hub', why: 'shipkit repo' },
133
135
  ]
134
- if (c.exists('.shipkit/comfy')) required.push({ name: 'comfy:up', why: '.shipkit/comfy/ exists' })
135
136
  if (c.exists('deploy.sh')) required.push({ name: 'up', why: 'deploy.sh exists' })
136
137
  return required
137
138
  }
@@ -203,8 +204,8 @@ export const packageJsonShape = check({
203
204
  applies: 'always',
204
205
  run: (c) => evaluatePackageJson(c),
205
206
  // mechanical repairs only: field flips + scripts whose canonical body the
206
- // vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
207
- // named in the summary, never invented. rvlib-* roots untouched — their
207
+ // vocabulary page states. A body it does NOT state is named in the
208
+ // summary, never invented. rvlib-* roots untouched — their
208
209
  // license/private are publish decisions.
209
210
  repair: (c): FixResult => {
210
211
  const raw = c.read('package.json')
@@ -18,8 +18,8 @@ export const claudeMdShape = check({
18
18
  title: 'CLAUDE.md',
19
19
  description: 'CLAUDE.md loads the house wiki into every session',
20
20
  owner: 'meta/agent-folder.md',
21
- // keyed on what it reads: CLAUDE.md and whether agent/index.md exists. The
22
- // gitignore probe runs git, so an answer that took it is never stored
21
+ // keyed on what it reads: CLAUDE.md, whether agent/index.md exists, and
22
+ // the ignore files git read for the gitignore probe (c.exec runs traced)
23
23
  runs: { onPreCommit: { budgetMs: 40 }, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
24
24
  cache: { by: 'reads' },
25
25
  fix: 'ci',
@@ -0,0 +1,34 @@
1
+ // SK030 cache audit (build/shipkit/runs.md owns the cache): a sample of the
2
+ // checks the cache answers is re-run fresh about once a day, and a cached
3
+ // green a fresh run disagrees with fails, naming the check whose key misses a
4
+ // path. The work is `sk cache-audit` (core/cache-audit.ts), in its own
5
+ // process, because it runs other checks.
6
+ import { join } from 'path'
7
+ import { check } from '../check.ts'
8
+
9
+ const CLI = join(import.meta.dir, '../cli.ts')
10
+
11
+ export const cacheAudit = check({
12
+ id: 'SK030',
13
+ title: 'cache audit',
14
+ description: 'a sample of cached answers matches what a fresh run of each check says',
15
+ owner: 'build/shipkit/runs.md',
16
+ blocking: true,
17
+ // never a gate: it re-runs other checks. The daemon asks it about once a
18
+ // day, and only while somebody works in the repo
19
+ runs: { onPreCommit: false, onCi: false, onFull: false, onRelease: false, onHealth: true, onSave: false, onClock: { every: ['20h', '28h'], inactive: 'wait' } },
20
+ cache: { by: 'never', why: 'it runs other checks fresh, so its answer is that run itself' },
21
+ fix: 'none',
22
+ applies: 'always',
23
+ run: async (c) => {
24
+ const r = await c.exec([process.execPath, CLI, 'cache-audit', '--json'], { cwd: c.root, timeoutMs: 600_000 })
25
+ let out: { pool: number; rows: { id: string; cached: string; fresh: string; keyedOn: string }[]; stale: { id: string; cached: string; fresh: string; keyedOn: string }[] }
26
+ try { out = JSON.parse(r.out.trim().split('\n').at(-1) ?? '') } catch { return c.fail(`sk cache-audit did not answer (exit ${r.code}: ${r.out.trim().slice(0, 160)})`) }
27
+ if (out.rows.length === 0) return c.note(`nothing to audit: the cache answers none of this repo's checks right now`)
28
+ for (const s of out.stale)
29
+ c.fail(`${s.id}: the cache said ${s.cached}, a fresh run says ${s.fresh}, so its key misses something it reads`, `${s.keyedOn === 'files' ? 'add the path to its cache.inputs' : 'move the read through c'}, then sk keys --why ${s.id}`)
30
+ if (out.stale.length === 0) c.ok(`${out.rows.map((x) => x.id).join(', ')}: every cached answer is what a fresh run says (${out.rows.length} of ${out.pool} cached)`)
31
+ },
32
+ })
33
+
34
+ export default cacheAudit
@@ -0,0 +1,39 @@
1
+ // SK031 traced audit (build/shipkit/cache.md owns the key): about once a day
2
+ // each gate task runs under the tracer, and a gitignored path it reads that
3
+ // its key misses joins that task's key from the kept answer. SK032 carries the
4
+ // kept proposals into repo.config.ts in the gate's fix pass. The work is
5
+ // `sk trace-audit` (core/trace-audit.ts), in its own process, because it runs
6
+ // the gate's tasks. On wherever the tracer runs, unless the machine opted out.
7
+ import { join } from 'path'
8
+ import { check } from '../check.ts'
9
+ import { traceUnavailable } from '../trace/tracer.ts'
10
+
11
+ const CLI = join(import.meta.dir, '../cli.ts')
12
+
13
+ export const traceAuditCheck = check({
14
+ id: 'SK031',
15
+ title: 'traced audit',
16
+ description: 'every gitignored path a gate task reads is in its key',
17
+ owner: 'build/shipkit/cache.md',
18
+ blocking: false,
19
+ // never a gate: it runs the gate's tasks. The daemon asks it about once a
20
+ // day, and only while somebody works in the repo
21
+ runs: { onPreCommit: false, onCi: false, onFull: false, onRelease: false, onHealth: true, onSave: false, onClock: { every: ['20h', '28h'], inactive: 'wait' } },
22
+ cache: { by: 'never', why: 'it runs the gate tasks under the tracer, so its answer is that run itself' },
23
+ fix: 'none',
24
+ applies: () => traceUnavailable(),
25
+ run: async (c) => {
26
+ const r = await c.exec([process.execPath, CLI, 'trace-audit', '--json'], { cwd: c.root, timeoutMs: 1_200_000 })
27
+ let out: { skipped?: string; tasks: { id: string; status: string; why?: string; uncovered: string[]; propose: string[] }[] }
28
+ try { out = JSON.parse(r.out.trim().split('\n').at(-1) ?? '') } catch { return c.fail(`sk trace-audit did not answer (exit ${r.code}: ${r.out.trim().slice(0, 160)})`) }
29
+ if (out.skipped != null) return c.note(`nothing traced: ${out.skipped}`)
30
+ for (const t of out.tasks.filter((x) => x.status === 'unaudited')) c.warn(`${t.id} could not be audited: ${t.why}`)
31
+ const missing = out.tasks.filter((x) => x.uncovered.length > 0)
32
+ // a finding joins its task's key at once (core/audit-report.ts), so it
33
+ // is news, never a failure: SK032 carries it into repo.config.ts
34
+ for (const t of missing) c.ok(`${t.id} reads ${t.uncovered.length} gitignored path(s) the config does not name, keyed from this audit: ${t.propose.join(', ')}`)
35
+ if (missing.length === 0) c.ok(`${out.tasks.filter((x) => x.status === 'audited').length} gate task(s) audited: every gitignored path they read is in their key`)
36
+ },
37
+ })
38
+
39
+ export default traceAuditCheck
@@ -0,0 +1,79 @@
1
+ // SK032 ignored inputs current (build/shipkit/cache.md owns the key): what the
2
+ // last traced audit (SK031) found a task reading outside its key is in
3
+ // repo.config.ts ignoredInputs, so other machines and clones key it too. The repair writes the missing entries into
4
+ // the managed block, so the gate's fix pass carries each finding into the
5
+ // config. Nothing is traced here: it reads the audit SK031 kept.
6
+ import { join } from 'path'
7
+ import { check } from '../check.ts'
8
+ import { describeByTask, pendingByTask, type AuditReport } from '../core/ignored-inputs.ts'
9
+
10
+ const CLI = join(import.meta.dir, '../cli.ts')
11
+
12
+ /** the first rvlib-shipkit release whose `defineRepo` knows `ignoredInputs`.
13
+ * A repo whose pin allows an older one loads its config elsewhere (a CI job
14
+ * installing from npm, a fresh clone) with that older copy, which refuses
15
+ * the unknown key: so nothing is written until the pin says at least this */
16
+ export const IGNORED_INPUTS_SINCE = '1.6.0'
17
+
18
+ const parts = (v: string): number[] => v.split('.').map((n) => Number.parseInt(n, 10) || 0)
19
+ const atLeast = (v: string, min: string): boolean => {
20
+ const a = parts(v), b = parts(min)
21
+ for (let i = 0; i < 3; i++) if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0)
22
+ return true
23
+ }
24
+
25
+ /** why the repo's pin cannot carry the key yet, or null. No pin (the repo
26
+ * that hosts the toolkit, or one running it from source) can carry it */
27
+ function pinTooOld(pkg: string | null): string | null {
28
+ if (pkg == null) return null
29
+ let range: string | undefined
30
+ try {
31
+ const j = JSON.parse(pkg) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
32
+ range = j.devDependencies?.['rvlib-shipkit'] ?? j.dependencies?.['rvlib-shipkit']
33
+ } catch {
34
+ return null
35
+ }
36
+ const min = range == null ? null : /(\d+\.\d+\.\d+)/.exec(range)?.[1] ?? null
37
+ if (min == null || range!.startsWith('link:') || range!.startsWith('file:') || range!.startsWith('workspace:')) return null
38
+ return atLeast(min, IGNORED_INPUTS_SINCE) ? null : `package.json pins rvlib-shipkit ${range}, which allows a release before ${IGNORED_INPUTS_SINCE} that refuses ignoredInputs: raise the pin to ^${IGNORED_INPUTS_SINCE} first`
39
+ }
40
+
41
+ /** the kept audit, read through c so the reads key follows it */
42
+ function kept(text: string | null): AuditReport | null {
43
+ try { return text == null ? null : (JSON.parse(text) as AuditReport) } catch { return null }
44
+ }
45
+
46
+ export const ignoredInputsCheck = check({
47
+ id: 'SK032',
48
+ title: 'ignored inputs current',
49
+ description: 'every gitignored path the traced audit saw a task read is in ignoredInputs',
50
+ owner: 'build/shipkit/cache.md',
51
+ blocking: false,
52
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: false, onHealth: true, onSave: false, onClock: false },
53
+ cache: { by: 'reads' },
54
+ fix: 'ci',
55
+ applies: 'always',
56
+ run: (c) => {
57
+ const missing = pendingByTask(kept(c.state('trace-audit.json')), c.config.ignoredInputs ?? {})
58
+ const n = Object.values(missing).flat().length
59
+ if (n === 0) return c.ok('repo.config.ts ignoredInputs covers what the last traced audit found')
60
+ // the keys on this machine already read the kept audit (core/audit-report.ts),
61
+ // so what is missing here is the copy other machines and clones see
62
+ const old = pinTooOld(c.read('package.json'))
63
+ if (old != null) return c.ok(`${n} path(s) the traced audit found are keyed on this machine only: ${old}`)
64
+ c.warn(`the traced audit found ${describeByTask(missing)}: keyed on this machine, not yet in repo.config.ts ignoredInputs`, 'sk ci writes them, and sk trace-audit --fix says why when it cannot')
65
+ },
66
+ repair: async (c) => {
67
+ const missing = pendingByTask(kept(c.state('trace-audit.json')), c.config.ignoredInputs ?? {})
68
+ if (Object.keys(missing).length === 0) return { changed: false, summary: 'nothing to add' }
69
+ const old = pinTooOld(c.read('package.json'))
70
+ if (old != null) return { changed: false, summary: old }
71
+ // the managed block writer loads repo.config.ts, which no read through
72
+ // c can stand for, so it runs in its own process
73
+ const r = await c.exec([process.execPath, CLI, 'trace-audit', '--fix'], { cwd: c.root, timeoutMs: 60_000 })
74
+ if (r.code !== 0) return { changed: false, summary: `sk trace-audit --fix failed: ${r.out.trim().split('\n').at(-1) ?? `exit ${r.code}`}` }
75
+ return { changed: true, summary: `repo.config.ts ignoredInputs gained ${describeByTask(missing)}` }
76
+ },
77
+ })
78
+
79
+ export default ignoredInputsCheck
@@ -2,7 +2,11 @@
2
2
  // (ComfyUI or procedural canvas), curate in the studio, ship with metadata
3
3
  // write-through. Thin clipanion surfaces; logic in assets/run-assets.ts.
4
4
  import { Command, Option } from 'clipanion'
5
- import { runAssetsCheck, runAssetsGen, runAssetsIdentity, runAssetsTopUp, type AssetsIo } from '../assets/run-assets.ts'
5
+ import type { AssetsIo } from '../assets/run-assets.ts'
6
+
7
+ // loaded when an assets command RUNS: it pulls in canvas, sharp and comfy-ts,
8
+ // which every other command would otherwise pay for at startup
9
+ const assets = (): Promise<typeof import('../assets/run-assets.ts')> => import('../assets/run-assets.ts')
6
10
 
7
11
  const ioOf = (cmd: Command): AssetsIo => ({
8
12
  print: (l) => cmd.context.stdout.write(`${l}\n`),
@@ -33,7 +37,7 @@ export class AssetsFillCommand extends Command {
33
37
  this.context.stderr.write(`FAIL: '${this.n}' is not a positive roll count — usage: shipkit assets fill <N> [category…]\n`)
34
38
  return 1
35
39
  }
36
- return runAssetsTopUp(process.cwd(), ioOf(this), { mode: 'fill', n, categories: this.categories })
40
+ return (await assets()).runAssetsTopUp(process.cwd(), ioOf(this), { mode: 'fill', n, categories: this.categories })
37
41
  }
38
42
  }
39
43
 
@@ -55,7 +59,7 @@ export class AssetsAddCommand extends Command {
55
59
  this.context.stderr.write(`FAIL: '${this.n}' is not a positive roll count — usage: shipkit assets add <N> [category…]\n`)
56
60
  return 1
57
61
  }
58
- return runAssetsTopUp(process.cwd(), ioOf(this), { mode: 'add', n, categories: this.categories })
62
+ return (await assets()).runAssetsTopUp(process.cwd(), ioOf(this), { mode: 'add', n, categories: this.categories })
59
63
  }
60
64
  }
61
65
 
@@ -76,7 +80,7 @@ export class AssetsGenCommand extends Command {
76
80
 
77
81
  async execute(): Promise<number> {
78
82
  const n = Math.max(1, Number(this.n) || 1)
79
- return runAssetsGen(process.cwd(), ioOf(this), { assetId: this.assetId, n })
83
+ return (await assets()).runAssetsGen(process.cwd(), ioOf(this), { assetId: this.assetId, n })
80
84
  }
81
85
  }
82
86
 
@@ -90,7 +94,7 @@ export class AssetsIdentityCommand extends Command {
90
94
  })
91
95
 
92
96
  async execute(): Promise<number> {
93
- return runAssetsIdentity(process.cwd(), ioOf(this))
97
+ return (await assets()).runAssetsIdentity(process.cwd(), ioOf(this))
94
98
  }
95
99
  }
96
100
 
@@ -104,6 +108,6 @@ export class AssetsCheckCommand extends Command {
104
108
  })
105
109
 
106
110
  async execute(): Promise<number> {
107
- return runAssetsCheck(process.cwd(), ioOf(this))
111
+ return (await assets()).runAssetsCheck(process.cwd(), ioOf(this))
108
112
  }
109
113
  }
@@ -0,0 +1,43 @@
1
+ // sk cache-audit — re-runs a sample of the checks the cache would answer right
2
+ // now, fresh, and compares: a cached green a fresh run disagrees with is a
3
+ // key that misses something its check reads (core/cache-audit.ts).
4
+ import { Command, Option } from 'clipanion'
5
+ import { loadRepoConfig } from '../repo-config.ts'
6
+ import { auditCache } from '../core/cache-audit.ts'
7
+
8
+ export class CacheAuditCommand extends Command {
9
+ static override paths = [['cache-audit']]
10
+
11
+ static override usage = Command.Usage({
12
+ category: 'Health',
13
+ description: 'is a cached green still true: re-run a sample of cached checks fresh and compare',
14
+ details: 'Picks checks the cache would answer right now (keyed on files or on reads), a different stable sample each day, runs each one fresh and compares its verdict with the cached one. A mismatch means the check key misses a path its body reads: the fresh answer replaces the stale one, and the line names the check. Exit 1 on a mismatch.',
15
+ examples: [
16
+ ['audit three cached checks', 'sk cache-audit'],
17
+ ['a bigger sample', 'sk cache-audit --sample 10'],
18
+ ['one check, by id', 'sk cache-audit --only SK004'],
19
+ ['machine-readable', 'sk cache-audit --json'],
20
+ ],
21
+ })
22
+
23
+ sample = Option.String('--sample', '3', { description: 'how many cached checks to re-run fresh (default 3)' })
24
+ only = Option.Array('--only', { description: 'audit these check ids instead of a sample' })
25
+ json = Option.Boolean('--json', false, { description: 'machine-readable { pool, rows, stale }' })
26
+
27
+ async execute(): Promise<number> {
28
+ const root = process.cwd()
29
+ const n = Number(this.sample)
30
+ if (!Number.isInteger(n) || n < 1) { this.context.stdout.write(`FAIL: --sample takes a whole number above 0, got '${this.sample}'\n`); return 1 }
31
+ const r = await auditCache(root, await loadRepoConfig(root), { sample: n, only: this.only })
32
+ if (this.json) { this.context.stdout.write(`${JSON.stringify(r)}\n`); return r.stale.length > 0 ? 1 : 0 }
33
+ if (r.rows.length === 0) { this.context.stdout.write(`skip: nothing to audit, the cache answers none of ${this.only?.join(', ') ?? 'the checks'} right now\n`); return 0 }
34
+ for (const row of r.rows) {
35
+ const same = row.fresh === row.cached || row.fresh === 'skip'
36
+ this.context.stdout.write(same
37
+ ? `ok: ${row.id} cached ${row.cached}, fresh ${row.fresh} (${row.ms}ms)\n`
38
+ : `FAIL: ${row.id} cached ${row.cached}, but a fresh run says ${row.fresh}: its key misses something it reads\n fix: ${row.keyedOn === 'files' ? `add the path its body reads to its cache.inputs` : 'move the read through c, or declare cache files or never'}, then sk keys --why ${row.id}\n`)
39
+ }
40
+ this.context.stdout.write(r.stale.length === 0 ? `ok: ${r.rows.length} of ${r.pool} cached check(s) audited, every cached answer is what a fresh run says\n` : `FAIL: ${r.stale.length} of ${r.rows.length} audited check(s) served a stale answer\n`)
41
+ return r.stale.length > 0 ? 1 : 0
42
+ }
43
+ }
@@ -8,7 +8,6 @@ import { homedir } from 'os'
8
8
  import { Command, Option } from 'clipanion'
9
9
  import { activityOf, type Declared } from '../home/activity.ts'
10
10
  import { ageShort, clampCap, DEFAULT_CAP, DEFAULT_MINUTES, dirtWords, idleRow, upstreamWords, type ActivityAnswer, type ActivityCommit, type ActivityRow } from '../home/activity-shape.ts'
11
- import { railRepos } from '../serve/project-gutter-route.ts'
12
11
 
13
12
  /** a span as typed: `10m`, `1h`, `3d`, `90` (minutes); null when it is none */
14
13
  export function spanMinutes(v: string): number | null {
@@ -108,6 +107,8 @@ export class CrossRepoActivityCommand extends Command {
108
107
  const cap = clampCap(this.maxCommit ?? DEFAULT_CAP)
109
108
  const declared = await daemonDeclared()
110
109
  // HOME as the shell has it now: the house include names `~`
110
+ // the hub route module draws app icons, so it loads only when this runs
111
+ const { railRepos } = await import('../serve/project-gutter-route.ts')
111
112
  const a = await activityOf(railRepos(process.cwd(), process.env.HOME || homedir()), minutes, Date.now(), declared)
112
113
  if (this.json) {
113
114
  this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
@@ -13,7 +13,6 @@ import type { ResourceView } from '../home/resources.ts'
13
13
  import { coreGroup, declaredRow, groupRows, processGroups, stateWords, type ProcessGroup } from '../home/process-rows.ts'
14
14
  import type { PmConfig } from '../home/pm-config.ts'
15
15
  import { readPmConfig } from '../home/run-pm.ts'
16
- import { railRepos } from '../serve/project-gutter-route.ts'
17
16
 
18
17
  export type GlobalProcessesAnswer = {
19
18
  /** pm as it answered, null when it did not */
@@ -75,11 +74,13 @@ export async function askPm(url = pmUrl()): Promise<PmAnswer | null> {
75
74
 
76
75
  /** the project list's order and the declared units, read from disk the way
77
76
  * pm reads them: what the table falls back on while pm does not answer */
78
- export function localProcessDeps(cwd: string): Pick<GlobalProcessesDeps, 'declared' | 'order'> {
77
+ export async function localProcessDeps(cwd: string): Promise<Pick<GlobalProcessesDeps, 'declared' | 'order'>> {
79
78
  // HOME as the shell has it now: the house include names `~`
80
79
  const home = process.env.HOME || homedir()
81
80
  let order: string[] = []
82
81
  try {
82
+ // the hub route module draws app icons: loaded only when this runs
83
+ const { railRepos } = await import('../serve/project-gutter-route.ts')
83
84
  order = railRepos(cwd, home).map((r) => r.name)
84
85
  } catch {
85
86
  // no rail from here: the groups sort by name
@@ -146,7 +147,7 @@ export class GlobalProcessesCommand extends Command {
146
147
  json = Option.Boolean('--json', false, { description: 'the rows as json: { pm, now, groups, errors, house }, groups exactly as the overview draws them' })
147
148
 
148
149
  async execute(): Promise<number> {
149
- const a = await globalProcessesAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), now: Date.now() })
150
+ const a = await globalProcessesAnswer({ ...(await localProcessDeps(process.cwd())), pm: () => askPm(), daemon: () => askDaemon(), now: Date.now() })
150
151
  if (this.json) {
151
152
  this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
152
153
  return 0
@@ -10,7 +10,6 @@ import { homeUp, homeUrl, hubBase } from '../home/home-paths.ts'
10
10
  import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
11
11
  import { homedir } from 'os'
12
12
  import { execPm, runPm } from '../home/run-pm.ts'
13
- import { runDaemon } from '../home/run-daemon.ts'
14
13
  import { runWeb } from '../home/run-web.ts'
15
14
 
16
15
  const OWN_CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
@@ -71,6 +70,8 @@ export class DaemonCommand extends Command {
71
70
  async execute(): Promise<number> {
72
71
  const port = portOf(this, this.port)
73
72
  if (port === null) return 1
73
+ // the daemon hosts every hub, image tools included: loaded when it runs
74
+ const { runDaemon } = await import('../home/run-daemon.ts')
74
75
  return runDaemon(process.cwd(), io(this), { port })
75
76
  }
76
77
  }
@@ -169,7 +170,7 @@ export class GuiCommand extends Command {
169
170
  // `.` = the repo you stand in, named the way the rail names it
170
171
  const name = this.repo === '.' && houseIndex(homeRepo(process.cwd()), homedir()) != null ? basename(homeRepo(process.cwd())) : this.repo
171
172
  const url = `${homeUrl()}${name != null && name !== '.' ? `${hubBase(name)}/` : '/'}`
172
- const cmd = openWindow(url, { version: LIB_VERSION, warn: out.printErr })
173
+ const cmd = await openWindow(url, { version: LIB_VERSION, warn: out.printErr })
173
174
  out.print(`ok: a home window on ${url}${cmd[0] === 'open' ? ' (the shipkit app)' : ''}`)
174
175
  return 0
175
176
  }
@@ -75,6 +75,14 @@ export class KeysCommand extends Command {
75
75
  return 0
76
76
  }
77
77
  out(` key ${k.key}`)
78
+ // a lib hash names nothing by itself: the files behind it are what
79
+ // an edit has to touch to move this key
80
+ const lib = k.libFiles ?? []
81
+ if (lib.length > 0) {
82
+ out(` toolkit files in its lib components: ${lib.length}`)
83
+ for (const f of lib.slice(0, 12)) out(` ${f}`)
84
+ if (lib.length > 12) out(` … ${lib.length - 12} more (shipkit keys --json)`)
85
+ }
78
86
  if (k.population === 'reads') {
79
87
  for (const comp of k.components) out(` ${comp}`)
80
88
  // the rest of its identity is what it READ, stored with the
@@ -89,12 +97,15 @@ export class KeysCommand extends Command {
89
97
  const lists = Object.entries(set.lists)
90
98
  const dirs = Object.entries(set.dirs ?? {})
91
99
  const links = Object.entries(set.links ?? {})
92
- out(` read set: ${files.length} file(s), ${exists.length} existence probe(s), ${lists.length} listing(s), ${dirs.length} dir read(s)${links.length > 0 ? `, ${links.length} link probe(s)` : ''}`)
100
+ // outside the repo a traced read is keyed on stat alone
101
+ const outside = Object.keys(set.sigs ?? {}).filter((p) => !(p in set.files))
102
+ out(` read set: ${files.length} file(s), ${exists.length} existence probe(s), ${lists.length} listing(s), ${dirs.length} dir read(s)${links.length > 0 ? `, ${links.length} link probe(s)` : ''}${outside.length > 0 ? `, ${outside.length} file(s) outside the repo, by stat` : ''}`)
93
103
  for (const [p, oid] of files) out(` read ${p}=${oid ?? 'absent'}`)
94
104
  for (const [p, kind] of exists) out(` exists ${p}=${kind ?? 'absent'}`)
95
105
  for (const [g, h] of lists) out(` list ${g}=${h}`)
96
106
  for (const [d, h] of dirs) out(` entries ${d}=${h ?? 'absent'}`)
97
107
  for (const [p, is] of links) out(` link ${p}=${is ? 'yes' : 'no'}`)
108
+ for (const p of outside) out(` stat ${p}=${set.sigs![p]}`)
98
109
  return 0
99
110
  }
100
111
  // the excluded block is 44 lines here and it buries the component
@@ -88,7 +88,7 @@ export class OverviewCommand extends Command {
88
88
  json = Option.Boolean('--json', false, { description: 'the answers as json: { now, pm, daemon, processes, widgets }, processes as `shipkit gp --json`, widgets the daemon route\'s payload' })
89
89
 
90
90
  async execute(): Promise<number> {
91
- const a = await overviewAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), widgets: () => askWidgets(), now: Date.now() })
91
+ const a = await overviewAnswer({ ...(await localProcessDeps(process.cwd())), pm: () => askPm(), daemon: () => askDaemon(), widgets: () => askWidgets(), now: Date.now() })
92
92
  if (this.json) {
93
93
  this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
94
94
  return 0
@@ -1,7 +1,6 @@
1
1
  // shipkit serve — the ONE hub server per repo (one vscode folder-open task,
2
2
  // clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
3
3
  import { Command, Option } from 'clipanion'
4
- import { runServe } from '../serve/run-serve.ts'
5
4
 
6
5
  export class ServeCommand extends Command {
7
6
  static override paths = [['serve']]
@@ -27,6 +26,8 @@ export class ServeCommand extends Command {
27
26
  return 1
28
27
  }
29
28
  }
29
+ // the hub runtime carries the image tools: loaded when it serves
30
+ const { runServe } = await import('../serve/run-serve.ts')
30
31
  return runServe(process.cwd(), {
31
32
  print: (l) => this.context.stdout.write(`${l}\n`),
32
33
  printErr: (l) => this.context.stderr.write(`${l}\n`),