rvlib-shipkit 1.9.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 (108) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +1 -1
  3. package/src/check.ts +5 -2
  4. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  5. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  6. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  7. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  8. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  9. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  10. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  11. package/src/cli/cmd_Ci.ts +3 -3
  12. package/src/cli/cmd_Commands.ts +21 -5
  13. package/src/cli/cmd_Hooks.ts +6 -2
  14. package/src/cli/cmd_Init.ts +1 -1
  15. package/src/cli/cmd_Serve.ts +1 -2
  16. package/src/cli/cmd_Start.ts +1 -1
  17. package/src/cli.ts +2 -4
  18. package/src/core/budget.ts +6 -8
  19. package/src/core/bun-builtins.ts +36 -0
  20. package/src/core/bun-tool-cli.ts +23 -0
  21. package/src/core/canonical-scripts.ts +6 -4
  22. package/src/core/check-ctx.ts +104 -10
  23. package/src/core/check-file-names.ts +2 -3
  24. package/src/core/check-pass.ts +17 -1
  25. package/src/core/check-runner.ts +19 -6
  26. package/src/core/config-rewrite.ts +27 -6
  27. package/src/core/discover-checks.ts +30 -10
  28. package/src/core/floor-resolve.ts +16 -7
  29. package/src/core/job-run.ts +2 -2
  30. package/src/core/layout.ts +4 -1
  31. package/src/core/legacy-shapes.ts +40 -0
  32. package/src/core/one-lib.ts +1 -1
  33. package/src/core/one-way-in-policy.ts +106 -13
  34. package/src/core/pre-commit-discovery.ts +106 -0
  35. package/src/core/retired-checks.ts +2 -0
  36. package/src/core/run-ci.ts +16 -10
  37. package/src/core/run-fix.ts +9 -0
  38. package/src/core/run-fixers.ts +91 -35
  39. package/src/core/runs-matrix.ts +7 -0
  40. package/src/core/slots.ts +1 -1
  41. package/src/core/vendored-read.ts +39 -0
  42. package/src/core/vendored.ts +82 -0
  43. package/src/core/waivers.ts +125 -0
  44. package/src/core/when-block.ts +25 -185
  45. package/src/core/when.ts +30 -13
  46. package/src/core/wire.ts +17 -1
  47. package/src/git-bootstrap.ts +1 -1
  48. package/src/home/attach.ts +3 -5
  49. package/src/home/insights.ts +6 -1
  50. package/src/home/pm-config.ts +2 -2
  51. package/src/home/run-daemon.ts +2 -2
  52. package/src/home/run-unit-action.ts +2 -2
  53. package/src/hooks/commit-message.ts +2 -14
  54. package/src/hooks/hook-gates.ts +38 -0
  55. package/src/hooks/leak-guard.ts +8 -3
  56. package/src/hooks/one-way-in-guard.ts +8 -2
  57. package/src/hooks/root-identity.ts +9 -6
  58. package/src/hooks/run-hooks.ts +72 -41
  59. package/src/hooks/staged.ts +49 -0
  60. package/src/hooks/tree-shrink.ts +41 -0
  61. package/src/init-repo.ts +0 -42
  62. package/src/ledger/check-table.ts +1 -1
  63. package/src/ledger/gate-checks.ts +45 -9
  64. package/src/ledger/ledger.ts +6 -1
  65. package/src/ledger/repo-view.ts +14 -4
  66. package/src/ledger/toolchain-identity.ts +4 -2
  67. package/src/mv/mv-md.ts +6 -1
  68. package/src/problems/extract.ts +27 -5
  69. package/src/problems/problem-line.ts +2 -3
  70. package/src/release/run-release.ts +13 -8
  71. package/src/repo-config.ts +44 -27
  72. package/src/scaffold/files/bunfig.toml +11 -0
  73. package/src/scaffold/files/vscode-settings.json +1 -2
  74. package/src/scaffold/jsonc-spans.ts +16 -0
  75. package/src/scaffold/project-scaffold.ts +43 -77
  76. package/src/serve/hub-app/check-view.tsx +4 -4
  77. package/src/serve/hub-app/check-words.ts +1 -0
  78. package/src/serve/hub-app/checks-svc.ts +1 -2
  79. package/src/serve/hub-app/home.html +0 -1
  80. package/src/serve/hub-app/hub-types.ts +0 -2
  81. package/src/serve/hub-app/index.html +0 -1
  82. package/src/serve/hub-app/project-list.css +1 -2
  83. package/src/serve/hub-app/tree-build.ts +3 -2
  84. package/src/serve/hub-app/tree-svc.ts +2 -2
  85. package/src/serve/hub-port.ts +3 -3
  86. package/src/serve/hub-runtime.ts +0 -3
  87. package/src/serve/serve-hub.ts +1 -16
  88. package/src/serve/tracked-watch.ts +1 -1
  89. package/src/start-repo.ts +15 -3
  90. package/src/suite/judge-suites.ts +29 -28
  91. package/src/suite/select.ts +2 -0
  92. package/src/suite/suite-cli.ts +6 -3
  93. package/src/suite/suite-cpu-preload.ts +7 -11
  94. package/src/suite/test-units.ts +7 -4
  95. package/src/ts/config.ts +12 -5
  96. package/src/ts/imports.ts +3 -1
  97. package/src/tui/build-steps-panel.ts +1 -1
  98. package/src/wiki/scan-md.ts +16 -9
  99. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  100. package/src/checks/SK014-prose-ste.check.ts +0 -78
  101. package/src/cli/cmd_Prose.ts +0 -74
  102. package/src/prose/prose-view.ts +0 -90
  103. package/src/prose/run-prose.ts +0 -187
  104. package/src/prose/ste.ts +0 -552
  105. package/src/scaffold/files/vscode-tasks.json +0 -24
  106. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  107. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  108. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -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
@@ -9,6 +9,7 @@ import type { ProcessRow, RowDot } from '../../home/process-rows.ts'
9
9
  import { checkVerdictWords, plural } from '../../core/outcome-words.ts'
10
10
  import { runsWords } from '../../core/runs-matrix.ts'
11
11
  import { checkName } from './check-words.ts'
12
+ import type { WhenAt } from './hub-types.ts'
12
13
  import { durationTone, fmtMs, type DurTone } from './dur.ts'
13
14
  import type { HashSel } from './hash-route.ts'
14
15
  import { SECTION_LETTER, sectionLabel, type SectionId } from './keys.ts'
@@ -234,8 +235,8 @@ export function flattenTree(nodes: TreeNode[], expanded: Set<string>, depth = 0)
234
235
  /** the line at the top of the checks page: how much of this repo runs on
235
236
  * every gate, and how much does not. Counts CHECKS only, because nothing
236
237
  * else declares a place to run */
237
- export function whenCounts(resources: Resource[]): Record<'always' | 'release' | 'health' | 'off', number> {
238
- const out = { always: 0, release: 0, health: 0, off: 0 }
238
+ export function whenCounts(resources: Resource[]): Record<WhenAt, number> {
239
+ const out: Record<WhenAt, number> = { always: 0, release: 0, health: 0, commit: 0, off: 0 }
239
240
  for (const r of resources) if (r.check != null) out[r.check.when]++
240
241
  return out
241
242
  }
@@ -95,9 +95,9 @@ export class TreeSvc {
95
95
  /** the line at the top of the checks page: what runs always and what does not */
96
96
  get whenLine(): string {
97
97
  const c = whenCounts(this.st.cols.resources)
98
- const total = c.always + c.release + c.health + c.off
98
+ const total = c.always + c.release + c.health + c.commit + c.off
99
99
  if (total === 0) return ''
100
- return [`${c.always} always`, `${c.release} release`, `${c.health} health`, `${c.off} off`].join(' · ')
100
+ return [`${c.always} always`, `${c.release} release`, `${c.health} health`, `${c.commit} commit`, `${c.off} off`].join(' · ')
101
101
  }
102
102
 
103
103
  get redCount(): number {
@@ -1,15 +1,15 @@
1
1
  // the hub's default port, alone in its own module ON PURPOSE.
2
2
  //
3
3
  // It is four lines of arithmetic that several places need BEFORE any server
4
- // exists: the repo scaffold writes it into .vscode/tasks.json, `init` prints
5
- // it, the config resolver defaults to it. Importing it from run-serve.ts drags
4
+ // exists: the repo scaffold writes it into agent/index.md, `init` prints it,
5
+ // the config resolver defaults to it. Importing it from run-serve.ts drags
6
6
  // the whole hub (react, the markdown renderer, the sanitiser, every panel)
7
7
  // into a caller that only wanted a number, which measured 393ms of module
8
8
  // load in the scaffold alone.
9
9
  //
10
10
  // A port derived from the repo NAME rather than assigned means two repos open
11
11
  // side by side almost never collide, and the same repo always reopens on the
12
- // same port, which is what makes a folder-open task able to advertise a URL.
12
+ // same port, which is what lets a page advertise a URL before the hub runs.
13
13
  export function defaultHubPort(name: string): number {
14
14
  let h = 0
15
15
  for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
@@ -27,8 +27,6 @@ import { wikisCollection } from '../collections/wikis.ts'
27
27
  import { pingTick } from '../collections/pings.ts'
28
28
  import { buildCollections } from '../collections/providers.ts'
29
29
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
30
- import { CheckCtx } from '../core/check-ctx.ts'
31
- import { proseView } from '../prose/prose-view.ts'
32
30
  import { allTasks, gateInventory } from '../ledger/gate-checks.ts'
33
31
  import { runCheck, type CheckEvent } from '../core/run-check.ts'
34
32
  import { runCi, type CiOpts } from '../core/run-ci.ts'
@@ -391,7 +389,6 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
391
389
  const input = { facts, runs: readRuns(record), suiteRuns: readSuiteRuns(record) }
392
390
  return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
393
391
  },
394
- prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
395
392
  needs: () => projectNeeds(allCollections(), hubProblems.collector),
396
393
  hubProblems,
397
394
  // a job outlives this image, so only an in process run holds a reload back
@@ -16,7 +16,6 @@ import type { CheckEvent } from '../core/run-check.ts'
16
16
  import { configDirty, readGateBlock, writeGateBlock, type GateEdit } from '../core/when-block.ts'
17
17
  import type { InvokedBy } from '../ledger/run-log.ts'
18
18
  import type { GraphMount } from '../wiki/graph-mount.ts'
19
- import type { ProseView } from '../prose/prose-view.ts'
20
19
  import type { Timeline } from '../ledger/run-timeline.ts'
21
20
  import type { TestCheckView, TestsRootView } from '../collections/test-time.ts'
22
21
  import type { LiveView } from '../ledger/live-timeline.ts'
@@ -105,9 +104,6 @@ export type HubDeps = {
105
104
  /** the comfy panel; null when the repo declares no workflow module, which
106
105
  * is also when the comfy section is absent from the page */
107
106
  comfy: ComfyDeps | null
108
- /** rule SK014's hits, grouped by word, over the config on disk: the data
109
- * of the SK014 page. Absent = the endpoint answers 404 */
110
- prose?: () => Promise<ProseView>
111
107
  /** re-read repo.config.ts when its bytes moved. The switch WRITES that
112
108
  * file, so every surface built on the config asks for this first, or a
113
109
  * toggle reads as a toggle that did nothing until the next restart */
@@ -367,7 +363,7 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
367
363
  if (req.method === 'POST') {
368
364
  try {
369
365
  const body = (await req.json()) as GateEdit & { expect?: string }
370
- const b = await writeGateBlock(d.root, { off: body.off, slow: body.slow, slowRemove: body.slowRemove, words: body.words }, { expect: body.expect })
366
+ const b = await writeGateBlock(d.root, { off: body.off, slow: body.slow, slowRemove: body.slowRemove }, { expect: body.expect })
371
367
  await d.refresh?.()
372
368
  return Response.json({ ...b, dirty: configDirty(d.root) })
373
369
  } catch (e) {
@@ -375,17 +371,6 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
375
371
  }
376
372
  }
377
373
  }
378
- if (path === '/api/prose.json' && d.prose != null) {
379
- // every SK014 hit, grouped by word, noisiest first. The word
380
- // lists are written through /api/checks/when like every other
381
- // edit of repo.config.ts
382
- await d.refresh?.()
383
- try {
384
- return Response.json(await d.prose())
385
- } catch (e) {
386
- return new Response(`the prose check could not run: ${(e as Error).message}`, { status: 500 })
387
- }
388
- }
389
374
  if (path === '/api/check/run' && req.method === 'POST') {
390
375
  const body = (await req.json().catch(() => ({}))) as { only?: string[]; gate?: boolean }
391
376
  // NDJSON: one line per event, so the page colours a row the
@@ -48,7 +48,7 @@ export async function gitIgnored(root: string, paths: string[]): Promise<Set<str
48
48
  }
49
49
 
50
50
  /** a commit, a fetch or a tag moved a ref: the one write inside .git a check
51
- * keyed on the refs (SK009) must hear. Lock files are git mid write */
51
+ * keyed on the refs must hear. Lock files are git mid write */
52
52
  export function isRefMove(rel: string): boolean {
53
53
  if (rel.endsWith('.lock')) return false
54
54
  return rel === '.git/HEAD' || rel === '.git/packed-refs' || rel.startsWith('.git/refs/')
package/src/start-repo.ts CHANGED
@@ -13,19 +13,24 @@ import { join } from 'path'
13
13
  import { ensureGitRepo, ensureIgnoreBaseline, firstGhLine, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
14
14
  import { initRepo } from './init-repo.ts'
15
15
  import { AGENT_LINE } from './checks/SK003-claude-md-shape.check.ts'
16
- import { scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
16
+ import { adoptHouse, scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
17
17
  import { defaultHubPort } from './serve/hub-port.ts'
18
18
 
19
19
  /** the one template: `shipkit start <name>` is the only thing anyone types,
20
- * and a repo without the corvion wiki in its context is not a started repo.
20
+ * and a repo without the house wiki in its context is not a started repo.
21
21
  * Base context first, the repo's own router last, so repo docs override.
22
22
  * A public repo swaps line 1 for the gitignored `@./CLAUDE.local.md`,
23
23
  * which is why that line satisfies the first include (SK003) */
24
+ // sk-disable-next-line RV003 -- the start template writes the house include line itself
24
25
  export const HOUSE_INCLUDE = ['@~/dev/corvion/wiki/index.md', '@./agent/index.md']
25
26
  export const HOUSE_ALSO_OK = ['@./CLAUDE.local.md']
26
27
  /** the day-job checkout: never wired, never given an include */
28
+ // sk-disable-next-line RV003 -- start refuses the day-job checkout by its name
27
29
  export const HOUSE_SKIP = ['monoloco']
28
30
 
31
+ /** a repo shipkit never touches: no CLAUDE.md, no git, no scaffold, no push */
32
+ export const isSkipped = (name: string, skip: readonly string[] = HOUSE_SKIP): boolean => skip.includes(name)
33
+
29
34
  /** where the first house line points, `~` resolved against `home` */
30
35
  export const housePath = (home: string): string => {
31
36
  const p = HOUSE_INCLUDE[0]!.replace(/^@/, '')
@@ -72,7 +77,7 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
72
77
  const house = opts.include ?? (houseOnDisk ? HOUSE_INCLUDE : [])
73
78
  if (opts.include == null && !houseOnDisk) console.error(`skip: no house wiki at ${housePath(home)} — CLAUDE.md gets the agent route only, and no .claude/ policy files`)
74
79
  const alt = opts.alsoOk ?? HOUSE_ALSO_OK
75
- const skipped = (opts.skip ?? HOUSE_SKIP).includes(name)
80
+ const skipped = isSkipped(name, opts.skip)
76
81
  if (skipped && house.length > 0) console.error(`skip: ${name} is include-exempt — CLAUDE.md untouched`)
77
82
  // start is the from-anywhere verb, and a STARTED repo comes out READY:
78
83
  // any dir with no commit history gets a minimal package.json, a first
@@ -146,6 +151,13 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
146
151
  else console.error(`WARN dependency install failed: ${firstGhLine(r.out)}`)
147
152
  }
148
153
  }
154
+ // a repo with history that a house adopts: its own files win, and the
155
+ // house half of the day-one shape lands beside them, so the include
156
+ // lines below route at an agent wiki that exists
157
+ if (wired && !startable && house.length > 0) {
158
+ try { adoptHouse(dir, { name, print }) }
159
+ catch (e) { console.error(`WARN could not seed the house files in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
160
+ }
149
161
  let unscaffolded = false
150
162
  if (!existsSync(join(dir, 'repo.config.ts'))) {
151
163
  if (existsSync(join(dir, 'package.json'))) {
@@ -18,7 +18,7 @@ export type SerialSuite = {
18
18
  why: string
19
19
  }
20
20
  export type TestsConfig = {
21
- /** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when the smaller of its CPU and its wall passes this, grown with the load share by up to half, or when its wall passes twice this, an allowance that grows with the square of the load share and has no end on a saturated machine. A suite whose CPU inside the parallel run, children included, plus all its worker burned before it stays under it skips that run, unless its wall is past that allowance or it ended with a child not yet reaped. The full tier fails nothing on time */
21
+ /** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when the smaller of its CPU and its wall passes this, grown with the load share by up to half, or when its wall passes twice this, an allowance that grows with the square of the load share and has no end on a saturated machine. A suite whose own CPU inside the parallel run, children included, is inside that CPU allowance skips that run, unless its wall is past the wait allowance or it ended with a child not yet reaped. What earlier suites of its worker burned is never charged to it. The full tier fails nothing on time */
22
22
  budgetMs?: number
23
23
  /** suites that break the budget on purpose: the full tier runs them, the quick tier does not */
24
24
  slow?: SlowSuite[]
@@ -56,6 +56,10 @@ const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceil
56
56
 
57
57
  export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
58
58
 
59
+ /** bun never discovers a file under a dot dir (`.shipkit/repros/` relies on
60
+ * it), so neither does the gate */
61
+ export const inDotDir = (file: string): boolean => normSuitePath(file).split('/').slice(0, -1).some((d) => d.startsWith('.') && d !== '.' && d !== '..')
62
+
59
63
  /** the arguments after `bun test`, or null when a shell would interpret the script
60
64
  * (cd, env prefix, pipes, quotes, substitution): that body stays the repo's own */
61
65
  export type BunfigTest = { root: string | null; ignores: string[]; error: string | null }
@@ -282,49 +286,46 @@ export const overWords = (c: Confirmed, load: MachineLoad, budgetMs: number = DE
282
286
  * (suite-cpu-preload.ts writes it, readPhaseCpu reads it) */
283
287
  export const SUITE_CPU_ENV = 'SHIPKIT_SUITE_CPU'
284
288
 
285
- /** one suite's figures from the parallel phase: its own CPU, children
286
- * included, and all its worker burned before it began (suite-cpu-preload.ts
287
- * says why the sum bounds a lone run) */
288
- export type PhaseCpu = { cpuMs: number; priorMs: number }
289
-
290
289
  /** what the workers wrote, keyed like the timings: relative to the run's cwd.
291
- * `ran` is every suite that finished in the phase, `cpu` the ones whose
292
- * figure can be trusted. A file bun ran twice keeps its larger bound, and
293
- * none at all once one of its runs could not be trusted */
294
- export function readPhaseCpu(text: string, cwd: string): { ran: string[]; cpu: Record<string, PhaseCpu> } {
290
+ * `ran` is every suite that finished in the phase, `cpu` the own CPU of the
291
+ * ones whose figure can be trusted (suite-cpu-preload.ts). A file bun ran
292
+ * twice keeps its larger figure, and none at all once one of its runs could
293
+ * not be trusted */
294
+ export function readPhaseCpu(text: string, cwd: string): { ran: string[]; cpu: Record<string, number> } {
295
295
  const ran = new Set<string>()
296
296
  const unsure = new Set<string>()
297
- const cpu: Record<string, PhaseCpu> = {}
297
+ const cpu: Record<string, number> = {}
298
298
  const base = cwd.replace(/\\/g, '/')
299
299
  for (const line of text.split('\n')) {
300
- const [file, ms, prior] = line.split('\t')
301
- if (file == null || file === '' || ms == null || prior == null) continue
300
+ const [file, ms] = line.split('\t')
301
+ if (file == null || file === '' || ms == null) continue
302
302
  const rel = normSuitePath(posix.relative(base, file.replace(/\\/g, '/')))
303
- if (ms === '-' && prior === '-') { ran.add(rel); unsure.add(rel); continue }
304
- if (!/^\d+$/.test(ms) || !/^\d+$/.test(prior)) continue
303
+ if (ms === '-') { ran.add(rel); unsure.add(rel); continue }
304
+ if (!/^\d+$/.test(ms)) continue
305
305
  ran.add(rel)
306
- const next = { cpuMs: Number(ms), priorMs: Number(prior) }
307
- const had = cpu[rel]
308
- if (had == null || next.cpuMs + next.priorMs > had.cpuMs + had.priorMs) cpu[rel] = next
306
+ cpu[rel] = Math.max(cpu[rel] ?? 0, Number(ms))
309
307
  }
310
308
  for (const f of unsure) delete cpu[f]
311
309
  return { ran: [...ran].sort(), cpu }
312
310
  }
313
311
 
314
- /** which suspects still need their alone run. A lone run can only need
315
- * what the worker computed by the suite's end, its whole CPU before the
316
- * suite (priorMs) plus what the suite burned, and sharing the machine only
317
- * raises CPU, so a suspect whose sum is inside the budget is inside it alone
318
- * too. A suspect with no figure (a child not yet reaped) runs alone. Only the
319
- * alone run can see a WAIT, so a suite whose wall is past its wait
320
- * allowance still gets it */
321
- export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, PhaseCpu>, budgetMs: number, load: MachineLoad): { rerun: Timed[]; cleared: Confirmed[] } {
312
+ /** which suspects still need their alone run. A suspect is judged on its OWN
313
+ * CPU in the parallel phase, charged and allowed as a lone run is
314
+ * (confirmOver): the load it suffered there only raised that figure, so one
315
+ * inside the allowance is cleared. What the earlier files of its worker
316
+ * burned is never its cost, a module they loaded or a memo they filled
317
+ * included: the first suite of a worker that needs shared work pays for it.
318
+ * A suspect with no figure (a child not yet reaped) or over its allowance
319
+ * runs alone, and only the alone run can see a WAIT, so a suite whose wall
320
+ * is past its wait allowance still gets it */
321
+ export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, number>, budgetMs: number, load: MachineLoad): { rerun: Timed[]; cleared: Confirmed[] } {
322
322
  const rerun: Timed[] = []
323
323
  const cleared: Confirmed[] = []
324
324
  for (const t of suspects) {
325
- const f = parallelCpu[t.file]
325
+ const cpuMs = parallelCpu[t.file]
326
326
  const mayWait = t.ms > waitAllowance(budgetMs, load)
327
- if (f != null && f.cpuMs + f.priorMs <= budgetMs && !mayWait) cleared.push({ file: t.file, ms: f.cpuMs, wallMs: t.ms, cpuMs: f.cpuMs, over: null })
327
+ const charged = cpuMs == null ? null : Math.min(cpuMs, t.ms)
328
+ if (charged != null && charged <= cpuAllowance(budgetMs, load) && !mayWait) cleared.push({ file: t.file, ms: charged, wallMs: t.ms, cpuMs: cpuMs ?? null, over: null })
328
329
  else rerun.push(t)
329
330
  }
330
331
  return { rerun, cleared }
@@ -31,6 +31,7 @@ import { blobOid, listingHash } from '../core/read-set.ts'
31
31
  import { shipkitRoot } from '../ledger/db.ts'
32
32
  import { extractSpecs, resolveSpec } from '../ts/source-graph.ts'
33
33
  import { type RvtConfig } from '../ts/config.ts'
34
+ import { inDotDir } from './judge-suites.ts'
34
35
 
35
36
  export const SELECT_VERSION = 5
36
37
 
@@ -489,6 +490,7 @@ export function suiteFiles(top: string, cwdRel: string, testRoot: string | null,
489
490
  for (const f of listed) {
490
491
  if (f === '' || !isTest(f) || !existsSync(join(top, f))) continue
491
492
  const rel = cwdRel === '' || cwdRel === '.' ? f : f.slice(cwdRel.length + 1)
493
+ if (inDotDir(rel)) continue
492
494
  if (res.some((re) => re.test(rel))) continue
493
495
  out.push(rel)
494
496
  }
@@ -57,7 +57,10 @@ async function runPhase(args: string[], timingsFile: string | null, ceilingMs: n
57
57
  if (echo) sink.write(text)
58
58
  }
59
59
  }
60
- await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stderr)])
60
+ // bun reports on stderr, and it is echoed on stdout: two pipes reach the
61
+ // gate in either order, so bun's `Ran 0 tests` could land after the FAIL
62
+ // printed below and become the headline. One stream keeps the order
63
+ await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stdout)])
61
64
  const code = await proc.exited
62
65
  clearTimeout(ceiling)
63
66
  clearTimeout(killer)
@@ -224,7 +227,7 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
224
227
  }
225
228
  const aloneMs = Date.now() - aloneAt
226
229
  const { over, cleared } = rerun.length > 0 ? confirmOver(rerun, aloneRuns, budget, load) : { over: [], cleared: [] }
227
- for (const c of clearedInPhase) notes.push(`note: ${c.file} took ${secs(c.wallMs)} in the parallel run and burned ${secs(c.cpuMs ?? 0)} of CPU there, plus ${secs(parallelCpu[c.file]?.priorMs ?? 0)} a lone run loads first, inside the ${budget}ms budget, so it did not run again alone`)
230
+ for (const c of clearedInPhase) notes.push(`note: ${c.file} took ${secs(c.wallMs)} in the parallel run but burned ${secs(c.ms)} of its own CPU there, inside the ${budget}ms budget (load ${load.load1.toFixed(1)} on ${load.cores} cores), so it did not run again alone`)
228
231
  for (const c of cleared) notes.push(`note: ${c.file} took ${secs(c.wallMs)} alone but burned ${c.cpuMs != null ? secs(c.cpuMs) : '?'} of CPU, inside the ${budget}ms budget (load ${load.load1.toFixed(1)} on ${load.cores} cores)`)
229
232
 
230
233
  // a ceiling kill makes bun report every worker it held as crashed, and
@@ -254,7 +257,7 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
254
257
  // rerun, the parallel phase's own figure, or a phase that ran that one file
255
258
  const phaseOf = (f: string): SuitePhase => (p2 != null && Object.keys(p2.timings).some((k) => normSuitePath(k) === f) ? 'serial' : parallel ? 'parallel' : 'sequential')
256
259
  const soloCpu = (p: Phase | null): number | null => (p != null && Object.keys(p.timings).length === 1 ? p.cpuMs : null)
257
- const cpuOf = (f: string): number | null => aloneRuns[f]?.cpuMs ?? (phaseOf(f) === 'serial' ? soloCpu(p2) : parallelCpu[f]?.cpuMs ?? soloCpu(p1))
260
+ const cpuOf = (f: string): number | null => aloneRuns[f]?.cpuMs ?? (phaseOf(f) === 'serial' ? soloCpu(p2) : parallelCpu[f] ?? soloCpu(p1))
258
261
  const check = process.env[CHECK_ID_ENV] ?? ''
259
262
  const serialFiles = p2 == null ? 0 : Object.keys(p2.timings).length
260
263
  const unrecorded = recordSuites(process.cwd(), Object.entries(timings).map(([file, ms]) => {