rvlib-shipkit 1.8.0 → 2.0.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 (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/check.ts +5 -2
  10. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  11. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  12. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  13. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  14. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  15. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  16. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  17. package/src/cli/cmd_Ci.ts +6 -5
  18. package/src/cli/cmd_Commands.ts +21 -5
  19. package/src/cli/cmd_Hooks.ts +6 -2
  20. package/src/cli/cmd_Init.ts +1 -1
  21. package/src/cli/cmd_Serve.ts +1 -2
  22. package/src/cli/cmd_Start.ts +1 -1
  23. package/src/cli.ts +2 -4
  24. package/src/collections/providers.ts +1 -1
  25. package/src/core/budget.ts +6 -8
  26. package/src/core/bun-builtins.ts +36 -0
  27. package/src/core/bun-tool-cli.ts +23 -0
  28. package/src/core/canonical-scripts.ts +6 -4
  29. package/src/core/check-ctx.ts +104 -10
  30. package/src/core/check-file-names.ts +2 -3
  31. package/src/core/check-pass.ts +17 -1
  32. package/src/core/check-runner.ts +32 -10
  33. package/src/core/config-rewrite.ts +27 -6
  34. package/src/core/discover-checks.ts +64 -15
  35. package/src/core/fix-scope.ts +5 -0
  36. package/src/core/floor-resolve.ts +18 -8
  37. package/src/core/git-root.ts +22 -12
  38. package/src/core/job-run.ts +2 -2
  39. package/src/core/layout.ts +4 -1
  40. package/src/core/legacy-shapes.ts +40 -0
  41. package/src/core/one-lib.ts +5 -3
  42. package/src/core/one-way-in-policy.ts +106 -13
  43. package/src/core/pre-commit-discovery.ts +106 -0
  44. package/src/core/retired-checks.ts +2 -0
  45. package/src/core/run-ci.ts +23 -16
  46. package/src/core/run-fix.ts +9 -0
  47. package/src/core/run-fixers.ts +91 -35
  48. package/src/core/runs-matrix.ts +7 -0
  49. package/src/core/slots.ts +36 -6
  50. package/src/core/vendored-read.ts +39 -0
  51. package/src/core/vendored.ts +82 -0
  52. package/src/core/waivers.ts +125 -0
  53. package/src/core/when-block.ts +25 -185
  54. package/src/core/when.ts +30 -13
  55. package/src/core/wire.ts +17 -1
  56. package/src/git-bootstrap.ts +1 -1
  57. package/src/home/attach.ts +3 -5
  58. package/src/home/gui-app/ShipkitGui.swift +96 -0
  59. package/src/home/insights.ts +6 -1
  60. package/src/home/pm-config.ts +2 -2
  61. package/src/home/resources.ts +3 -0
  62. package/src/home/run-daemon.ts +2 -2
  63. package/src/home/run-unit-action.ts +2 -2
  64. package/src/hooks/commit-message.ts +2 -14
  65. package/src/hooks/hook-gates.ts +38 -0
  66. package/src/hooks/leak-guard.ts +8 -3
  67. package/src/hooks/one-way-in-guard.ts +8 -2
  68. package/src/hooks/root-identity.ts +9 -6
  69. package/src/hooks/run-hooks.ts +72 -41
  70. package/src/hooks/staged.ts +49 -0
  71. package/src/hooks/tree-shrink.ts +41 -0
  72. package/src/init-repo.ts +0 -42
  73. package/src/ledger/check-table.ts +1 -1
  74. package/src/ledger/gate-checks.ts +45 -9
  75. package/src/ledger/ledger.ts +6 -1
  76. package/src/ledger/repo-view.ts +14 -4
  77. package/src/ledger/toolchain-identity.ts +4 -2
  78. package/src/mv/mv-md.ts +6 -1
  79. package/src/plan/planner.ts +21 -4
  80. package/src/plan/repo-source.ts +7 -3
  81. package/src/problems/extract.ts +27 -5
  82. package/src/problems/problem-line.ts +2 -3
  83. package/src/release/run-release.ts +30 -16
  84. package/src/release/unit-gate.ts +12 -5
  85. package/src/repo-config.ts +51 -27
  86. package/src/scaffold/files/bunfig.toml +11 -0
  87. package/src/scaffold/files/vscode-settings.json +1 -2
  88. package/src/scaffold/jsonc-spans.ts +16 -0
  89. package/src/scaffold/project-scaffold.ts +43 -77
  90. package/src/serve/hub-app/check-view.tsx +4 -4
  91. package/src/serve/hub-app/check-words.ts +1 -0
  92. package/src/serve/hub-app/checks-svc.ts +1 -2
  93. package/src/serve/hub-app/home.html +0 -1
  94. package/src/serve/hub-app/hub-types.ts +0 -2
  95. package/src/serve/hub-app/index.html +0 -1
  96. package/src/serve/hub-app/project-list.css +1 -2
  97. package/src/serve/hub-app/tree-build.ts +3 -2
  98. package/src/serve/hub-app/tree-svc.ts +2 -2
  99. package/src/serve/hub-port.ts +3 -3
  100. package/src/serve/hub-runtime.ts +3 -5
  101. package/src/serve/project-gutter.ts +5 -2
  102. package/src/serve/run-lanes.ts +7 -3
  103. package/src/serve/serve-hub.ts +1 -16
  104. package/src/serve/tracked-watch.ts +1 -1
  105. package/src/smoke/run-smoke.ts +13 -6
  106. package/src/start-repo.ts +15 -3
  107. package/src/suite/judge-suites.ts +81 -32
  108. package/src/suite/select.ts +2 -0
  109. package/src/suite/suite-cli.ts +27 -16
  110. package/src/suite/suite-cpu-preload.ts +7 -11
  111. package/src/suite/test-units.ts +7 -4
  112. package/src/ts/config.ts +12 -5
  113. package/src/ts/imports.ts +3 -1
  114. package/src/ts/typecheck-cache.ts +11 -0
  115. package/src/ts/typecheck-cli.ts +2 -1
  116. package/src/tui/build-steps-panel.ts +1 -1
  117. package/src/wiki/scan-md.ts +16 -9
  118. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  119. package/src/checks/SK014-prose-ste.check.ts +0 -78
  120. package/src/cli/cmd_Prose.ts +0 -74
  121. package/src/prose/prose-view.ts +0 -90
  122. package/src/prose/run-prose.ts +0 -187
  123. package/src/prose/ste.ts +0 -552
  124. package/src/scaffold/files/vscode-tasks.json +0 -24
  125. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  126. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  127. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -12,7 +12,7 @@ import { runCi } from '../core/run-ci.ts'
12
12
  import { extractChangelogSection } from './changelog.ts'
13
13
  import { deriveRemote, dirtyPaths, git, remoteTagState, tagExistsLocally, type RemoteTagState } from './git-facts.ts'
14
14
  import { releaseScope } from './release-scope.ts'
15
- import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
15
+ import { publishCommand, unitBlockers, type Publisher } from './unit-gate.ts'
16
16
  import { formatUnitError, resolveUnits, unitById, unknownUnit, type ReleaseUnit } from './units.ts'
17
17
  import type { RepoConfig } from '../repo-config.ts'
18
18
  import { nestedIn, nestedRefusal } from '../core/nesting.ts'
@@ -37,6 +37,10 @@ export type ReleaseConfig = {
37
37
  * Absent = rely on ambient npm auth. Never put a token in config.
38
38
  * Repo-wide: one account publishes every unit. */
39
39
  npmTokenCommand?: string[]
40
+ /** the tool that packs and publishes every npm unit (default 'npm'). A
41
+ * repo that tests its tarball with `bun pm pack` publishes with 'bun', so
42
+ * the packer it tests is the packer that ships. */
43
+ publisher?: Publisher
40
44
  /** cut a GitHub release for the ROOT unit with `gh` (default true) */
41
45
  githubRelease?: boolean
42
46
  /** make a NON-blocking check failure block the release too. Blocking
@@ -113,7 +117,7 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
113
117
  return await runCi(root, {
114
118
  fix: o.fix ?? false,
115
119
  // a check's repair never runs on the release path (runs.onRelease):
116
- // only the mechanical slots (the formatter) may, when bump asks
120
+ // only the mechanical slots (the import fixer) may, when bump asks
117
121
  fixRules: false,
118
122
  // the rows declaring onRelease, whatever the tier
119
123
  release: true,
@@ -239,13 +243,7 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
239
243
  const remoteName0 = 'remote' in remote ? remote.remote : null
240
244
  const tagExists = (): boolean => tagExistsLocally(root, tag)
241
245
  const remoteState: RemoteTagState = remoteName0 == null ? { state: 'unreadable', error: 'no remote' } : remoteTagState(root, remoteName0, tag)
242
- if (remoteName0 != null && remoteState.state === 'unreadable')
243
- blockers.push(`cannot read tags on '${remoteName0}' (${remoteState.error}) — the push would be a guess`)
244
- // a tag NAME the remote already carries at another commit is not a
245
- // resumable release: this version would describe a tree that shipped as
246
- // something else, and git refuses the push anyway
247
- if (remoteState.state === 'different')
248
- blockers.push(`tag ${tag} already on '${remoteName0 ?? ''}' at ${remoteState.remoteOid.slice(0, 8)}, ${remoteState.localOid == null ? 'and not here at all' : `not ${remoteState.localOid.slice(0, 8)} as here`} — that version number is burnt, bump a new one`)
246
+ blockers.push(...tagBlockers(remoteName0, remoteState, tag))
249
247
  const ghReleaseExists = (): boolean =>
250
248
  wantGh && Bun.which('gh') != null && Bun.spawnSync(['gh', 'release', 'view', tag], { cwd: root }).exitCode === 0
251
249
  const fullyShipped = tagExists() && remoteState.state === 'same' && (!wantGh || ghReleaseExists()) && (!wantNpm || alreadyPublished)
@@ -301,18 +299,23 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
301
299
  if (wantNpm && !alreadyPublished) {
302
300
  const env: Record<string, string> = { ...process.env as Record<string, string> }
303
301
  const argv = rc.npmTokenCommand
302
+ const publisher = rc.publisher ?? 'npm'
304
303
  let npmrc: string | null = null
304
+ let token: string | null = null
305
305
  if (argv != null && argv[0] != null) {
306
306
  const t = Bun.spawnSync(argv)
307
- const token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
307
+ token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
308
308
  if (token === '') { print(`FAIL: release.npmTokenCommand produced no token (exit ${t.exitCode})`); return 1 }
309
- // token via a throwaway userconfig, never argv, never a
310
- // committed .npmrc, never a line in a log
311
- npmrc = join(work, 'npmrc')
312
- writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
309
+ // token via a throwaway userconfig (npm) or the env (bun),
310
+ // never argv, never a committed .npmrc, never a line in a log
311
+ if (publisher === 'npm') {
312
+ npmrc = join(work, 'npmrc')
313
+ writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
314
+ }
313
315
  }
314
- const p = Bun.spawnSync(npmPublishArgv({ npmrc }), { cwd: unit.dir, env, stdout: 'inherit', stderr: 'inherit' })
315
- if (p.exitCode !== 0) { print(`FAIL: npm publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
316
+ const cmd = publishCommand({ publisher, npmrc, token })
317
+ const p = Bun.spawnSync(cmd.argv, { cwd: unit.dir, env: { ...env, ...cmd.env }, stdout: 'inherit', stderr: 'inherit' })
318
+ if (p.exitCode !== 0) { print(`FAIL: ${publisher} publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
316
319
  print(`ok: published ${name}@${version}`)
317
320
  }
318
321
 
@@ -356,3 +359,14 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
356
359
  rmSync(work, { recursive: true, force: true })
357
360
  }
358
361
  }
362
+
363
+ /** what the remote's view of the tag forbids: a remote nobody can read makes
364
+ * the push a guess, and a tag NAME it already carries at another commit is not
365
+ * a resumable release (this version would describe a tree that shipped as
366
+ * something else, and git refuses the push anyway) */
367
+ export function tagBlockers(remote: string | null, state: RemoteTagState, tag: string): string[] {
368
+ if (remote != null && state.state === 'unreadable') return [`cannot read tags on '${remote}' (${state.error}) — the push would be a guess`]
369
+ if (state.state === 'different')
370
+ return [`tag ${tag} already on '${remote ?? ''}' at ${state.remoteOid.slice(0, 8)}, ${state.localOid == null ? 'and not here at all' : `not ${state.localOid.slice(0, 8)} as here`} — that version number is burnt, bump a new one`]
371
+ return []
372
+ }
@@ -9,11 +9,18 @@ import { dirtyPaths } from './git-facts.ts'
9
9
  import type { ReleaseUnit } from './units.ts'
10
10
  import type { RepoConfig } from '../repo-config.ts'
11
11
 
12
- /** the publish argv. `--access public` is not a default: npm restricts a
13
- * scoped package unless it is asked, and a restricted publish looks
14
- * successful right up until a consumer cannot install it. */
15
- export function npmPublishArgv(o: { npmrc?: string | null } = {}): string[] {
16
- return ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])]
12
+ /** the tool that packs and publishes an npm unit (`release.publisher`) */
13
+ export const PUBLISHERS = ['npm', 'bun'] as const
14
+ export type Publisher = (typeof PUBLISHERS)[number]
15
+
16
+ /** the publish argv and the env its token rides in. `--access public` is not
17
+ * a default: npm restricts a scoped package unless it is asked, and a
18
+ * restricted publish looks successful right up until a consumer cannot
19
+ * install it. npm reads the token from a throwaway userconfig; `bun publish`
20
+ * has no `--userconfig` and reads NPM_CONFIG_TOKEN instead. */
21
+ export function publishCommand(o: { publisher?: Publisher; npmrc?: string | null; token?: string | null } = {}): { argv: string[]; env: Record<string, string> } {
22
+ if (o.publisher === 'bun') return { argv: ['bun', 'publish', '--access', 'public'], env: o.token != null ? { NPM_CONFIG_TOKEN: o.token } : {} }
23
+ return { argv: ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])], env: {} }
17
24
  }
18
25
 
19
26
  /** everything that must hold for this unit, checked before anything is
@@ -5,26 +5,27 @@
5
5
  // (lint/typecheck/test/imports:check/cycles): a package.json script wins;
6
6
  // with no script, imports:check/cycles fill from shipkit's own
7
7
  // rvlib-ts-tools dep and typecheck from the repo's tsc (O6 zero-config);
8
- // a slot that can't fill emits a visible reason, disabling via
9
- // `defaults: { x: false }` prints a skipped-by-config line — never silence.
8
+ // a slot that can't fill emits a visible reason, and one turned off with
9
+ // `off: { x: '<why>' }` prints a skipped-by-config line, never silence.
10
10
  // The `contracts` task is always present when any contract applies.
11
- import { existsSync } from 'fs'
11
+ import { existsSync, readFileSync } from 'fs'
12
12
  import { isAbsolute, join, normalize } from 'path'
13
13
  import { ROUTED_SCRIPTS } from './core/canonical-scripts.ts'
14
14
  import { FLOOR_IDS } from './core/floor.ts'
15
15
  export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
16
16
  import type { ContractManifest } from './contracts/contract-types.ts'
17
- import type { ProseConfig } from './prose/run-prose.ts'
18
- import { proseWordsProblems } from './prose/ste.ts'
19
17
  import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
20
18
  import { RULE_ID_RE, validateRules } from './rules/rule.ts'
21
19
  import { builtinCheckIds } from './core/discover-checks.ts'
20
+ import { RETIRED_CHECK_IDS } from './core/retired-checks.ts'
22
21
  import { parseWhen } from './core/when.ts'
22
+ import { normalizeVendored, vendoredOfText, vendoredProblem } from './core/vendored.ts'
23
23
  import { budgetValueProblem } from './core/budget.ts'
24
24
  import type { Rule } from './check.ts'
25
25
  import { leakAllowProblems } from './hooks/leak-guard.ts'
26
26
  import type { HooksConfig } from './hooks/run-hooks.ts'
27
27
  import type { ReleaseConfig } from './release/run-release.ts'
28
+ import { PUBLISHERS } from './release/unit-gate.ts'
28
29
  import { TEST_ONLY_UNIT_KEYS, UNIT_DECL_KEYS, type UnitDecl } from './release/unit-resolve.ts'
29
30
  import { TASK_DEF_KEYS, type TaskDef } from './check.ts'
30
31
  import { testsConfigProblems, type TestsConfig } from './suite/judge-suites.ts'
@@ -41,6 +42,10 @@ export type ServeConfig = {
41
42
  * its own port — house range 5800+, default = the hub port plus 1000
42
43
  * (build/comfyui.md owns that surface) */
43
44
  comfyPort?: number
45
+ /** false hides the asset kit from the hub page (no asset rows, an empty
46
+ * studio) and from `shipkit needs`, for a repo whose own tool mounts
47
+ * `studioRoutes` and owns the picking. Default true */
48
+ assets?: boolean
44
49
  /** custom wiki-graph provider for repos with their own link semantics.
45
50
  * Default: a generic md scan of the repo root. */
46
51
  wikiGraph?: {
@@ -75,12 +80,13 @@ export type RepoConfig = {
75
80
  social?: string[]
76
81
  /** prefix for contract fix: pointers (docs checkout or URL) — written by init, never baked in */
77
82
  docsBase?: string
78
- /** a floor slot: `false` disables it (always visible), a def with a
79
- * `cmd` or `logic` REPLACES it, and a def with neither DECORATES whatever
80
- * fills it. The decoration is how a repo declares what its own `test`
81
- * script reads (`{ test: { inputs: ['tests', 'src'] } }`) without
82
- * restating the command: the read set is repo knowledge, the command is
83
- * already in package.json, and restating it is a second source of truth. */
83
+ /** a floor slot: a def with a `cmd` or `logic` REPLACES it, and a def
84
+ * with neither DECORATES whatever fills it. The decoration is how a repo
85
+ * declares what its own `test` script reads (`{ test: { inputs:
86
+ * ['tests', 'src'] } }`) without restating the command: the read set is
87
+ * repo knowledge, the command is already in package.json, and restating
88
+ * it is a second source of truth. A slot set to `false` is RETIRED, read
89
+ * as `off` while SK029 ports it there: a slot turns off like any check */
84
90
  defaults?: Record<string, Partial<TaskDef> | false>
85
91
  /** DEPRECATED, read through 0.40 and rewritten by `shipkit check --fix
86
92
  * SK016`: a build step is a `<name>.check.ts` file with `build: true` */
@@ -162,10 +168,10 @@ export type RepoConfig = {
162
168
  * folder only: it never commits, untracks, tags or pushes on the parent's
163
169
  * history. Rule SK015 fails when this and the disk disagree (core/nesting.ts) */
164
170
  nested?: boolean
165
- /** THE CHECKS THIS REPO TURNS OFF, check id → why. The one thing a repo
166
- * says about a check it does not own: where a check runs is its own
167
- * declaration (build/shipkit/runs.md), and an off check runs in no
168
- * context. `defineRepo` throws on a blank reason and on a rule id no
171
+ /** THE CHECKS THIS REPO TURNS OFF, check id → why, for a whole check it
172
+ * does not own (one false finding is waived at its site instead): where
173
+ * a check runs is its own declaration (build/shipkit/runs.md), and an
174
+ * off check runs in no context. `defineRepo` throws on a blank reason and on a rule id no
169
175
  * check carries. The hub writes this map through the managed block in
170
176
  * this file (core/when-block.ts). */
171
177
  off?: Record<string, string>
@@ -246,6 +252,18 @@ export type RepoConfig = {
246
252
  * repo root is refused outright: a single line that excuses everything
247
253
  * would turn a blocking rule into a false green. */
248
254
  notReleasable?: string[]
255
+ /** repo-relative dirs of THIRD PARTY code (a git subtree, a copied
256
+ * library) that must stay byte identical to upstream. Every tool that
257
+ * treats the tree as the repo's own source leaves them out: the import
258
+ * fixer and its package discovery, imports:check, cycles, the fix pass
259
+ * (a fixer that rewrites a file under one gets it put back, with a
260
+ * WARN naming it), bun lint, the checks' file
261
+ * listings, check discovery, the md scan and `shipkit mv`. Cache keys
262
+ * and watchers still read them, because they are still inputs, and the
263
+ * leak guard still scans what a commit stages there. Write it
264
+ * as a literal array of strings: the tools that never load this file
265
+ * read it as text. One entry is one dir, never a glob */
266
+ vendored?: string[]
249
267
  /** git-hook gates: built-in leak guard + commit-message predicate, plus the
250
268
  * repo's own commands. `shipkit hooks list` enumerates them, so what blocks
251
269
  * a commit is answerable without reading shell. hooks/run-hooks.ts */
@@ -258,12 +276,6 @@ export type RepoConfig = {
258
276
  /** what a `*.check.ts` with no `export const blocking` means here */
259
277
  blocking?: boolean
260
278
  }
261
- /** house prose in STE (identity/writing-voice.md), gated by rule SK014.
262
- * `include` is what makes the rule run at all: a repo that declares no
263
- * scope is skipped rather than failed, because STE binds the prose an
264
- * agent WRITES and no repo can be opted in from the outside.
265
- * prose/run-prose.ts */
266
- prose?: ProseConfig
267
279
  /** `shipkit smoke`: the deployed url, the routes a read only browser pass
268
280
  * visits after a deploy, and how it logs in. The password is a secret
269
281
  * NAME: the value never sits in config, argv or env. smoke/run-smoke.ts */
@@ -295,7 +307,7 @@ export type RepoConfig = {
295
307
  logDir?: string
296
308
  }
297
309
 
298
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'ignoredInputs', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
310
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'ignoredInputs', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'vendored', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'smoke', 'tests'])
299
311
 
300
312
  export function defineRepo(c: RepoConfig): RepoConfig {
301
313
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -319,7 +331,9 @@ export function defineRepo(c: RepoConfig): RepoConfig {
319
331
  // gate is exactly the never-silence failure mode (A23)
320
332
  for (const k of Object.keys(c))
321
333
  if (!KNOWN_KEYS.has(k))
322
- throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
334
+ throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : k === 'prose' ? ` — prose was removed with rule SK014: delete the key` : ''}`)
335
+ if (c.release?.publisher != null && !(PUBLISHERS as readonly string[]).includes(c.release.publisher))
336
+ throw new Error(`defineRepo: release.publisher '${String(c.release.publisher)}' is not a publisher (${PUBLISHERS.join(', ')})`)
323
337
  // a typo'd unit key would silently drop a target and release the wrong shape
324
338
  for (const u of c.units ?? []) {
325
339
  if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
@@ -341,6 +355,11 @@ export function defineRepo(c: RepoConfig): RepoConfig {
341
355
  if (isAbsolute(d) || rel === '.' || rel === '' || rel === '..' || rel.startsWith('../'))
342
356
  throw new Error(`defineRepo: notReleasable '${d}' is the repo root or outside it, and would excuse every package at once — name one package dir per entry`)
343
357
  }
358
+ if (c.vendored != null && !Array.isArray(c.vendored)) throw new Error(`defineRepo: vendored is a list of repo-relative dirs`)
359
+ for (const d of c.vendored ?? []) {
360
+ const bad = vendoredProblem(d)
361
+ if (bad != null) throw new Error(`defineRepo: vendored '${String(d)}' ${bad}`)
362
+ }
344
363
  for (const [k, slot] of Object.entries(c.defaults ?? {})) {
345
364
  if (!(FLOOR_IDS as readonly string[]).includes(k))
346
365
  throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
@@ -378,6 +397,7 @@ export function defineRepo(c: RepoConfig): RepoConfig {
378
397
  for (const [id, reason] of Object.entries(c.off ?? {})) {
379
398
  if (id.trim() === '') throw new Error(`defineRepo: off has an entry with no check id`)
380
399
  if (typeof reason !== 'string' || reason.trim() === '') throw new Error(`defineRepo: off.${id} needs a reason: a check nobody can explain is a silenced gate`)
400
+ if (RETIRED_CHECK_IDS[id] != null) throw new Error(`defineRepo: off.${id} names a retired rule (${RETIRED_CHECK_IDS[id]}): delete the entry`)
381
401
  if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
382
402
  throw new Error(`defineRepo: off.${id} names no rule, the ids are in \`shipkit checks\``)
383
403
  }
@@ -396,15 +416,12 @@ export function defineRepo(c: RepoConfig): RepoConfig {
396
416
  }
397
417
  // an exemption silences a gate, so it names a rule that exists and says why
398
418
  for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
419
+ if (RETIRED_CHECK_IDS[id] != null) throw new Error(`defineRepo: ruleExempt.${id} names a retired rule (${RETIRED_CHECK_IDS[id]}): delete the entry`)
399
420
  if (!builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
400
421
  throw new Error(`defineRepo: ruleExempt.${id} names no rule, the ids are in \`shipkit checks\``)
401
422
  if (typeof reason !== 'string' || reason.trim() === '')
402
423
  throw new Error(`defineRepo: ruleExempt.${id} needs a reason, an exemption nobody can explain is a silenced gate`)
403
424
  }
404
- // a word list entry silences or adds gate hits, so a malformed one dies
405
- // here rather than matching nothing
406
- const words = proseWordsProblems(c.prose?.words)
407
- if (words.length > 0) throw new Error(`defineRepo: ${words[0]}`)
408
425
  // a smoke typo would land as a run that visits nothing and exits 0
409
426
  if (c.smoke != null) validateSmokeConfig(c.smoke)
410
427
  // a leak-guard waiver is the one config row that can make a gate quieter,
@@ -432,5 +449,12 @@ export async function loadRepoConfig(root: string, o: { fresh?: boolean } = {}):
432
449
  if (!existsSync(path)) throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
433
450
  const mod = (await import(o.fresh === true ? `${path}?v=${Date.now()}-${Math.random().toString(36).slice(2)}` : path)) as { default?: RepoConfig }
434
451
  if (mod.default == null) throw new Error(`${path}: must default-export defineRepo({...})`)
452
+ // the tools that never load the config read `vendored` off the text, so
453
+ // a value the text cannot show would omit one set of dirs here and
454
+ // another there
455
+ const loaded = (mod.default.vendored ?? []).map(normalizeVendored)
456
+ const read = vendoredOfText(readFileSync(path, 'utf8'))
457
+ if (loaded.join('\0') !== read.join('\0'))
458
+ throw new Error(`${path}: \`vendored\` must be a literal array of strings, the tools that never load the config read it as text (they read [${read.join(', ')}], the config says [${loaded.join(', ')}])`)
435
459
  return mod.default
436
460
  }
@@ -0,0 +1,11 @@
1
+ # `bun lint` is the house linter, and `sk ci` runs it once [lint] is declared.
2
+ # [fmt] is the house style for a manual `bun fmt`: nothing formats in the gate.
3
+ [fmt]
4
+ semi = false
5
+ singleQuote = true
6
+ printWidth = 140
7
+ tabWidth = 4
8
+ # code only: prose keeps its own shape, and package.json belongs to its fixers
9
+ ignorePatterns = ["*.md", "*.json", "*.jsonc", "*.yaml", "*.yml", "*.toml"]
10
+
11
+ [lint]
@@ -33,8 +33,7 @@
33
33
  "js/ts.tsserver.automaticTypeAcquisition.enabled": false,
34
34
  "js/ts.tsserver.checkNpmIsInstalled": false,
35
35
 
36
- // oxc lints in-editor; eslint off; formatter owns formatting, not tsserver
37
- "oxc.enable": true,
36
+ // eslint off, and no formatter on save: formatting is a manual `bun fmt`
38
37
  "eslint.enable": false,
39
38
  "js/ts.format.enabled": false
40
39
  }
@@ -111,3 +111,19 @@ export function removalSpan(text: string, arr: Extract<Node, { kind: 'array' }>,
111
111
  }
112
112
  return [item.start, item.end]
113
113
  }
114
+
115
+ /** the spans that take several items out of one array at once, disjoint so
116
+ * they apply in any order. An item with a kept one after it goes with its own
117
+ * comma, and the removed run after the last kept item goes as one span from
118
+ * that item's end, so no comma is left dangling */
119
+ export function removalSpans(text: string, arr: Extract<Node, { kind: 'array' }>, idxs: Iterable<number>): [number, number][] {
120
+ const gone = new Set(idxs)
121
+ if (gone.size === 0) return []
122
+ const kept = arr.items.flatMap((_, i) => (gone.has(i) ? [] : [i]))
123
+ const lastKept = kept.at(-1) ?? -1
124
+ const spans: [number, number][] = []
125
+ for (const i of [...gone].sort((a, b) => a - b)) if (i < lastKept) spans.push(removalSpan(text, arr, i))
126
+ const tail = [...gone].filter((i) => i > lastKept).sort((a, b) => a - b)
127
+ if (tail.length > 0) spans.push([lastKept < 0 ? arr.items[tail[0]!]!.start : arr.items[lastKept]!.end, arr.items[tail.at(-1)!]!.end])
128
+ return spans
129
+ }
@@ -10,10 +10,9 @@
10
10
  // package.json rewrite reformats the file it edits.
11
11
  // Templates live beside this file in files/ and ship in the npm tarball, so
12
12
  // none of them may name a machine-local tool or a private path.
13
- import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
14
  import { dirname, join } from 'path'
15
15
  import { defaultHubPort } from '../serve/hub-port.ts'
16
- import { hubProblemMatcher } from '../problems/problem-line.ts'
17
16
 
18
17
  const FILES = join(import.meta.dir, 'files')
19
18
 
@@ -36,15 +35,14 @@ export type ScaffoldOpts = {
36
35
  claudePolicy?: boolean
37
36
  }
38
37
 
39
- /** every dep the house toolchain needs for the scripts below to run */
40
- export const TOOLCHAIN = ['typescript', 'oxlint', 'oxfmt', '@types/bun'] as const
38
+ /** every dep the house toolchain needs for the scripts below to run. The
39
+ * linter and the formatter are bun's own, declared in bunfig.toml */
40
+ export const TOOLCHAIN = ['typescript', '@types/bun'] as const
41
41
 
42
42
  /** script bodies for a fresh repo whose only source dir is src/. A stack
43
- * scaffold (vite, hono) adds dev/build on top; these three are the floor
44
- * rule SK001 asks every repo for */
43
+ * scaffold (vite, hono) adds dev/build on top. No `lint` or `fmt`: a script
44
+ * of either name would shadow `bun lint` and `bun fmt` */
45
45
  const SCRIPTS: Record<string, string> = {
46
- lint: 'oxlint src/',
47
- fmt: 'oxfmt src/',
48
46
  typecheck: 'tsc --noEmit',
49
47
  }
50
48
 
@@ -104,64 +102,12 @@ function put(root: string, rel: string, body: string): boolean {
104
102
  return true
105
103
  }
106
104
 
107
- export type ErgonomicsOpts = {
108
- /** repo name, substituted into the templates */
109
- name: string
110
- /** the port the hub actually listens on, resolved by the caller from the
111
- * repo config (`serve.port`, else the name-derived default), so the task
112
- * advertises a url something answers on */
113
- port: number
114
- }
115
-
116
- /** the folder-open story, and the ONE file of it a repo shipkit did not create
117
- * can be handed unasked.
118
- *
119
- * What lands is `tasks.json` alone. `settings.json` turns eslint off and hands
120
- * formatting to an oxc extension, so writing it into a repo that lints with
121
- * eslint silences the linter that repo chose: that is an answer about what the
122
- * repo IS, and a repo with a history of its own already gave it. A tasks file
123
- * is the opposite: with no `folderOpen` task nothing starts at all, so there
124
- * is no existing answer to overrule.
125
- *
126
- * What lands INSIDE it is only what is true of every wired repo: the hub,
127
- * whose script `init` writes, carrying the problem matcher that turns every
128
- * check's findings into the problems pane. There is no type checker task, the
129
- * hub runs `typecheck` like any other check. Every watcher a stack earns (a
130
- * dev server, css) stays the analysis step `build/vscode-tasks.md` describes,
131
- * and the caller says so out loud.
132
- *
133
- * Create-only: an adopted repo often carries a `.vscode/` from whatever it was
134
- * forked from, and that one wins byte for byte (rule SK017 then repairs its
135
- * hub task in place). Returns the paths it wrote. */
136
- export function editorErgonomics(root: string, opts: ErgonomicsOpts): string[] {
137
- // a symlinked .vscode is a dotfiles pattern pointing at a SHARED dir, and
138
- // writing through it lands files outside the repo being wired
139
- if (isLink(join(root, '.vscode'))) return []
140
- const body = tasksTemplate({ NAME: opts.name, PORT: String(opts.port) })
141
- return put(root, '.vscode/tasks.json', body) ? ['.vscode/tasks.json'] : []
142
- }
143
-
144
- const isLink = (path: string): boolean => {
145
- try {
146
- return lstatSync(path).isSymbolicLink()
147
- } catch {
148
- return false
149
- }
150
- }
151
-
152
- /** the tasks template, the hub's problem matcher rendered in from the one
153
- * module the hub prints through, so the two can never disagree */
154
- export function tasksTemplate(vars: Record<string, string>): string {
155
- return template('vscode-tasks.json', vars).replace(/^(\s*)(.*)\{\{HUB_PROBLEM_MATCHER\}\}/m, (_all, indent: string, head: string) =>
156
- `${indent}${head}${JSON.stringify(hubProblemMatcher(), null, 3).split('\n').join(`\n${indent}`)}`)
157
- }
158
-
159
105
  export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
160
106
  const p = opts.print ?? console.log
161
107
  // the hub resolves its port from repo.config, whose name `init` takes from
162
108
  // the CALLER first and the manifest only after (init-repo.ts repoName). A
163
- // dir that arrived holding a forked package.json would otherwise get a
164
- // tasks.json advertising a port nothing listens on, so the caller that
109
+ // dir that arrived holding a forked package.json would otherwise get an
110
+ // agent index advertising a port nothing listens on, so the caller that
165
111
  // knows the name it is about to wire passes the port it resolves
166
112
  const port = opts.port ?? defaultHubPort(packageName(root) ?? opts.name)
167
113
  const vars = {
@@ -175,15 +121,32 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
175
121
 
176
122
  write('src/index.ts', 'export {}\n')
177
123
  write('tsconfig.json', template('tsconfig.json', vars))
178
- ;(editorErgonomics(root, { name: opts.name, port }).length > 0 ? written : skipped).push('.vscode/tasks.json')
179
- // settings.json only here: it turns eslint off and hands formatting to an
180
- // oxc extension, which is a decision only the repo's creator gets to make
124
+ write('bunfig.toml', template('bunfig.toml', vars))
125
+ // settings.json only here: it turns eslint off and keeps tsserver from
126
+ // formatting, which is a decision only the repo's creator gets to make
181
127
  write('.vscode/settings.json', template('vscode-settings.json', vars))
182
128
  write('.claude/skills/verify/SKILL.md', template('verify-skill.md', vars))
129
+ writeHouseFiles(write, vars, opts.claudePolicy === true, written, p)
130
+
131
+ addScripts(root, written, p)
132
+ addIgnoreLines(root, written)
133
+
134
+ if (written.length > 0) p(`ok: scaffolded ${written.length} file(s): ${written.slice(0, 6).join(', ')}${written.length > 6 ? `, +${written.length - 6} more` : ''}`)
135
+ // the skips are the interesting half on a half-built dir: a pre-existing
136
+ // .claude/settings.json means no attribution silencer, and that one only
137
+ // counts from the first commit on
138
+ if (skipped.length > 0) p(`skip: ${skipped.length} file(s) already present, left untouched: ${skipped.join(', ')}`)
139
+ return written
140
+ }
141
+
142
+ /** the half of the day-one shape a house brings, whatever the repo already
143
+ * is: the agent wiki its CLAUDE.md routes at, the reflections folder, and
144
+ * for a caller that declared a house the .claude/ policy files. Create-only */
145
+ function writeHouseFiles(write: (rel: string, body: string) => void, vars: Record<string, string>, claudePolicy: boolean, written: string[], p: (line: string) => void): void {
183
146
  // a permission grant and a git-history policy are decisions about someone
184
147
  // else's repo, so they land only for a caller that declared a house, and
185
148
  // the grant is named out loud the one time it is written
186
- if (opts.claudePolicy === true) {
149
+ if (claudePolicy) {
187
150
  write('.claude/settings.json', template('claude-settings.json', vars))
188
151
  write('.claude/settings.local.json', template('claude-settings-local.json', vars))
189
152
  if (written.includes('.claude/settings.local.json')) p(`ok: .claude/ policy written — agents are pre-approved for ${grantSummary()} in this repo, and the harness writes no attribution trailer`)
@@ -197,15 +160,18 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
197
160
  // gitignored by policy, so it never ships: it exists so an agent hitting
198
161
  // friction with the toolchain finds the place to write it down
199
162
  write('.shipkit/reflections/README.md', REFLECTIONS_README)
163
+ }
200
164
 
201
- addScripts(root, written, p)
202
- addIgnoreLines(root, written)
203
-
204
- if (written.length > 0) p(`ok: scaffolded ${written.length} file(s): ${written.slice(0, 6).join(', ')}${written.length > 6 ? `, +${written.length - 6} more` : ''}`)
205
- // the skips are the interesting half on a half-built dir: a pre-existing
206
- // .claude/settings.json means no attribution silencer, and that one only
207
- // counts from the first commit on
208
- if (skipped.length > 0) p(`skip: ${skipped.length} file(s) already present, left untouched: ${skipped.join(', ')}`)
165
+ /** an ADOPTED repo (one with history) whose start declares a house: the
166
+ * include lines pull it into the house contract, so it gets the house half
167
+ * of the day-one shape and nothing that says what the repo IS. Create-only,
168
+ * so every file the repo already holds wins. Returns the paths it wrote */
169
+ export function adoptHouse(root: string, opts: { name: string; today?: string; print?: (line: string) => void }): string[] {
170
+ const p = opts.print ?? console.log
171
+ const vars = { NAME: opts.name, PORT: String(defaultHubPort(opts.name)), DATE: opts.today ?? new Date().toISOString().slice(0, 10) }
172
+ const written: string[] = []
173
+ writeHouseFiles((rel, body) => { if (put(root, rel, body)) written.push(rel) }, vars, true, written, p)
174
+ if (written.length > 0) p(`ok: adopted into the house, ${written.length} file(s) seeded: ${written.join(', ')}`)
209
175
  return written
210
176
  }
211
177
 
@@ -227,14 +193,14 @@ What broke, which page or lib, what you expected. Gitignored on purpose,
227
193
  transient by design: it is harvested, then deleted.
228
194
  `
229
195
 
230
- /** the three baseline scripts, added only where the key is free */
196
+ /** the baseline scripts, added only where the key is free */
231
197
  function addScripts(root: string, written: string[], p: (line: string) => void): void {
232
198
  const path = join(root, 'package.json')
233
- if (!existsSync(path)) return void p('WARN no package.json here — the lint/fmt/typecheck scripts were not written')
199
+ if (!existsSync(path)) return void p('WARN no package.json here — the typecheck script was not written')
234
200
  const raw = readFileSync(path, 'utf8')
235
201
  let pkg: { scripts?: Record<string, string> }
236
202
  try { pkg = JSON.parse(raw) as { scripts?: Record<string, string> } }
237
- catch (e) { return void p(`WARN package.json is unparseable (${String(e).slice(0, 80)}) — the lint/fmt/typecheck scripts were not written, repair it then re-run`) }
203
+ catch (e) { return void p(`WARN package.json is unparseable (${String(e).slice(0, 80)}) — the typecheck script was not written, repair it then re-run`) }
238
204
  pkg.scripts ??= {}
239
205
  let added = 0
240
206
  for (const [k, v] of Object.entries(SCRIPTS)) if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
@@ -13,7 +13,6 @@ import type { RepoSt } from './repo-st.ts'
13
13
  import type { SlowSuite, CheckFacts, RecentRun } from './hub-types.ts'
14
14
  import { renderMd, renderMdInline } from './md.ts'
15
15
  import { nextRun } from './next-run.ts'
16
- import { ProseHits } from './prose-hits-view.tsx'
17
16
  import { stampWords, TimeAgo, TimeAhead } from './time-ago.tsx'
18
17
  import { checkName, commandOf, docPath, editorHref, lastRun, rememberedFact, repoPath, whatFacts, type ShortPath } from './check-words.ts'
19
18
 
@@ -23,8 +22,9 @@ const SOURCE_CHIP: Record<CheckFacts['whenSource'], string> = {
23
22
  off: 'repo.config.ts',
24
23
  when: 'when (retired)',
25
24
  ruleExempt: 'ruleExempt (retired)',
25
+ 'hooks.disable': 'hooks.disable (retired)',
26
26
  'check-file': 'the check file',
27
- defaults: 'defaults',
27
+ defaults: 'defaults (retired)',
28
28
  default: 'derived',
29
29
  }
30
30
 
@@ -33,7 +33,8 @@ const SOURCE_WORD: Record<CheckFacts['whenSource'], string> = {
33
33
  when: 'turned off by the retired `when` key, which `shipkit ci` ports to `off` (SK029)',
34
34
  ruleExempt: 'turned off by the retired `ruleExempt` key, which `shipkit ci` ports to `off` (SK029)',
35
35
  'check-file': 'declared by the check file itself: every context it runs in, no default',
36
- defaults: 'from the `defaults` block, which removes the floor slot entirely',
36
+ 'hooks.disable': 'turned off by the retired `hooks.disable` key, which `shipkit ci` ports to `off` (SK029)',
37
+ defaults: 'turned off by the retired `defaults: { <slot>: false }`, which `shipkit ci` ports to `off` (SK029)',
37
38
  default: 'a row with no file of its own: the code that derives it writes every context',
38
39
  }
39
40
 
@@ -359,7 +360,6 @@ export const CheckCard = observer(({ app, id }: { app: RepoSt; id: string }) =>
359
360
  </div>
360
361
  <WhereItRuns app={app} u={u} />
361
362
  {u.build && u.checkId === 'test' ? <SlowSuites app={app} /> : null}
362
- {u.checkId === 'SK014' ? <ProseHits app={app} /> : null}
363
363
  <LastOutput app={app} lines={u.lastLines} />
364
364
  </div>
365
365
  )
@@ -19,6 +19,7 @@ const CADENCE: Record<WhenAt, string> = {
19
19
  always: 'on every run',
20
20
  release: 'on every release run',
21
21
  health: 'each time the plan runs it',
22
+ commit: 'on every commit, in a git hook',
22
23
  off: 'nowhere, it is off',
23
24
  }
24
25
 
@@ -100,8 +100,7 @@ export class ChecksSvc {
100
100
  }
101
101
  }
102
102
 
103
- /** ONE write path for the switch, the slow suite form and the prose word
104
- * lists alike: it edits repo.config.ts in place and leaves it dirty to be
103
+ /** ONE write path for the switch and the slow suite form alike: it edits repo.config.ts in place and leaves it dirty to be
105
104
  * reviewed. Resolves true when the server took the edit */
106
105
  async writeGate(edit: Record<string, unknown>, said: string): Promise<boolean> {
107
106
  if (this.gateBusy) return false
@@ -6,7 +6,6 @@
6
6
  <title>home</title>
7
7
  <link rel="stylesheet" href="./hub.css" />
8
8
  <link rel="stylesheet" href="./project-list.css" />
9
- <link rel="stylesheet" href="./prose-hits.css" />
10
9
  <link rel="stylesheet" href="./home-strip.css" />
11
10
  <link rel="stylesheet" href="./planned.css" />
12
11
  <link rel="stylesheet" href="./when.css" />
@@ -145,8 +145,6 @@ export type GateBlock = {
145
145
  tests: { budgetMs?: number; slow?: SlowSuite[]; serial?: { file: string; why: string }[] }
146
146
  declaredOutside: boolean
147
147
  dirty: boolean
148
- /** the prose words block inside `prose: {` (core/when-block.ts) */
149
- wordsState?: 'missing-file' | 'missing-block' | 'stale' | 'current' | 'no-prose'
150
148
  }
151
149
 
152
150
  /** one check finishing, as the run stream sends it */
@@ -6,7 +6,6 @@
6
6
  <title>hub</title>
7
7
  <link rel="stylesheet" href="./hub.css" />
8
8
  <link rel="stylesheet" href="./project-list.css" />
9
- <link rel="stylesheet" href="./prose-hits.css" />
10
9
  <link rel="stylesheet" href="./home-strip.css" />
11
10
  <link rel="stylesheet" href="./planned.css" />
12
11
  <link rel="stylesheet" href="./when.css" />
@@ -1,5 +1,4 @@
1
- /* the project list, the page's left panel. Its classes are pj*: another
2
- sheet of the page already styles .plist (prose-hits.css). It fills its
1
+ /* the project list, the page's left panel. Its classes are pj*. It fills its
3
2
  panel (hub-app.tsx, react-resizable-panels), whose width is the only one:
4
3
  this sheet sizes the rows and never the list. Colours are hub.css tokens only, spacing is its scale:
5
4
  tests/hub-css-invariants.test.ts reads this sheet too. Every icon is 18px