rvlib-shipkit 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -20
package/CHANGELOG.md CHANGED
@@ -1,3 +1,107 @@
1
+ ## 1.4.0
2
+
3
+ - **Fix: `shipkit gui` wears the house mark from any directory.** The app took its icon from cwd, so a run outside every house repo rebuilt `Shipkit.app` with no icon. The house now comes from cwd, else from the installed pm agent's `WorkingDirectory` (`installedHouseRoot`, `guiRoot`), and never from cwd itself.
4
+
5
+ - **Fix: a busy machine never fails a suite as a wait.** The wait verdict judges the wall against `waitAllowance`, twice the budget divided by `1 - share²`, which has no end once the load reaches the core count; `fairWall` is gone. The fair share model failed four suites at 11 to 19s wall with about 1s of CPU at load 20.5 on 8 cores. The FAIL line names the load a wait was judged at instead of "on an idle machine", and `overWords` moved to `judge-suites.ts`.
6
+ - `Answers.busyTimeout()` names the busy timeout sqlite holds on the open handle, so a test asserts a read waits only `READ_BUSY_MS` on a writer instead of timing it.
7
+ - **Fix: a save in a repo nobody looks at costs the daemon nothing.** A hub's warm answers (rows, runs page, wiki graphs) recompute in the background only while a page read them in the last 30s (`WARM_WATCH_MS`), at most once per ten times their last compute (`WARM_SPACING`); the first reader after that waits on one fresh compute. Measured on a live daemon, one file of a large repo touched every 2s: 83.5% cpu before, 5.0% after.
8
+ - **Fix: widgets scan and render only while someone reads them.** No read of `/api/home/widgets.json` for 30s (`WIDGET_WATCH_MS`), and the daemon walks no repo and renders no widget; the first read after that waits on a scan and a render. `Widgets.look(repos)` is the reader's call.
9
+ - **Fix: pm reads the house side again only when it moved.** The `pm-config.ts` child answers with the paths it read and their stamp, and pm stats them every 15s (about 4ms) instead of spawning a child that imports every unit file (about 0.6s of cpu). A dir counts by its subdirs and unit files, so a temp file coming and going in a repo root moves nothing. `repoUnitFiles(root, dirs)` collects the dirs its walk read.
10
+ - **Fix: an ask probe runs with the env the process holds now.** `shipkit asks check` and SK026 spawn each probe with `process.env` named, since bun's default is the env the process started with, blind to a later write such as a caller pointing `HOME` elsewhere.
11
+ - **Fix: the line SK009 asks a foreign commit-msg hook to add works on a clone without `node_modules`.** It reads `if [ -f <cli> ]; then <shipkit> hooks commit-msg "$1" || exit 1; else echo "… attribution gate skipped" >&2; fi`, so a fresh clone skips the gate with a note instead of failing every commit. The bare form and the multi line guarded form both still count as chained.
12
+ - **Fix: the runs page list never moves in.** The `reading the run record…` line sits inside the list box, where the list will be, so the list no longer drops 8px when it arrives (a refresh of the runs tab measured CLS 0.0205, now 0.0019).
13
+ - **The checks page and the asks page are tables.** One line per item on the activity table style instead of a card grid, under group headers that count their lines. A check line: status dot, id, title, age of its last result, duration against its budget, the first finding line (cut, every line on hover), and how it is planned (`on change`, `every 7d`, `once`, `every run`, `health pass`, `every release`, `off`); groups failing, warning, never ran, passing, off, most recent first. An ask line: kind, title, age, what it blocks, `auto close` or `manual`; open asks by what they block then oldest first, done ones after. Passing, off and done fold by default, a header click folds or opens, and the choice is kept per viewer (`hub:table-folds`). The filter input and the `a`, `n`, `r` keys narrow the lines. An ask resource now carries `ask: AskFacts` (kind, created, closed, blocks, probe), and `Ask` gains `closed`.
14
+ - **Every url on the overview opens three ways.** Before the url: open in Chrome, and open as the process's own app (a spinner while it builds). The url itself opens the default browser. The open pick in the url header is gone, and `MachineSt.openProcess` takes the way as its second argument.
15
+ - **The shipkit app finds and inspects.** Cmd-F opens the page's find bar (cmd-G, shift-cmd-G, Esc), alt-cmd-I the Web Inspector (Edit › Find, View › Developer Tools). The zoom keys stay.
16
+ - **Every global process with a url can open as its own app.** One Swift source serves the shipkit window and each process's app: the bundle's Info.plist names it and its start page. The app is built on first use into the declaring repo's `.shipkit/wrapper/<id>.app` (bundle id `dev.shipkit.app.<id>`, its image icon, else the repo's mark), rebuilt only when the source, the icon or the url changed. pm's resource route takes `chrome` (Chrome, else the default browser) beside `open` and `app`, and reports `appBusy` while an app builds.
17
+ - **Fix: no empty band at the bottom of every page.** The daemon or pm not answering is a toast floating over the bottom right corner of the page column, above the dock (`MachineToasts`, replacing `MachineBar`), drawn only while it lasts and taking no room.
18
+ - **The overview's process switches are small icons beside the label.** Play (stop while it runs) and restart sit in their own narrow columns right after the process name, every play under every play, each with a tooltip; open is an open external icon before the url, which stays a link. The new window button and the open choice sit in the url's header. New icons `play`, `stop`, `restart`, `external`.
19
+ - **The runs page opens on its tabs.** The record chart no longer stands over the tabs: it shows only in the `runs` tab, under its list. `test files to fix`, `slowest checks` and `longest runs` draw no chart.
20
+ - **Smaller monitor cards.** The `.wbar` meter is 40px, a meter's value cell takes the class `wv` (11px, tabular figures), and a widget card's padding and line height are tighter.
21
+ - **SK028 names both ways out.** Each finding asks for the screenshot, or for the rule turned off with its reason by the exact key: `when: { SK028: 'off:<why this repo shows no screenshot>' }` in repo.config.ts, which refuses an `off` with no reason.
22
+ - **SK016 says the real refusal condition and offers `--fix` only when it can fix.** The line reads `still read, and refused by the first shipkit release after \`shipkit fleet\` shows no repo below 0.40 and none still carrying a check script`, instead of `read through 0.40, refused after`. Its pointer says `shipkit check --fix SK016 rewrites N of them` while something is mechanical, and that the port is by hand when nothing is. A fix pointer that says `by hand` no longer gets the `· autofix:` suffix, the TODO placeholder warning included.
23
+ - **Fix: the overview never moves when the daemon or pm stops answering.** A process's note or a refused take over is a warn mark after its state words, the words on hover, instead of a line of its own. The daemon, the web or pm not answering reads `no answer` in its own row (row status `silent`, `CoreOpts.silent`), and while pm is silent every global process keeps its line with its state `unknown`. A widget error is a mark on its card's title. The table waits for pm's first answer and the list's order, so a cold load adds no row after its first paint. `bun run home:shift` now also refuses the daemon's and pm's answers in the browser and requires the table's height unchanged.
24
+ - **SK001 asks for `lint` and `fmt` only once the repo has a source file**, like `test` already waits for a test file. A source file is a code file outside installs, build output, dot dirs and `repo.config.ts`, with more in it than an empty `export {}`, or any test file.
25
+ - **Fix: the suite budget verdict is stable.** One continuous rule replaces the step at load == cores: the load is read before the run (never after this run's own parallel phase) and enters as a share of the cores; a suite is charged the smaller of its CPU and its wall, so bun's GC and JIT threads and side by side children are never billed as the suite's time, against a CPU allowance that grows with the load share up to 1.5 times the budget; a wait fails when the wall at a fair share of the machine passes twice the budget. A check's own budget warning (`passed in 12.0s, over its 10s budget`) is no longer printed while the load is at or past the core count, where the wall is the machine's. `SHIPKIT_MACHINE_LOAD=<load1>/<cores>` pins the reading for tests.
26
+ - **Fix: a `never:` check gets the subprocess budget.** A check that declares `never` reads the world like a `ttl` one, so its default budget is 10s instead of the 2s of an in process body: SK026, which runs each ask's own shell probe, no longer warns at 7s.
27
+ - **SK014 soft hits are a note.** With no `prose.baseline`, the soft classes print as one note, `N soft hit(s) need a human read (sentence 900, passive 400, modal 120, …)`, naming every soft class with its count, instead of a warning that named only two of them. Within a declared baseline it stays a note, above it the check fails with the same breakdown.
28
+ - **⌘J opens the picker and ⌘K the overview.** The lines on top of the project list read jump to… ⌘J, overview ⌘K, activity ⌘L, planned ⌘', and the picker lists home, activity and planned in that order.
29
+ - **SK009 checks the hook, not the history.** The commit-msg hook is what refuses AI attribution, so SK009 now checks in every repo that it is armed: `core.hooksPath` at `.shipkit/hooks`, or a foreign dir whose commit-msg chains the shipkit gate, and the finding prints the exact line to add (`<shipkit> hooks commit-msg "$1" || exit 1`). It no longer scans `git log`: what is pushed is accepted as it is. A declared-public repo still needs the `.claude/settings.json` silencer and a clean ref name; a telling branch nobody pushed warns as `local only` instead of failing. `hooks.disable: ['commit-message']` no longer lifts the attribution rule: it runs as its own `ai-attribution` gate, never disabled.
30
+ - **Fix: a gate never writes or commits in another repo.** `shipkit ci` and `check` used to rewrite the docsBase wiki's `local-projects.md` and commit it there ("chore(machine): refresh local-projects row"), so a narrowed gate in one repo landed commits in the house repo. They now file the row in the machine db only. The daemon's hourly sweep (`startSweeps` takes an `also`) runs `landMotherhome`, which writes every filed row into its page and lands it as one scoped commit, at most once a day per page (`LAND_GAP_MS`), with the same churn rule (data change or 7 days) and the same dead path pruning.
31
+ - **SK002 asks each contract only where it means something.** A public surface is a declared venue, `deploy.sh`, a GitHub Pages workflow, a wrangler config, or `visibility: 'public'` (unless an npm package is the repo's only surface). Before one exists the identity contract is silent instead of a warning, and after, it wants favicon, OG image and logo at the dir the deploy serves (a Pages artifact path, a wrangler output dir), overridden by the new `contracts.webRoot`, `public` otherwise; `shipkit assets identity` writes there too. An undeclared `.shipkit/social/` ships only once a post says `status: posted`: a folder of drafts is a note. A `discord` social channel no longer requires the discord server contract. Steam asks for `vdf/` only once `.shipkit/steam/steam_appid.txt` records the appid, and names that blocker before. Contract lines read `steam: …` instead of a double space. New detector `fileMatches(glob, pattern)`.
32
+ - **Fix: a remembered world answer says how old it is, never a clock time frozen into its text.** A `ttl` check's first line carried `(ran at 21:47:51, kept until 22:47:51)` in its stored text, which the next day still read as an expiry to come. The times now ride as data on the line (`Line.stamp`: `at`, `until`, `cached`), and every surface renders them against its own clock: `(ran just now, kept 1h more)`, `(cached, ran 3h ago, kept 57m more)`, `(ran 1d ago, expired 1d ago)`. `formatLine` takes an optional `now`, and `lineText`, `stampWords` and `spanShort` are exported from the wire module. An answer stored by an older build drops its baked times on replay.
33
+ - **Fix: every status surface shows every finding of a check, not the first one.** A failed build step that speaks the wire (the suite runner) keeps the FAIL lines its report closes on, and its headline counts them: `test — FAIL: 3 test(s) failed (+1 more below)`, the budget failure listed under it instead of hidden. The Activity page's hover list and `shipkit activity` list every fail and warn line of each check, the worst weight first (`StatusCheck.lines`), so SK009 shows its 17 attributed commits beside its ref name, and `tts` both of its warnings.
34
+ - **Fix: a non-blocking failure reads orange everywhere, as the gate prints it.** The run record filed a failure of a `blocking: false` check as `fail`, so Activity, the rail's dot and the check feed painted it red while the gate printed WARN and exited 0. The record now files it as `warn`, the status the gate reported.
35
+ - **Fix: SK027 no longer flags a working link.** A link that leaves `agent/` but stays in the repo (`../tools/readme.md`) is checked on disk and fails only when the file is really missing. A link to a folder (`[docs](external-docs/)`, or a backticked `agent/external-docs/`) reaches every page under it. An orphan page still fails. The dangling hint no longer tells anyone to backtick a working link. `scanMd` takes a `boundary`, the dir a ref may leave the scan root for.
36
+ - **SK027 says how to fix an orphan.** The finding asks for a markdown link from the page that should mention it, and names that page when one already names the file (its path, its file name, or a glob like `*_Compte_rendu_*`): `add a markdown link to it from agent/brief.md, which already names it`. Otherwise it points at `agent/index.md or the page of its topic`, and deleting comes second, for a dead page only. An `@./page.md` include now counts as a link.
37
+
38
+ ## 1.3.0
39
+
40
+ - **`shipkit ci` prints what the reader acts on, and nothing else.** A green run is one line per check that ran and one verdict line: `ok: test 920 tests (1.4s)` then `ok: 1 check green`, where it used to print nine. A test check carries its count on its own line. The whole gate folds its tier and cache counts into the verdict (`ok: 31 checks green; quick tier; 25 cached, 5 ran, 1 never cached`). A fix pass prints only when it rewrote files, and names them. A green check prints one line, a warned one only its warnings and their `fix:` pointers, a failure prints whole. `--only` says nothing about checks outside its selection (floor gaps, `off` checks, parked checks). `--verbose` restores every line the default leaves out: the clean fix pass, the machine pool, the tier, the cache declarations, the parked checks, the old summary and the cache counts. STATUS.md keeps them all, a release or bump gate still prints them all, and `--json` is unchanged.
41
+ - **Compact monitor cards.** A widget's leading `<header>` joins its card's title line, dim (`splitWidgetHead`), and `shipkit overview` prints it on the title line the same way. A card is as wide as its content, the owner repo moves to the hover, the `.wbar` meter is 56px, and `<table class="wmeters">` lays meters out two to a row, label, bar and value.
42
+ - **Fix: the planned page lists only what is actually planned.** A check waiting for its repo to be active is no longer listed as late: the page folds all of them into one line at the end (`waiting for their repo to be active: 24 checks in 9 repos`) that opens on a click, and `shipkit planned` prints the same line, listing them with `--all`. A check can declare `planOff: (env) => reason | null`, and the plan leaves it out of a repo where it has nothing to do: SK026 without an ask that carries a probe, SK025 without a unit that releases, SK005 without an rvlib dependency. A decided fact (`ttl: 'forever'`) with no answer under its key adopts, once, the newest clean answer stored under its older timed key for the same declared value, so SK007 stays known where it already answered.
43
+ - **Every section of a repo page has a key: ⌥ and its first letter.** ⌥C checks, ⌥R runs, ⌥A asks, ⌥T tests, ⌥M media, ⌥W wikis, ⌥D admin, read from the physical key so macOS's option characters and AZERTY keep them, printed as a badge at the end of each section line and on each section's entry in the ⌘K picker.
44
+ - **The open in VS Code button wears the VS Code mark, and it and open in Fork close the top bar at its far right end.**
45
+ - **The dock header: a cycling size button and small status pills.** The size is one button showing the current size, a dot per size and `⌘B`; a click or the key moves to the next (`CycleButton`, reusable). `runs, every repo`, the `(?)`, the in flight count and the size switch are gone. The status is a row of dim pills that never wraps: idle or running, queued, a warn pill when the plan holds, the last check with its repo icon, verdict, short id and age, the settling repos as one pill listing them on hover, and the window at slim and open; what does not fit folds into `+N`.
46
+ - **A repo page's sections are the top of its left pane, its runs are in the top bar, and the bar under the strip is gone.** Checks, runs, asks, tests, media, wikis and admin are one line each over the tree, in the project list's line style (icon, label, a count where it says something: red checks, open asks, red test files), the current one lit; the `resources` title is gone. The home strip carries the repo's run state (`idle`, or a spinner and what runs, cut, whole on hover) and two compact buttons, `health pass` and `gate`, each with a tooltip naming what it runs; at its right the repo's processes, its hub's trouble, `modernize` while behind, and two icons, open in VS Code and open in Fork. The bar never wraps: below a width the labels shrink to their icons, then the run state to its spinner. A hub of its own draws the same pieces in its own header, with its name.
47
+ - **An admin page per repo (`#page:admin`).** Reload the hub (the old `restart`: stops it in the daemon and loads it again, its config read fresh), keep active (the old `active` checkbox: the plan's hand set active flag) and archive, which now asks for a confirmation first, each with the sentence that says what it does. The three left the bar.
48
+ - **The tree's filter is quieter.** Its placeholder is `filter`, its key `/` sits in a small badge at its right the way the project list prints its keys, and the severity filter sits beside it.
49
+ - **The tests page leads with every test file, slowest first.** One list of every file of every test check: its check, its last duration against its budget, its status and its age, the never ran ones last, then the test checks and the failing files. The `tests` section line opens it. `/api/tests.json` carries it as `files`, merged from each check's own list.
50
+ - **The dock slides between its sizes.** `⌘B` and the size switch ease the boundary over 180ms instead of jumping; a drag, a window resize and the first paint stay instant, and `prefers-reduced-motion` turns the slide off.
51
+ - **The list's foot: a sort menu that names each sort, and an archived button beside it.** The sort button no longer sorts on a click: it names the order the list is in and opens a small menu of labeled sorts, `last activity`, `last commit`, `name` and `open asks`, the current one checked, and a pick sorts once and closes it. `manual order` is the state after a drag, never a pick. The `▸ archived (N)` fold became a small button with the archived count beside the sort button: a click shows the archived repos just above the foot, which never moves, and a second click hides them, kept per browser. `/api/projects.json` rows carry `commit`, the last commit time, for the new sort.
52
+ - **Fix: a daemon restart in place no longer loses a save burst, resets the catch up window, or retries a decided fact.** When a repo joins the plan, a file the owner wrote after its last run on record counts as a save, so the burst settles and runs. The catch up window's start lives in the machine db, and a boot within 10 minutes of the plan's last beat keeps it. A decided fact whose probe skipped retries 1 hour after that skip, doubled per skip in a row up to 24 hours, from the run record. What holds the plan (`held: load 9.2 on 8 cores`, `settling: my-app, 42s`) shows on the planned page, first in `shipkit planned`, in its `--json` as `held`, and at the end of the dock's thin line.
53
+ - **The dock never shows nothing, and the regions read apart.** `⌘B` cycles a one row line (`2 running · 5 queued · last: my-app typecheck ✓ 12s ago`, or `idle`), the slim chart and the open panel; a browser that kept the old hidden size gets the line. The project list, the top bars and the dock each wear a surface token a small step from the page (`--bg-side`, `--bg-top`, `--bg-dock`).
54
+ - **The dock: what shipkit runs in every repo, under the whole page, on every page.** One run chart docked at the bottom of the window, below the rail and the page, one group of lanes per house repo that ran something in the window, the repo's icon and name on the left. `⌘B` (Ctrl+B) cycles its sizes: slim, the last two minutes in thin lanes; open, the last ten with the run bands, the axis and the concurrency band against the cap, its height dragged and kept. The size is read from the browser before the first paint, so a reload moves nothing. It reads ONE route for every repo, `GET /api/house-runs.json`, read only from each repo's record and only when that record moved, asked every 2s while the dock shows and the tab is in front.
55
+ - **A repo's whole run record moved to the chart over its runs page.** The live strip under the header is gone, and so are its fold key `l` and the other repos' collapsed lanes in `/api/live.json`, which the dock replaces.
56
+ - **The overview's processes table reads tighter.** The version has its own column (empty when unknown), the process label column is narrow and cut with the whole label on hover, and each kind of button has its own column (declared actions, take over, restart, start or stop, open), so every start sits under every start and a line without a button keeps the space. A pid copies on a click and says `copied` for a moment (a refused clipboard is said in the strip). A url is a link, opened the way the pick says. The section titles are gone: the open pick and `new window` sit at the right of the table's header row. The widget cards are as wide as what they hold, from 260px, with tighter padding.
57
+ - **The overview is the monitor, then one processes table, shipkit's own group first.** pm, the daemon and the web are lines of the table in the same line UI as the global processes, under a `shipkit` group: dot, label with a `core` tag and the shipkit version, port, pid, uptime from the pid's start (a pm reload no longer resets the daemon's or the web's uptime), and the log on hover. Their stop is disabled (always on), the daemon and the web restart through pm, and pm has none: it is the LaunchAgent, its tooltip says so. The separate shipkit card is gone, the monitor widgets come first, and each group's name sits at the top of its group. pm's status carries `ports` and its own log `tail`, and every child's log tail. `shipkit gp` prints the same core group first, with versions (it asks the daemon's `/hub.json` too), and `shipkit overview` prints the monitor then the processes, as the page.
58
+ - **The daemon or pm down is said on a line at the bottom of the page, never over it.** The line is reserved whether or not it says anything, so an alert moves nothing, and it stays empty until a probe has actually failed.
59
+ - **Fix: the plan no longer keeps the machine busy.** A save burst is asked about once the repo has been quiet for 60 s (every 10 minutes at most for a repo never quiet), where it was every 250 ms pause. A check a save moved waits 20 times its last measured run after that run, so a heavy suite runs at most a few times an hour. Above a load average of 1 per core the plan starts nothing on its own (`Planner.held()` says why), and a click still runs at once. A check nothing can key that has a cadence (its `every`, its `cron`, or the default range) runs on that cadence only, never on every save. Measured on the house repo before the fix: 342 background gates in 2 hours, `test:rvlib-shipkit` 188 times, `pb-playground` 716 runs in a row under a rule that read every 20h..28h.
60
+ - **The home's console commands read as one family.** `shipkit activity` is the activity page's command, `shipkit cross-repo-activity` stays as its long name. `shipkit overview`, `shipkit activity`, `shipkit planned` and `shipkit global-processes` (`gp`) sit together under a `Home` group in `shipkit --help` and the command page. The activity page names its command in a dim footer line, as the overview and the plan do.
61
+ - **`shipkit overview`: the overview page in the console.** Its three sections from the answers the page reads: pm, the daemon and the web with pid, uptime and version (pm's status, the daemon's `/hub.json`), the global processes as `shipkit gp` prints them (one `globalProcessesAnswer`), and every monitor widget as text from the html the daemon last rendered (`/api/home/widgets.json`): tags stripped, a table's cells aligned, a `.wbar` meter drawn as `██████░░░░ 62%` (`src/home/widget-text.ts`). When pm or the daemon does not answer it says which and prints what is still known (the declared processes, their state unknown), and exits 0. `--json` gives `{ now, pm, daemon, processes, widgets }`, widgets the route's exact payload. pm's status carries `since`, so its own uptime shows. The overview names the command in a dim footer line.
62
+ - **The global pages are four lines on top of the project list.** Overview, jump to…, activity and planned each get a line in the repo rows' style, an icon, the label and the shortcut at the line's end, then a thin divider before the repos. The page shown is lit, and the jump to line while the picker is open. At the list's narrowest the labels truncate and the shortcuts stay.
63
+ - **`shipkit planned`: the planned page in the console.** It asks the daemon's `/api/home/planned.json`, the route the page polls, and draws the page's one list (now, late, next) with the page's own functions, now in `src/plan/plan-timeline.ts`: per row when (`in 4m`, `late 2h`), repo, check, its mark, its last result, then its title and the rule that planned it (`every 7d`, `on change (keyed on its files)`). The checks only a save runs are counted, `--all` lists them, `--repo <name>` keeps one repo, `--json` prints the route's exact payload. With the daemon down it says so and exits 0: the plan (its marks, the running batch) lives only there, so nothing is rebuilt from disk. A plan row carries its check's `title`. The planned page names the command in a dim footer line.
64
+ - **A global process's uptime survives a pm reload.** While a process is up with a pid, its uptime counts from when that pid started, as the OS says (`ps -o lstart=`), not from pm's last observed state change. The start is kept per pid: a pid pm spawned needs no `ps` at all, an adopted pid costs one `ps` at adoption, and a new pid (a restart) is read again. A process without a pid keeps `since` as before.
65
+ - **Fix: opening the overview, the picker, the activity or the plan no longer resets the selected repo to the house repo.** The repo list keeps marking the repo you were in, dimmer while a global page is up, and on the home no row is marked before a repo is picked. On a global page the ⌘K picker lists that repo too, since it is not on screen.
66
+ - **The open in Fork button leads each repo's commit list on the activity page.** It left the repo name cell for a narrow column of its own right before the commits, on the same line as the first commit (or the red uncommitted line), still dim until its row is hovered.
67
+ - **The ⌘K picker shows icons.** Every repo entry wears its repo icon (its letter tile when it has none), as the project list shows it, and home, planned, activity and archived wear the icons of their buttons. The order is unchanged. `bun run activity:check` checks it in headless Chrome, with the activity status dot.
68
+ - **The activity page names what is wrong behind each status dot.** A red or orange dot is a button: hovering it floats the failing and warning checks at once, failing first, each with its id, its title, how old that outcome is and the first line it printed about the problem. A click lists the same checks under the repo's row, so they read without hover (the macOS app shows no native tooltip), and a second click hides them. Each id links to that repo's check page. The daemon's activity answer carries them as `failing` on each row, titles from the plan (`Planner.titles`).
69
+ - **Fix: `bun run home:check` reported screenplay's tree as taking 20s on its first visit.** It loads in about 40ms from what was read ahead. The walk waited for a title starting with the home's key (`screenplay ·`), but the title carries the name the repo's own config declares (`screenplay-shed · hub`), so the wait ran to its timeout and the timeout was then reported as a load time. The walk now matches the shown repo by its home key, and reports a load time only for a tree that showed.
70
+ - **Fix: `bun run home:check` read the four buttons on top of the project list as missing.** Its probe quoted `⌘'` inside a single quoted string, the page could not parse it, and the walk's wait read the parse error as "not yet" for 20s. The expected labels are now quoted by `JSON.stringify`, and a probe that does not parse now throws instead of timing out.
71
+ - **A private repo commits its comfy drafts through a `.comfy-ts/` block.** The `blocked-paths` pre-commit gate lets `.comfy-ts/drafts/` through when the repo declares `visibility: 'private'`; `hosts/`, `cache/`, `outputs/` and every other `.comfy-ts/` path stay blocked. A public repo and a repo with no declared visibility keep the full block, and a prefix naming the drafts folder itself still blocks it. Before, a `.comfy-ts/` block contradicted the SK004 `!.comfy-ts/drafts/` re-include, and drafts stayed uncommitted forever. `blockedStaged` is exported from `hooks/run-hooks.ts`, and `shipkit hooks list` names the exemption.
72
+ - **A public repo and an npm unit keep their own files.** Two declared facts, each on its owner. The root unit's `release: { npm: true }` says a package strangers install and work on: SK001 asks only that it can publish (no `"private": true`, a `license`), never routes or renames its scripts, and its fixer writes nothing; SK005 names an rvlib bump and its command and never runs it; SK006 reads it as publishing by design; SK013 and SK017 report green and never write its `.claude/settings.json` or `.vscode/tasks.json`. The repo's `visibility: 'public'` says strangers read its tree: SK004 keeps `.comfy-ts/drafts/` ignored, and SK007 and SK009 read the declared visibility only (absent is private). A public repo with no npm target, like a site, keeps SK013 and SK017. A `shipkit ci` over a public fixture whose root unit declares npm, with its own cli, leaves package.json, `.claude/settings.json` and `.vscode/tasks.json` byte for byte (`tests/public-npm-repo.test.ts`, with a private app control that is rewritten).
73
+ - **The root unit's npm target is declared, never inferred.** A root whose package.json is not private and whose config says neither `release: { npm: true }` nor `npm: false` no longer publishes by default: resolution refuses and names both edits, the same rule sub units follow. So the release path and the checks read one fact.
74
+ - **`shipkit global-processes` (short: `shipkit gp`).** The overview's global processes table in the console, from pm's own answer: per repo, one line per process with its state (`up 2h`, `crashed 3m ago`, `stopped`), label, url, pid, note and last log line. `--json` gives the rows the overview draws. When pm is down it says so, lists the declared processes with their state unknown, and exits 0.
75
+ - **The global processes are one table, a line per process.** The overview's cards are gone: a row group per declaring repo (its icon and name in the first column, as on the activity page), then one line per process with its state dot, icon, label (its log on hover), url, `up 2h` or `crashed 3m ago`, and start or stop and open at the right. Restart, take over and declared actions stay small, shown only when they exist. A running line is tinted green, a crashed one red, and nothing scrolls sideways at phone width. pm's resource view carries `pid` and `since` (the last status change). The rows come from `src/home/process-rows.ts`, one model for the page and the console.
76
+ - **Fix: a link with no class of its own was the browser's dark blue on the dark page**, like `5 archived repos` on the overview. Every such link now takes the accent colour.
77
+ - **Fix: a repo's own cli is never taken for shipkit.** SK001 read any `bun <path>cli.ts` script as the in repo shipkit, so in a repo with its own cli (`bun src/cli/comfy-ts-cli.ts tui`) its fixer rerouted `test`, `lint` and `typecheck` to `bun src/cli/comfy-ts-cli.ts ci --only …`, a command that cli does not have, and SK013 pointed its guard beside it. Only a file named `cli.ts` under a directory whose name says shipkit counts now (`isShipkitCli`), and the `serve` alias WARN reads the same test.
78
+ - **Open a repo in Fork from the activity page.** A small button beside each repo name opens it in the Fork git client (macOS). The daemon takes a house repo name only, refuses an archived one, and says so when Fork is not installed.
79
+ - **`shipkit cross-repo-activity`.** The activity page in the console, from the same computation: every house repo's checks, branch, upstream, uncommitted work and commits in a window (`--max-age 30m`, `--max-commit 3`), or the route's exact payload with `--json`. Local git only, no daemon needed.
80
+ - **Fix: the activity page and the status dot read what is true now.** An activity answer older than 30s is never served (a page opened hours later showed hours old rows), a tab coming back no longer forces a git pass, and a repo's status dot counts only the checks it declares today, so a renamed or deleted check no longer holds it red. The hover says how long each check has been red.
81
+ - **The activity page reads faster.** Six repos at a time, and the git asks inside a repo start together: 0.5s to 0.22s for 19 repos on an idle machine, with a larger gain while the daemon is busy.
82
+ - **The activity page has a status column before the branch.** One dot per repo from its own run record: red with the number of failing checks, orange with the number of warnings, green when every check passed, grey when shipkit never ran there. The check ids show on hover. The project rows carry it as `checks`.
83
+ - **The activity page shows only `main` green.** A repo on `master` is orange like any other branch, so a repo still to rename stands out.
84
+ - **New built-in SK028 readme, non-blocking.** A repo's `README.md` exists and shows at least one screenshot: a markdown or html image whose source is a file in the repo or an https url (a status badge does not count). Each requirement reports its own line, so a new mandatory README item is one more entry. Keyed on the README and the images it names, so it runs on change only.
85
+ - **No built-in rides the daily default range any more.** SK008, SK009, SK015, SK022 and SK024 each declare what their answer depends on and run only when it moves: SK008 on repo.config.ts, the agent definition and the brief (by content, inside the repo or not), SK009 on HEAD, every ref and the settings file (a commit, a fetch or a tag re-asks it, since the save watch now hears a ref move inside `.git`), SK015 on the config and where the work tree root sits, SK022 on package.json, the installed copy and the running version, SK024 on the config and the installed config source. The 20h..28h range is now for a REPO check that declares nothing, and `shipkit keys --burn-down` WARNs on each one (a built-in there is a FAIL line). A guard test fails the toolkit on any built-in without a key, a `ttl` or an `every`.
86
+ - **SK008 is titled "reviewer definition".** It lints the agent definition file and never starts an agent, so the planned page no longer reads as a scheduled review.
87
+ - **A sweep guard: nothing starts an agent on its own.** `agentSpawnIn` (`src/core/agent-spawn.ts`) finds a spawn or exec of `claude`, `claude -p`, or an import of the agent sdk, comments skipped. A test runs it over every source file, beside the samples it must catch and the lookalikes it must let through (a url, `~/.claude/projects`, a help line naming the product).
88
+ - **A repo page with no row picked shows its README, images included.** The first of `README.md`, `readme.md`, `Readme.md`, `README.markdown` or `README` at the repo root, rendered as markdown. A relative image loads through the new `GET /api/readme-file/<path>`, which serves only an image file inside the repo. An http(s) image such as a badge loads as written. A repo without a README keeps the hint.
89
+ - **An archived repo is processed by nothing but its own page.** `houseRepos()` now splits `archived: true` repos out, so the rail, the daemon (hub, watch, plan rows, gates, processes, widgets, identity fill, store sweep), the activity page, pm's global processes and `shipkit fleet` never see one. The project list's folded archived group is gone. `/archived` (the picker's `archived`, and a link at the foot of the overview while one exists) lists them, each with `unarchive`. `/api/projects.json` answers `archived: [{ name, icon }]` beside `projects`, and a row no longer carries `archived`.
90
+ - **Archive a repo: `archived: true` in repo.config.ts.** It stays known and listed (dimmed, in a folded group at the bottom of the project list, out of the ⌘ numbering), and nothing runs there: the daemon never loads its hub, so no file watch, no plan rows, no automatic gate, no repo process, no widget or global process, no identity fill, no store sweep. The `archive` button in the repo header toggles it and moves only that key (`POST /api/projects/archived`, `src/core/archived.ts`). Archiving a loaded repo stops it on the next tick; unarchiving loads it again.
91
+ - **Global process cards: start and open, grouped by repo, tinted by state.** A card has two buttons, `start` (`stop` while it runs) and `open`; restart, take over and declared actions stay as small controls where they apply. Cards group under their declaring repo (its icon, its identity colour) in the project list's order, are smaller, and an up card is tinted green, a crashed one red. Where `open` opens is one icon pick in the section's title row, the default browser or a shipkit app window, kept per browser. New `globalProcess({ icon })`: an emoji or a repo-relative image path, served by pm at `/api/pm/icon/<id>`; without one the card wears its repo's icon. Breaking: `app: true` is removed, since the app window opens any url; pm's `act: 'app'` works for every process with a url.
92
+ - **New built-ins SK026 asks auto close and SK027 agent wiki links.** Every repo with a `.shipkit/asks/` queue now archives the asks whose own `check:` probe passes (an open queue is no warning; a frozen run archives nothing). Every repo with `agent/index.md` fails an orphan page or a dangling ref there. A repo without the queue or the wiki skips.
93
+ - **Retired check ids are listed** (`src/core/retired-checks.ts`), and discovery refuses a built-in that takes one again.
94
+ - **Breaking: the `contracts` check is gone, SK002 is the one row for the release contracts.** Both ran the same scan, so every repo showed one invariant twice. A venue deploy step declares `deps: ['SK002']` instead of `deps: ['contracts']`.
95
+ - **New built-in SK025 release drift.** Every unit a repo releases matches npm and its push remote, the survey `shipkit releases` prints, as warnings. A repo that releases nothing (a private root with no release config and no units) skips it. A clean answer keeps 20h, keyed on every unit's version. It replaces the drift check a repo kept per published lib.
96
+ - **Breaking: SK021 (github login) and SK023 (stores under cap) are no longer built-ins.** Both ask about the machine, never the repo, so every repo re-asked one fact. The gh probe and its `core` credential row stay in the lib as `src/core/gh-login.ts` (`probeGhLogin`, `GH_LOGIN_CHECK`): a house repo declares the one check that runs it. The store caps keep their measure and sweep in `src/core/retention.ts` (`capWords` joins them), for a house check that measures every repo in one pass.
97
+ - **`shipkit assets check` no longer probes the comfy backend.** Whether a box answers is a machine fact, never a question about a repo's kit.
98
+ - **An activity page (`/activity`, ⌘L).** One table of every house repo: its branch (green on main, orange elsewhere) and a red line of what it holds uncommitted (the paths on hover). Beside them, its commits in a window you pick (10m to 7d, 30m by default), newest first, each with a green ✓ once pushed or an orange ↑ until then. A number sets how many commits a repo shows (1 to 15, 3 by default). It reads again every minute while shown, and the countdown is a button that reads at once. The counts beside it are pills. A repo with nothing in the window still shows its last commit, in grey, and quiet repos sit below the busy ones. The daemon reads it with local git only while the page is open and visible, never with a fetch.
99
+ - **New keys, and a picker button.** ⌘J the overview, ⌘K the picker, ⌘L the activity, ⌘' the plan, four buttons on top of the project list in that order. The sort by last activity moves to the foot of the list, and above it a fold lists the archived repos by name, each a link to the archived page.
100
+ - **The house repo is active whenever any house repo is.** Its checks serve every repo, so a `wait` check there no longer turns late while you work elsewhere.
101
+ - **`ttl: 'forever'` for a decided fact.** A fact that moves only when someone decides to move it (a repo's visibility, an account that exists) keeps its clean answer for good, keyed on its declared value alone (`cacheKey`), so a new shipkit asks nothing again, and the store sweep never drops it. The plan reads it as the new `every: 'once'`: it runs only while no answer exists for its current key (first time, a declaration edit, a lost db), never on a clock.
102
+ - **SK007 is a decided fact and no longer blocks.** Its clean answer is kept until `visibility` changes, and a mismatch warns: a gate block does not un-leak a repo.
103
+ - **A check keyed on its files runs on change only.** With no `every` declared, a check with `inputs`, or one keyed on what it read through `c`, no longer runs on the daily default range: its answer replays until a file moves. Only a check nothing can key keeps that range, and a `ttl` still reads as `every`.
104
+
1
105
  ## 1.2.0
2
106
 
3
107
  - **Fix: SK024 read back only an `identity` key that starts a line.** On a one line config its own fix wrote the key inline, the check kept warning, and every fix pass added the key again, so a release gate always rewrote `repo.config.ts`. The key is now found at the top level of `defineRepo({…})` wherever it sits, bare or quoted, and a nested `identity` (an SK002 exemption) or one in a string or comment is not it.
package/README.md CHANGED
@@ -81,7 +81,7 @@ Output is a stable line protocol — `ok:` / `WARN` / `FAIL:` / `skip:` with ind
81
81
 
82
82
  ## Release contracts
83
83
 
84
- A venue contract is data: required files, exact PNG dimensions (header-parsed, no image dependency), glob minimums, required markdown sections. Enforcement is both ways — a declared venue with an incomplete folder fails, and an undeclared venue whose signals fire (deploy scripts, folders) fails too. `exempt: { steam: "reason" }` skips visibly. Wire venue deploys as `deps: ['contracts']` and an incomplete folder physically blocks the upload.
84
+ A venue contract is data: required files, exact PNG dimensions (header-parsed, no image dependency), glob minimums, required markdown sections. Enforcement is both ways — a declared venue with an incomplete folder fails, and an undeclared venue whose signals fire (deploy scripts, folders) fails too. `exempt: { steam: "reason" }` skips visibly. Wire venue deploys as `deps: ['SK002']` and an incomplete folder physically blocks the upload.
85
85
 
86
86
  Ship your own contracts via `contracts: { extra: [defineContract({...})] }` — see `rvlib-shipkit/contracts` for the rule vocabulary.
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "1.2.0",
3
+ "version": "1.4.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",
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFile
9
9
  import { atomicWrite, mutateLease, withMutateLease } from '../core/locked-write.ts'
10
10
  import { join } from 'path'
11
11
 
12
- export type Ask = { file: string; status: string; kind: string; created: string; blocks: string; check: string | null; title: string }
12
+ export type Ask = { file: string; status: string; kind: string; created: string; closed: string | null; blocks: string; check: string | null; title: string }
13
13
  export type CreateOpts = { kind?: string; blocks?: string; check?: string; why?: string }
14
14
 
15
15
  /** filename slug: kebab, truncated at a WORD boundary (never mid-word — a
@@ -31,6 +31,7 @@ const parse = (dir: string, file: string): Ask => {
31
31
  status: fm('status') ?? 'open',
32
32
  kind: fm('kind') ?? 'action',
33
33
  created: fm('created') ?? '?',
34
+ closed: fm('closed'),
34
35
  blocks: fm('blocks') ?? '-',
35
36
  check: fm('check'),
36
37
  title: md.match(/^# (.+)$/m)?.[1] ?? file,
@@ -73,7 +74,8 @@ function checkAsksBody(dir: string, print: (line: string) => void): { closed: nu
73
74
  for (const a of listAsks(dir)) {
74
75
  if (a.status === 'done') { renameSync(join(dir, a.file), join(done, a.file)); print(`archived ${a.file} → done/`); continue }
75
76
  if (a.check == null) { print(`still ${a.status.padEnd(8)} ${a.file} (manual — no probe)`); continue }
76
- const p = Bun.spawnSync(['bash', '-lc', a.check])
77
+ // env named: bun's default is the env the process started with, blind to a later process.env write
78
+ const p = Bun.spawnSync(['bash', '-lc', a.check], { env: process.env })
77
79
  if (p.exitCode === 0) {
78
80
  const md = readFileSync(join(dir, a.file), 'utf8').replace(/^status:.*$/m, `status: done\nclosed: ${new Date().toISOString().slice(0, 10)}`)
79
81
  atomicWrite(join(dir, a.file), md)
@@ -7,9 +7,9 @@
7
7
  // hook, so picking a logo roll re-renders the whole identity set from the
8
8
  // picked png. Headless `identity` uses the current pick — no pick yet is a
9
9
  // loud FAIL, never a placeholder.
10
- // Outputs are COMMITTED: public/favicon.png (512² master), public/og.png
11
- // (1200×630), public/logo.png. Brand data is per-repo and never defaults
12
- // across repos.
10
+ // Outputs are COMMITTED at the web root (public/ unless the deploy says
11
+ // otherwise): favicon.png (512² master), og.png (1200×630), logo.png. Brand
12
+ // data is per-repo and never defaults across repos.
13
13
  import { createCanvas, loadImage, type Image, type SKRSContext2D } from '@napi-rs/canvas'
14
14
  import { mkdirSync } from 'fs'
15
15
  import { dirname, join } from 'path'
@@ -81,11 +81,13 @@ const defaultOg = (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: numbe
81
81
  }
82
82
 
83
83
  /** THE identity writer — any mark png (canvas draw or picked comfy roll) →
84
- * public/logo.png (1024² master) + public/favicon.png (512² resize) +
85
- * public/og.png (default card: bg + mark left + name/tagline right). */
86
- export async function renderIdentityFromMark(brand: Brand, root: string, markPng: Buffer): Promise<string[]> {
84
+ * <web>/logo.png (1024² master) + <web>/favicon.png (512² resize) +
85
+ * <web>/og.png (default card: bg + mark left + name/tagline right), where
86
+ * <web> is the dir the site serves (contracts/web-surface.ts, default public) */
87
+ export async function renderIdentityFromMark(brand: Brand, root: string, markPng: Buffer, webRoot = 'public'): Promise<string[]> {
87
88
  const out: string[] = []
88
- const write = async (rel: string, buf: Buffer): Promise<void> => {
89
+ const write = async (name: string, buf: Buffer): Promise<void> => {
90
+ const rel = webRoot === '.' ? name : `${webRoot}/${name}`
89
91
  const path = join(root, rel)
90
92
  mkdirSync(dirname(path), { recursive: true })
91
93
  await Bun.write(path, buf)
@@ -94,26 +96,26 @@ export async function renderIdentityFromMark(brand: Brand, root: string, markPng
94
96
  const logoPng = await sharp(markPng)
95
97
  .resize(LOGO_SIZE, LOGO_SIZE, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
96
98
  .png().toBuffer()
97
- await write('public/logo.png', logoPng)
99
+ await write('logo.png', logoPng)
98
100
  // favicon = logo master resized to 512² (browsers derive the rest)
99
- await write('public/favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
101
+ await write('favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
100
102
  const og = createCanvas(OG_W, OG_H)
101
103
  const ctx = og.getContext('2d')
102
104
  if (brand.drawOg != null) brand.drawOg(ctx, brand, { w: OG_W, h: OG_H })
103
105
  else defaultOg(ctx, brand, { w: OG_W, h: OG_H }, await loadImage(logoPng))
104
- await write('public/og.png', og.toBuffer('image/png'))
106
+ await write('og.png', og.toBuffer('image/png'))
105
107
  return out
106
108
  }
107
109
 
108
110
  /** Headless render of the full identity set. Canvas marks draw themselves;
109
111
  * comfy marks need `markFile` = the PICKED roll (the caller resolves the
110
112
  * pick — run-assets does) and FAIL loud without one. */
111
- export async function renderIdentity(brand: Brand, root: string, opts?: { markFile?: string }): Promise<string[]> {
113
+ export async function renderIdentity(brand: Brand, root: string, opts?: { markFile?: string; webRoot?: string }): Promise<string[]> {
112
114
  if (brand.mark.backend === 'canvas') {
113
115
  const logo = createCanvas(LOGO_SIZE, LOGO_SIZE)
114
116
  await brand.mark.draw(logo.getContext('2d'), { id: 'identity-logo', prompt: '' })
115
- return renderIdentityFromMark(brand, root, logo.toBuffer('image/png'))
117
+ return renderIdentityFromMark(brand, root, logo.toBuffer('image/png'), opts?.webRoot)
116
118
  }
117
119
  if (opts?.markFile == null) throw new Error(`brand '${brand.name}' uses a comfy mark and no roll is picked — ${NO_PICK_FIX}`)
118
- return renderIdentityFromMark(brand, root, await sharp(opts.markFile).png().toBuffer())
120
+ return renderIdentityFromMark(brand, root, await sharp(opts.markFile).png().toBuffer(), opts.webRoot)
119
121
  }
@@ -8,6 +8,9 @@ import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-
8
8
  import { parentIdsOf, resolveParentContext, topoSort, type ParentContext } from './gen/derived.ts'
9
9
  import { allAssets, findAsset, loadKit, type ComfyGen, type ResolvedKit } from './kit-config.ts'
10
10
  import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
11
+ import { webDeploys, webRootOf } from '../contracts/web-surface.ts'
12
+ import { CheckCtx } from '../core/check-ctx.ts'
13
+ import { defineRepo, loadRepoConfig } from '../repo-config.ts'
11
14
  import { assetDir, listRolls, readPick, readPromptOverlay } from './roll-workspace.ts'
12
15
 
13
16
  export type Print = (line: string) => void
@@ -111,6 +114,12 @@ const brandHashPath = (root: string): string => join(root, '.shipkit/identity/br
111
114
  const brandHash = async (root: string): Promise<string> =>
112
115
  new Bun.CryptoHasher('sha256').update(await Bun.file(join(root, '.shipkit/identity/brand.ts')).text()).digest('hex').slice(0, 16)
113
116
 
117
+ /** the dir the identity outputs go to: the same answer SK002 reads */
118
+ async function repoWebRoot(root: string): Promise<string> {
119
+ const config = existsSync(join(root, 'repo.config.ts')) ? await loadRepoConfig(root) : defineRepo({ name: 'repo' })
120
+ return webRootOf(config.contracts?.webRoot, webDeploys(CheckCtx.open({ root, config })))
121
+ }
122
+
114
123
  export type IdentityRender = { written: string[] } | { blocked: string }
115
124
 
116
125
  export async function renderIdentityNow(root: string): Promise<IdentityRender> {
@@ -133,7 +142,7 @@ export async function renderIdentityNow(root: string): Promise<IdentityRender> {
133
142
  markFile = join(dir, pick.pick)
134
143
  if (!existsSync(markFile)) return { blocked: `picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio` }
135
144
  }
136
- const written = await renderIdentity(brand, root, { markFile })
145
+ const written = await renderIdentity(brand, root, { markFile, webRoot: await repoWebRoot(root) })
137
146
  await Bun.write(brandHashPath(root), (await brandHash(root)) + '\n')
138
147
  return { written }
139
148
  }
@@ -142,7 +151,8 @@ export async function renderIdentityNow(root: string): Promise<IdentityRender> {
142
151
  * Returns what happened; 'blocked' = only a human pick can clear it. */
143
152
  export async function healIdentity(root: string): Promise<{ state: 'fresh' | 'rendered' | 'blocked' | 'absent'; detail?: string }> {
144
153
  if (!existsSync(join(root, '.shipkit/identity/brand.ts'))) return { state: 'absent' }
145
- const outputsMissing = ['public/favicon.png', 'public/og.png', 'public/logo.png'].some((p) => !existsSync(join(root, p)))
154
+ const web = await repoWebRoot(root)
155
+ const outputsMissing = ['favicon.png', 'og.png', 'logo.png'].some((p) => !existsSync(join(root, web, p)))
146
156
  const stored = existsSync(brandHashPath(root)) ? (await Bun.file(brandHashPath(root)).text()).trim() : null
147
157
  if (!outputsMissing && stored === (await brandHash(root))) return { state: 'fresh' }
148
158
  const r = await renderIdentityNow(root)
@@ -236,14 +246,9 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
236
246
  io.print(`WARN no schema cache for host '${lw.wf.host.data.id}' — bunx comfy-ts gen --id ${lw.wf.host.data.id} --host ${lw.wf.host.getServerHostHTTP()}`)
237
247
  }
238
248
  }
239
- try {
240
- for (const url of new Set([...wfs.values()].map((lw) => lw.wf.host.getServerHostHTTP()))) {
241
- const up = await fetch(`${url}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
242
- io.print(up ? `ok: comfy backend reachable (${url})` : `WARN comfy backend unreachable (${url})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
243
- }
244
- } finally {
245
- disconnectAll({ workflows: wfs })
246
- }
249
+ // whether the backend answers is a machine fact the house repo's
250
+ // box check owns, never a question about this repo's kit
251
+ disconnectAll({ workflows: wfs })
247
252
  }
248
253
  }
249
254
  // machine-clearable red clears itself: drifted/missing identity
package/src/check.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  import type { CheckCtx } from './core/check-ctx.ts'
22
22
  import { parseWhen } from './core/when.ts'
23
23
  import { planDeclProblem, type Every, type WhenInactiveDecl } from './plan/cadence.ts'
24
- import type { Line } from './core/wire.ts'
24
+ import { FOREVER, parseDuration, type Line } from './core/wire.ts'
25
25
  import type { RepoConfig } from './repo-config.ts'
26
26
 
27
27
  export { defineScan, mdPages, packageJson, type MdPage, type PackageJson, type Scan } from './scan.ts'
@@ -98,7 +98,10 @@ export type CheckDef = {
98
98
  * correctness. At most one of the three. */
99
99
  inputs?: string[]
100
100
  /** WORLD population: nothing on disk describes the answer, so a clean
101
- * pass keeps for this long, as in '24h'. */
101
+ * pass keeps for this long, as in '24h'. `'forever'` for a DECIDED fact
102
+ * (a repo's visibility, an account that exists): a clean answer never
103
+ * expires, it is keyed on the declared value alone (`cacheKey`), and the
104
+ * plan runs it only while no answer exists for that key. */
102
105
  ttl?: string
103
106
  /** a DECIDED never-cacheable, with the reason. Says somebody looked and
104
107
  * no key can describe this, which takes it off the burn-down list. */
@@ -112,13 +115,16 @@ export type CheckDef = {
112
115
  /** WHEN the planner runs it on its own, beside the save that moved what
113
116
  * it reads: a span since its last run (`'24h'`), a range (`['10h',
114
117
  * '12h']`, each repo and check on a stable point inside it, so checks
115
- * spread out), a cron expression (`'0 9 * * 1'`), or null for on change
116
- * only. Or a PURE function of its last result (`status`, `at`, `streak`
118
+ * spread out), a cron expression (`'0 9 * * 1'`), `'once'` (only while
119
+ * no answer exists: what `ttl: 'forever'` reads as), or null for on
120
+ * change only. Or a PURE function of its last result (`status`, `at`, `streak`
117
121
  * of that same status) and whether the repo is active, returning any of
118
122
  * those: `(last) => (last?.status === 'fail' ? '1h' : '7d')`. A lambda
119
123
  * that throws is an error row on the planned page, and the check runs on
120
- * change only. Absent: its `ttl`, else a build step waits for a save and
121
- * any other check runs every 24h. */
124
+ * change only. Absent: its `ttl`, else a build step or a check keyed on
125
+ * its files waits for a save. A repo check nothing can key runs about
126
+ * once a day until it declares one, and a built-in never may: `shipkit
127
+ * keys --burn-down` names both. */
122
128
  every?: Every
123
129
  /** a time of day or week for the rare check that needs one, as five cron
124
130
  * fields in local time (`'0 9 * * 1'`, Mondays at 9). Never with `every`. */
@@ -130,6 +136,12 @@ export type CheckDef = {
130
136
  * working). A pure function of the last result may choose. Default
131
137
  * `'wait'`. */
132
138
  whenInactive?: WhenInactiveDecl
139
+ /** why this repo gives the check nothing to do on a clock, or null: the
140
+ * plan leaves it out there (no row, no timer), and a gate or a health
141
+ * pass still runs it. Read at every reload of the repo's checks, so a
142
+ * file that gives it work (an ask with a probe, a release unit) plans it
143
+ * again. Cheap and pure over the repo's files and config */
144
+ planOff?: (env: CheckEnv) => string | null
133
145
  /** cache-identity contribution for a `ttl` check whose verdict depends on
134
146
  * config: a changed key busts the answer instantly instead of serving
135
147
  * a stale cached assertion for the rest of the TTL. */
@@ -148,7 +160,8 @@ export type CheckDef = {
148
160
  * passes over it prints a WARN and reads yellow, and never blocks.
149
161
  * Judged on the wall time of a run that measured, at the quick tier.
150
162
  * Default 2000 for an in process body, 10000 for a build step, a `cmd`
151
- * or a world probe (core/budget.ts). The repo's `budgets` wins over it. */
163
+ * or a world probe (one that declares `ttl` or `never`, core/budget.ts).
164
+ * The repo's `budgets` wins over it. */
152
165
  budgetMs?: number
153
166
  /** a label several checks share, so `--skip <group>` parks all of them
154
167
  * and `--only <group>` runs all of them. */
@@ -201,6 +214,8 @@ export function check(d: CheckDef): CheckDef {
201
214
  const populations = (['inputs', 'ttl', 'never'] as const).filter((k) => has(k))
202
215
  if (populations.length > 1)
203
216
  throw new Error(`check${d.id == null ? '' : ` ${d.id}`}: ${populations.join(' and ')} contradict each other — a check reads files, or the world, or nothing can describe it`)
217
+ if (d.ttl != null && d.ttl !== FOREVER)
218
+ try { parseDuration(d.ttl) } catch { throw new Error(`check${name}: ttl '${d.ttl}' is neither a span ('7d') nor 'forever'`) }
204
219
  const planBad = planDeclProblem({ every: d.every, cron: d.cron, whenInactive: d.whenInactive })
205
220
  if (planBad != null) throw new Error(`check${name}: ${planBad}`)
206
221
  if (d.when != null) {
@@ -359,12 +374,13 @@ export async function runCheckBody(
359
374
  }
360
375
 
361
376
  /** tag every severity line with the check id; a fix pointer of a fixable check
362
- * advertises the autofix. Idempotent, since an already-tagged line passes
363
- * back through here on a cached suite replay. */
377
+ * advertises the autofix, unless it names its own `--fix` or says the repair
378
+ * is `by hand`. Idempotent, since an already-tagged line passes back through
379
+ * here on a cached suite replay. */
364
380
  export function tagCheckLines(u: { id: string; fix?: unknown }, lines: Line[]): Line[] {
365
381
  return lines.map((l) => {
366
382
  if (l.kind === 'fix-warn' || l.kind === 'fix-fail')
367
- return u.fix == null || l.text.includes('--fix ')
383
+ return u.fix == null || l.text.includes('--fix ') || /\bby hand\b/.test(l.text)
368
384
  ? l
369
385
  : { ...l, text: `${l.text} · autofix: shipkit check --fix ${u.id}` }
370
386
  if (l.kind === 'note') return l
@@ -13,6 +13,7 @@
13
13
  import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
14
14
  import type { CheckCtx } from '../core/check-ctx.ts'
15
15
  import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
16
+ import { rootPublishesNpm } from '../core/repo-facts.ts'
16
17
  import type { RepoConfig } from '../repo-config.ts'
17
18
  import { check, type FixResult } from '../check.ts'
18
19
 
@@ -24,7 +25,7 @@ const OWNER = 'meta/rv-conventions/package-json.md'
24
25
  // alias really is the canonical capability (a `serve` that serves a dist/
25
26
  // folder must not be told to rename)
26
27
  const ALIASES: Record<string, { canonical: string; bodyMatch: RegExp }> = {
27
- 'serve': { canonical: 'hub', bodyMatch: /(shipkit|cli\.ts) serve\b/ },
28
+ 'serve': { canonical: 'hub', bodyMatch: /(?:^|\s)(?:shipkit|\S*shipkit\S*\/cli\.ts) serve\b/ },
28
29
  'pretty': { canonical: 'fmt', bodyMatch: /./ },
29
30
  'check-types': { canonical: 'typecheck', bodyMatch: /./ },
30
31
  }
@@ -46,6 +47,15 @@ export function evaluatePackageJson(t: CheckCtx): void {
46
47
  return
47
48
  }
48
49
  const scripts = pkg.scripts ?? {}
50
+ // a package whose root unit declares npm is a public contract: it
51
+ // publishes, so the app field rules invert, and its script names are its
52
+ // own vocabulary, which no house name or route may rewrite
53
+ if (rootPublishesNpm(c)) {
54
+ if (pkg.private === true) t.fail(`package.json: the root unit declares release.npm, and "private": true makes npm refuse the publish`)
55
+ if (pkg.license == null) t.fail(`package.json: a package published to npm declares its license`)
56
+ if (!t.failed) t.ok(`package.json shape ok (published to npm: publishable, license ${pkg.license})`)
57
+ return
58
+ }
49
59
  // rvlib-* as a probe ROOT is theoretical (libs live as sub-packages and
50
60
  // the probe only runs where repo.config.ts loads) — field inversion is
51
61
  // still handled so a future lib-root repo fails correctly, not absurdly
@@ -97,8 +107,11 @@ export function evaluatePackageJson(t: CheckCtx): void {
97
107
 
98
108
  // `test` is asked for only once the repo HAS tests: the fixer deliberately
99
109
  // refuses to scaffold it before that (a fixer never manufactures a
100
- // failure), so nagging for it would be a WARN no machine may clear
101
- const baseline = BASELINE.filter((n) => n !== 'test' || hasTestFiles(testFiles(t)))
110
+ // failure), so nagging for it would be a WARN no machine may clear. For
111
+ // the same reason `lint` and `fmt` wait for a source file to read
112
+ const tests = hasTestFiles(testFiles(t))
113
+ const source = tests || hasSourceFiles(t)
114
+ const baseline = BASELINE.filter((n) => (n === 'test' ? tests : source))
102
115
  const missingBaseline = baseline.filter((n) => !has(n))
103
116
  if (missingBaseline.length > 0)
104
117
  t.warn(`package.json: missing baseline script(s): ${missingBaseline.join(', ')}`)
@@ -163,6 +176,21 @@ export function hasTestFiles(paths: readonly string[]): boolean {
163
176
  })
164
177
  }
165
178
 
179
+ const SOURCE_GLOB = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'
180
+ /** an empty module: nothing but `export {}`, semicolons and whitespace */
181
+ const EMPTY_MODULE = /^(?:\s|;|export\s*\{\s*\})*$/
182
+
183
+ /** a code file with something in it, outside installs, build output, dot dirs
184
+ * and the repo config: what lint and fmt would read. Stops at the first */
185
+ function hasSourceFiles(c: CheckCtx): boolean {
186
+ for (const f of c.list(SOURCE_GLOB)) {
187
+ const parts = f.split('/')
188
+ if (f === 'repo.config.ts' || !parts.slice(0, -1).every((d) => !TEST_SKIP.has(d) && !d.startsWith('.'))) continue
189
+ if (!EMPTY_MODULE.test(c.read(f) ?? '')) return true
190
+ }
191
+ return false
192
+ }
193
+
166
194
  export const packageJsonShape = check({
167
195
  id: 'SK001',
168
196
  blocking: true,
@@ -177,14 +205,15 @@ export const packageJsonShape = check({
177
205
  fix: (c): FixResult => {
178
206
  const raw = c.read('package.json')
179
207
  if (raw == null) return { changed: false, summary: 'no package.json — nothing to fix' }
208
+ if (rootPublishesNpm(c.config)) return { changed: false, summary: 'published to npm: its package.json is its public contract, never rewritten' }
180
209
  let pkg: Pkg & { scripts?: Record<string, string> }
181
210
  try { pkg = JSON.parse(raw) as Pkg } catch { return { changed: false, summary: 'package.json unparseable — repair it by hand first' } }
182
211
  if (pkg.name?.startsWith('rvlib-') === true) return { changed: false, summary: 'rvlib-* package — license/private are publish decisions, not autofixable' }
183
212
  const changes: string[] = []
184
213
  const notes: string[] = []
185
214
  // any "I am published" marker → the field flips are a publish
186
- // decision, not a mechanical repair (the LIBRARY-profile gap in
187
- // meta/gaps.md is still open)
215
+ // decision, not a mechanical repair (a package that publishes says so
216
+ // with release.npm, which skips this fixer outright)
188
217
  const publishMarkers: string[] = ['files', 'exports', 'publishConfig'].filter((k) => (pkg as Record<string, unknown>)[k] != null)
189
218
  if (pkg.scripts?.['prepublishOnly'] != null) publishMarkers.push('prepublishOnly')
190
219
  if (publishMarkers.length > 0) {
@@ -2,9 +2,9 @@
2
2
  // registration point; the evaluation machinery stays in
3
3
  // contracts/contracts-engine.ts (venue manifests are DATA rows, not rules —
4
4
  // one SK id covers the invariant "declared contracts are fulfilled, folders
5
- // can't rot undeclared"). Every surface reporting fulfillness (`check`,
6
- // `shipkit contracts`, the ci `contracts` check) tags with this id via
7
- // contracts/apply-contracts.ts.
5
+ // can't rot undeclared"). Every surface reporting fulfillness (`check`, `ci`,
6
+ // `shipkit contracts`) tags with this id via contracts/apply-contracts.ts. A
7
+ // venue deploy step deps on it BY ID (`deps: ['SK002']`).
8
8
  import { applyContractsResult, contractsResult } from '../contracts/apply-contracts.ts'
9
9
  import { check } from '../check.ts'
10
10
 
@@ -16,6 +16,7 @@
16
16
  // `c.exec` is never stored on its reads.
17
17
  import { readdirSync } from 'fs'
18
18
  import type { RepoConfig } from '../repo-config.ts'
19
+ import { declaredPublic } from '../core/repo-facts.ts'
19
20
  import { GENERATED_IGNORE_LINES, GENERATED_TRACKED_PATHS, untrackGenerated } from '../rules/untrack-generated.ts'
20
21
  import { check, type CheckCtx, type FixResult } from '../check.ts'
21
22
  import { gitBin } from '../core/git-bin.ts'
@@ -29,6 +30,8 @@ const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit ou
29
30
  const END = '# <<< shipkit gitignore policy'
30
31
  const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
31
32
 
33
+ const DRAFTS_BACK = '!.comfy-ts/drafts/'
34
+
32
35
  export const POLICY_LINES = [
33
36
  // everything shipkit keeps lives under .shipkit/ (core/layout.ts). Local
34
37
  // by default: the private notes, drafts, venue listings and caches, and the
@@ -62,7 +65,7 @@ export const POLICY_LINES = [
62
65
  // steps and cfg, so it is authored state and belongs in the repo, unlike the
63
66
  // schema dump, the lora previews and the raw run outputs beside it
64
67
  '.comfy-ts/*',
65
- '!.comfy-ts/drafts/',
68
+ DRAFTS_BACK,
66
69
  '*.loras.local.txt',
67
70
  // shipkit's own per-run output. Ignoring is only half of it: the fixer
68
71
  // also removes these from the index, because .gitignore does nothing to a
@@ -70,6 +73,11 @@ export const POLICY_LINES = [
70
73
  ...GENERATED_IGNORE_LINES,
71
74
  ]
72
75
 
76
+ /** the policy one repo carries. A declared public tree ships to strangers,
77
+ * so the drafts stay ignored there: a draft holds the prompts and values
78
+ * behind generated assets, authored state in a private repo */
79
+ const policyLines = (config: RepoConfig): string[] => declaredPublic(config) ? POLICY_LINES.filter((l) => l !== DRAFTS_BACK) : POLICY_LINES
80
+
73
81
  const LEGACY = '# the old root layout, ignored until the fix pass moves it under .shipkit/'
74
82
 
75
83
  /** `legacy`: the repo still carries contract folders at its root from before `.shipkit/`. Their names
@@ -77,11 +85,11 @@ const LEGACY = '# the old root layout, ignored until the fix pass moves it under
77
85
  * that is newer than the tree it guards */
78
86
  export function expectedBlock(config: RepoConfig, legacy = false): string {
79
87
  const extra = config.gitignore ?? []
80
- return [BEGIN, ...POLICY_LINES, ...(legacy ? [LEGACY, ...LEGACY_IGNORED] : []), ...(extra.length > 0 ? [CONTRIB, ...extra] : []), END].join('\n')
88
+ return [BEGIN, ...policyLines(config), ...(legacy ? [LEGACY, ...LEGACY_IGNORED] : []), ...(extra.length > 0 ? [CONTRIB, ...extra] : []), END].join('\n')
81
89
  }
82
90
 
83
91
  export function policyPatterns(config: RepoConfig, legacy = false): string[] {
84
- return [...POLICY_LINES.filter((l) => !l.startsWith('#')), ...(legacy ? LEGACY_IGNORED : []), ...(config.gitignore ?? [])]
92
+ return [...policyLines(config).filter((l) => !l.startsWith('#')), ...(legacy ? LEGACY_IGNORED : []), ...(config.gitignore ?? [])]
85
93
  }
86
94
 
87
95
  /** does the root still hold a contract folder from before `.shipkit/` */