rvlib-shipkit 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -0
  3. package/package.json +1 -1
  4. package/src/cli/cmd_Bump.ts +2 -0
  5. package/src/cli/cmd_Ci.ts +3 -1
  6. package/src/cli/cmd_Pb.ts +27 -0
  7. package/src/cli/cmd_Release.ts +1 -1
  8. package/src/cli/cmd_Wiki.ts +2 -2
  9. package/src/cli.ts +49 -39
  10. package/src/collections/graphs.ts +17 -19
  11. package/src/collections/providers.ts +7 -2
  12. package/src/contracts/contracts-once.ts +43 -0
  13. package/src/core/canonical-scripts.ts +33 -0
  14. package/src/core/check-ctx.ts +13 -0
  15. package/src/core/check-suite.ts +121 -53
  16. package/src/core/floor.ts +82 -0
  17. package/src/core/motherhome.ts +14 -1
  18. package/src/core/run-check.ts +34 -0
  19. package/src/core/run-ci.ts +63 -2
  20. package/src/core/run-fixers.ts +6 -1
  21. package/src/ledger/toolchain-identity.ts +134 -0
  22. package/src/pb/collection-scaffold.ts +299 -0
  23. package/src/pb/pb-types.ts +233 -0
  24. package/src/pb/run-pb.ts +157 -0
  25. package/src/release/git-facts.ts +3 -2
  26. package/src/release/release-scope.ts +52 -0
  27. package/src/release/releases-share.ts +85 -0
  28. package/src/release/run-bump.ts +31 -8
  29. package/src/release/run-release.ts +8 -1
  30. package/src/release/run-releases.ts +20 -6
  31. package/src/repo-config.ts +22 -65
  32. package/src/rules/SK001-package-json-shape.ts +65 -2
  33. package/src/rules/SK002-contracts-fulfillness.ts +2 -2
  34. package/src/rules/SK004-gitignore-policy.ts +27 -3
  35. package/src/rules/SK012-config-barrel-import.ts +60 -0
  36. package/src/rules/rule-registry.ts +2 -0
  37. package/src/rules/untrack-generated.ts +117 -0
  38. package/src/scaffold/project-scaffold.ts +1 -1
  39. package/src/serve/hub-port.ts +17 -0
  40. package/src/serve/run-serve.ts +54 -20
  41. package/src/serve/serve-hub.ts +13 -15
  42. package/src/shipkit.ts +3 -2
  43. package/src/wiki/graph-mount.ts +21 -0
  44. package/src/wiki/run-wiki.ts +18 -8
@@ -2,11 +2,17 @@
2
2
  // private:true, no license, capability-required script names FAIL, banned
3
3
  // aliases + missing baseline scripts WARN. Blocking, so it runs in `shipkit
4
4
  // ci` like every other rule. Pure local fs, no TTL.
5
+ //
6
+ // It also enforces the package.json half of ONE WAY IN (build/shipkit.md):
7
+ // `test`, `lint` and `typecheck` ROUTE to `shipkit ci --only <id>` and the
8
+ // tool body they used to hold sits at `<id>:raw`. That is what makes a cache
9
+ // worth having: `bun run test` is the reflex, and the reflex has to land in
10
+ // shipkit rather than beside it.
5
11
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
6
12
  import { join } from 'path'
7
- import { canonicalBodies, conditionalBodies, inferBin } from '../core/canonical-scripts.ts'
13
+ import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
8
14
  import type { CheckCtx } from '../core/check-ctx.ts'
9
- import type { RepoConfig } from '../repo-config.ts'
15
+ import { resolveFloor, type RepoConfig } from '../repo-config.ts'
10
16
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
11
17
 
12
18
  type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
@@ -68,6 +74,23 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
68
74
  const missing = required.filter((r) => !has(r.name))
69
75
  for (const m of missing) t.fail(`package.json: missing script '${m.name}' (${m.why})`)
70
76
 
77
+ // ONE WAY IN — a governed script that runs the tool directly is the whole
78
+ // reason a cache gets bypassed: an agent types `bun run test` and shipkit
79
+ // never hears about it. A slot the repo turned off has no shipkit task to
80
+ // route to, so it is not governed.
81
+ const floor = resolveFloor(c, root)
82
+ for (const id of ROUTED_SCRIPTS) {
83
+ if (routedExempt(c).has(id) || c.defaults?.[id] === false) continue
84
+ const body = scripts[id]
85
+ if (body == null) continue
86
+ if (!routesToShipkit(body)) {
87
+ t.fail(`package.json: script '${id}' runs the tool directly — route it to \`${routedBody(id, { bin: inferBin(scripts) })}\` and move the body to '${rawName(id)}', so \`bun run ${id}\` gets the cache instead of bypassing it`)
88
+ continue
89
+ }
90
+ const gap = floor.unfilled.find((u) => u.id === id)
91
+ if (gap != null) t.fail(`package.json: ${gap.reason} — \`bun run ${id}\` would measure nothing`)
92
+ }
93
+
71
94
  // `test` is asked for only once the repo HAS tests: the fixer deliberately
72
95
  // refuses to scaffold it before that (a fixer never manufactures a
73
96
  // failure), so nagging for it would be a WARN no machine may clear
@@ -96,6 +119,29 @@ function requiredScripts(root: string, _c: RepoConfig): Array<{ name: string; wh
96
119
  return required
97
120
  }
98
121
 
122
+ /** rebuild the script map with `key` inserted directly after `after`, so a
123
+ * routed pair reads as two adjacent lines instead of the tool body landing at
124
+ * the bottom of the file, far from the name it belongs to */
125
+ function insertAfter(scripts: Record<string, string>, after: string, key: string, value: string): Record<string, string> {
126
+ const out: Record<string, string> = {}
127
+ for (const [k, v] of Object.entries(scripts)) {
128
+ if (k === key) continue
129
+ out[k] = v
130
+ if (k === after) out[key] = value
131
+ }
132
+ if (out[key] == null) out[key] = value
133
+ return out
134
+ }
135
+
136
+ /** governed names this repo keeps as a direct tool call. Declared per NAME in
137
+ * repo.config, never as a list of banned binaries: the invariant is about
138
+ * which VERBS an agent reaches for, and a binary blocklist goes stale the day
139
+ * a repo changes runner. `defineRepo` rejects a name outside ROUTED_SCRIPTS,
140
+ * so a typo can never silently un-govern a script. */
141
+ function routedExempt(c: RepoConfig): Set<string> {
142
+ return new Set(c.oneWayIn?.exempt ?? [])
143
+ }
144
+
99
145
  const TEST_SKIP = new Set(['node_modules', 'dist', 'tmp', 'references', 'coverage'])
100
146
 
101
147
  /** any `*.test.ts` in the repo, so the test script is only scaffolded once it
@@ -164,6 +210,23 @@ export const packageJsonShape: Rule = {
164
210
  // none, so scaffolding this into a repo without any turns the gate red
165
211
  // on work the fixer itself did. a fixer must never manufacture a failure.
166
212
  if (pkg.scripts['test'] == null && hasTestFiles(env.root)) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
213
+ // ONE WAY IN, last so it also routes whatever this pass just added:
214
+ // the tool body slides to `<id>:raw` and `<id>` becomes the shipkit
215
+ // call. Both keys keep their place in the file, so the diff a human
216
+ // reads is the two lines that changed and not a reordered block
217
+ for (const id of ROUTED_SCRIPTS) {
218
+ if (routedExempt(env.config).has(id) || env.config.defaults?.[id] === false) continue
219
+ const body = pkg.scripts[id]
220
+ if (body == null || routesToShipkit(body)) continue
221
+ const raw = rawName(id)
222
+ const held = pkg.scripts[raw]
223
+ // a `<id>:raw` that already says something else is a repo's own
224
+ // answer, and overwriting it would delete a body nobody can recover
225
+ if (held != null && held !== body) { notes.push(`'${id}' left as it is — '${raw}' already holds a different body`); continue }
226
+ pkg.scripts = insertAfter(pkg.scripts, id, raw, body)
227
+ pkg.scripts[id] = routedBody(id, { bin })
228
+ changes.push(`script '${id}' routed through shipkit ('${raw}' holds the tool)`)
229
+ }
167
230
  if (changes.length > 0) {
168
231
  const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
169
232
  writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
@@ -6,7 +6,7 @@
6
6
  // `shipkit contracts`, the ci `contracts` task) tags with this id via
7
7
  // contracts/apply-contracts.ts.
8
8
  import { activeContracts, applyContractsResult, contractsConfigFrom } from '../contracts/apply-contracts.ts'
9
- import { evaluateContracts } from '../contracts/contracts-engine.ts'
9
+ import { evaluateContractsOnce } from '../contracts/contracts-once.ts'
10
10
  import type { Rule } from './rule.ts'
11
11
 
12
12
  export const contractsFulfillness: Rule = {
@@ -15,6 +15,6 @@ export const contractsFulfillness: Rule = {
15
15
  title: 'contracts',
16
16
  owner: 'meta/contracts.md',
17
17
  lint: async (t, env) => {
18
- applyContractsResult(t, await evaluateContracts(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
18
+ applyContractsResult(t, await evaluateContractsOnce(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
19
19
  },
20
20
  }
@@ -11,6 +11,7 @@
11
11
  import { existsSync, readFileSync, writeFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import type { RepoConfig } from '../repo-config.ts'
14
+ import { GENERATED_IGNORE_LINES, trackedGeneratedPaths, untrackGenerated } from './untrack-generated.ts'
14
15
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
15
16
 
16
17
  const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
@@ -37,6 +38,10 @@ const POLICY_LINES = [
37
38
  // so every consumer repo needs both ignored before its first commit
38
39
  '.comfy-ts/',
39
40
  '*.loras.local.txt',
41
+ // shipkit's own per-run output. Ignoring is only half of it: the fixer
42
+ // also removes these from the index, because .gitignore does nothing to a
43
+ // file already tracked (untrack-generated.ts owns the pair)
44
+ ...GENERATED_IGNORE_LINES,
40
45
  ]
41
46
 
42
47
  export function expectedBlock(config: RepoConfig): string {
@@ -96,6 +101,13 @@ export const gitignorePolicy: Rule = {
96
101
  title: '.gitignore',
97
102
  owner: 'meta/contracts.md',
98
103
  lint: (t, env) => {
104
+ // a generated path still in the index is the half-applied state the
105
+ // managed block cannot fix on its own
106
+ const tracked = trackedGeneratedPaths(env.root)
107
+ if (tracked.length > 0) {
108
+ t.fail(`${tracked.join(', ')} generated on every run but TRACKED — gitignore does not apply to a tracked file, so it dirties every tree`)
109
+ t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) untracks it in one commit, the file stays on disk' })
110
+ }
99
111
  const s = blockState(env.root, env.config)
100
112
  switch (s) {
101
113
  case 'missing-file': return t.fail('.gitignore missing — the contract git policy is unenforced')
@@ -107,8 +119,20 @@ export const gitignorePolicy: Rule = {
107
119
  },
108
120
  fix: (env: RuleEnv): FixResult => {
109
121
  const before = blockState(env.root, env.config)
110
- if (before === 'current') return { changed: false, summary: 'policy block already current' }
111
- writeBlock(env.root, env.config)
112
- return { changed: true, summary: before === 'missing-file' ? '.gitignore created with the policy block' : before === 'missing-block' ? 'policy block appended (repo lines untouched)' : before === 'corrupted' ? 'policy block rebuilt (stray markers dropped, repo lines kept visible)' : 'policy block rewritten in place (repo lines untouched)' }
122
+ const blockSummary = before === 'current'
123
+ ? null
124
+ : before === 'missing-file' ? '.gitignore created with the policy block'
125
+ : before === 'missing-block' ? 'policy block appended (repo lines untouched)'
126
+ : before === 'corrupted' ? 'policy block rebuilt (stray markers dropped, repo lines kept visible)'
127
+ : 'policy block rewritten in place (repo lines untouched)'
128
+ if (blockSummary != null) writeBlock(env.root, env.config)
129
+ // the block must be written FIRST: untracking a path the ignore list
130
+ // does not yet carry leaves it staged for re-add on the next `git add`
131
+ const un = untrackGenerated(env.root, { enabled: env.config.untrackGenerated })
132
+ const parts = [blockSummary]
133
+ if (un.did === 'untracked') parts.push(`untracked ${un.paths.join(', ')} (generated, now gitignored, committed as ${un.commit})`)
134
+ if (un.did === 'refused') parts.push(`left ${trackedGeneratedPaths(env.root).join(', ')} tracked: ${un.why}`)
135
+ const summary = parts.filter((x): x is string => x != null).join(' · ')
136
+ return { changed: blockSummary != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
113
137
  },
114
138
  }
@@ -0,0 +1,60 @@
1
+ // SK012 — `repo.config.ts` must import from the NARROW module, never from the
2
+ // package barrel.
3
+ //
4
+ // THE COST, measured on this tree: `import { defineRepo } from 'rvlib-shipkit'`
5
+ // pulls the whole toolkit, 405ms of module load. The same symbol from
6
+ // `rvlib-shipkit/src/repo-config.ts` is 7ms. Every shipkit invocation in every
7
+ // repo loads this file first, the ~1s pre-commit hook included, so the barrel
8
+ // is a fixed tax on every gate run, every hook, every hub boot.
9
+ //
10
+ // A barrel is a fine PUBLIC surface and a terrible one to import from a config
11
+ // file that exists to be read before anything else happens. This rule is
12
+ // narrow on purpose: it is about repo.config.ts, not about barrels in general.
13
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
14
+ import { join } from 'path'
15
+ import type { FixResult, Rule, RuleEnv } from './rule.ts'
16
+
17
+ const CONFIG = 'repo.config.ts'
18
+ const BARREL = /(^|\n)(\s*import\s+(?:type\s+)?\{[^}]*\}\s+from\s+)['"]rvlib-shipkit['"]/
19
+ const NARROW = 'rvlib-shipkit/src/repo-config.ts'
20
+
21
+ /** the symbols a config file can legitimately want, all of which the narrow
22
+ * module already exports. A config reaching for anything else is asking the
23
+ * barrel for a reason, and the rule says so rather than rewriting it. */
24
+ const CONFIG_SYMBOLS = new Set(['defineRepo', 'RepoConfig', 'TaskDef', 'ServeConfig', 'FloorSource', 'FloorResolution', 'loadRepoConfig', 'resolveFloor', 'allTasks'])
25
+
26
+ const importedSymbols = (line: string): string[] =>
27
+ (/\{([^}]*)\}/.exec(line)?.[1] ?? '').split(',').map((s) => s.replace(/^\s*type\s+/, '').trim()).filter((s) => s !== '')
28
+
29
+ export const configBarrelImport: Rule = {
30
+ id: 'SK012',
31
+ blocking: true,
32
+ title: 'config import path',
33
+ owner: 'build/shipkit.md',
34
+ lint: (t, env) => {
35
+ const p = join(env.root, CONFIG)
36
+ if (!existsSync(p)) return void t.ok(`${CONFIG} absent — nothing to narrow`)
37
+ const src = readFileSync(p, 'utf8')
38
+ if (!BARREL.test(src)) return void t.ok(`${CONFIG} imports the narrow config module`)
39
+ const line = src.split('\n').find((l) => /from\s+['"]rvlib-shipkit['"]/.test(l)) ?? ''
40
+ const symbols = importedSymbols(line)
41
+ const strays = symbols.filter((s) => !CONFIG_SYMBOLS.has(s))
42
+ if (strays.length > 0) {
43
+ t.fail(`${CONFIG} imports ${strays.join(', ')} from the package barrel, which costs ~400ms of module load on every shipkit run`)
44
+ return void t.lines.push({ kind: 'fix-fail', text: `move ${strays.join(', ')} to their own module, then import from ${NARROW}` })
45
+ }
46
+ t.fail(`${CONFIG} imports from the package barrel (~400ms of module load on every shipkit run, the pre-commit hook included)`)
47
+ },
48
+ fix: (env: RuleEnv): FixResult => {
49
+ const p = join(env.root, CONFIG)
50
+ if (!existsSync(p)) return { changed: false, summary: `${CONFIG} absent` }
51
+ const src = readFileSync(p, 'utf8')
52
+ if (!BARREL.test(src)) return { changed: false, summary: 'already narrow' }
53
+ const line = src.split('\n').find((l) => /from\s+['"]rvlib-shipkit['"]/.test(l)) ?? ''
54
+ // only rewrite what the narrow module actually exports: silently
55
+ // repointing an import it cannot serve turns a slow gate into a broken one
56
+ if (importedSymbols(line).some((s) => !CONFIG_SYMBOLS.has(s))) return { changed: false, summary: 'imports symbols the narrow module does not export — move them by hand' }
57
+ writeFileSync(p, src.replace(BARREL, `$1$2'${NARROW}'`))
58
+ return { changed: true, summary: `${CONFIG} now imports ${NARROW} (~400ms saved per run)` }
59
+ },
60
+ }
@@ -14,6 +14,7 @@ import { reviewerAgent } from './SK008-reviewer-agent.ts'
14
14
  import { commitAiTells } from './SK009-commit-ai-tells.ts'
15
15
  import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
16
16
  import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
17
+ import { configBarrelImport } from './SK012-config-barrel-import.ts'
17
18
 
18
19
  export const RULES: readonly Rule[] = [
19
20
  packageJsonShape,
@@ -27,6 +28,7 @@ export const RULES: readonly Rule[] = [
27
28
  commitAiTells,
28
29
  undeclaredReleaseUnit,
29
30
  siblingUnitRanges,
31
+ configBarrelImport,
30
32
  ]
31
33
 
32
34
  // THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
@@ -0,0 +1,117 @@
1
+ // paths shipkit GENERATES on every run, and the one operation that makes a
2
+ // declaration like that actually hold.
3
+ //
4
+ // THE CONTRADICTION THIS RESOLVES: `.gitignore` does not apply to a file that
5
+ // is already tracked. So a path shipkit ignores but leaves in the index keeps
6
+ // dirtying every tree forever, and rides into unrelated commits as a mystery
7
+ // diff. Ignoring and untracking are therefore ONE operation, never two, and
8
+ // any path declared machine-generated gets both.
9
+ //
10
+ // STATUS.md is the first customer and the reason: 141 of this repo's 625
11
+ // commits carried it, 125 of them as an unrelated rider inside someone else's
12
+ // diff, in a repo whose commits ARE the session-resume mechanism. It is
13
+ // nondeterministic by construction (minute-resolution header, task lines in
14
+ // DAG completion order, per-worktree cache stamps that move backwards), and
15
+ // nothing on this disk ever reads a past version: the hub reads it off disk,
16
+ // the TUI renders live, the tests assert the writer.
17
+ //
18
+ // WHY THIS ONE FIXER COMMITS, when shipkit otherwise never does: `git rm
19
+ // --cached` leaves a STAGED DELETION in the index, which the next agent's
20
+ // unrelated commit sweeps up as an unexplained deletion. That is strictly
21
+ // worse than the disease. The commit is a one-time migration per path, it
22
+ // goes through the hooks like any other, and it refuses in every state where
23
+ // it could surprise someone.
24
+ import { existsSync } from 'fs'
25
+ import { join } from 'path'
26
+
27
+ /** rendered into the SK004 managed block. A glob is fine HERE: ignoring is
28
+ * not destructive. */
29
+ export const GENERATED_IGNORE_LINES = [
30
+ '# shipkit writes these on every run — ignored AND untracked (untrack-generated.ts)',
31
+ 'STATUS.md',
32
+ 'STATUS.md.lock',
33
+ '.STATUS.md.tmp-*',
34
+ ]
35
+
36
+ /** the LITERAL subset an autofix may remove from the index. Never a glob and
37
+ * never derived from a pattern: `git rm --cached` driven by a wildcard is how
38
+ * a fixer eats source it was never pointed at. A future entry is added HERE,
39
+ * by hand, spelled out. */
40
+ export const GENERATED_TRACKED_PATHS = ['STATUS.md', 'STATUS.md.lock'] as const
41
+
42
+ export type UntrackOutcome =
43
+ | { did: 'nothing' }
44
+ | { did: 'refused'; why: string }
45
+ | { did: 'untracked'; paths: string[]; commit: string }
46
+
47
+ const git = (root: string, args: string[]): { code: number; out: string } => {
48
+ const p = Bun.spawnSync(['git', ...args], { cwd: root })
49
+ return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}`.trim() }
50
+ }
51
+
52
+ /** every declared path this repo still carries in its index. ONE git spawn,
53
+ * not one per path: this runs inside the SK004 lint, which runs on every
54
+ * `shipkit check` in every repo, and a process start is ~10ms of it. */
55
+ export function trackedGeneratedPaths(root: string): string[] {
56
+ if (!existsSync(join(root, '.git'))) return []
57
+ const r = git(root, ['ls-files', '-z', '--', ...GENERATED_TRACKED_PATHS])
58
+ if (r.code !== 0) return []
59
+ const listed = new Set(r.out.split('\0').filter((l) => l !== ''))
60
+ return GENERATED_TRACKED_PATHS.filter((p) => listed.has(p))
61
+ }
62
+
63
+ /** a state where an automated commit would surprise someone, or land on the
64
+ * wrong ref. Named rather than boolean so the refusal says which one. */
65
+ function unsafeState(root: string): string | null {
66
+ const gitDir = git(root, ['rev-parse', '--git-dir'])
67
+ if (gitDir.code !== 0) return 'not a git work tree'
68
+ const d = join(root, gitDir.out)
69
+ if (existsSync(join(d, 'MERGE_HEAD'))) return 'a merge is in progress'
70
+ if (existsSync(join(d, 'rebase-merge')) || existsSync(join(d, 'rebase-apply'))) return 'a rebase is in progress'
71
+ if (git(root, ['symbolic-ref', '-q', 'HEAD']).code !== 0) return 'HEAD is detached'
72
+ if (git(root, ['rev-parse', '--verify', '-q', 'HEAD']).code !== 0) return 'the repo has no commit yet'
73
+ // the commit below stages nothing but our own removal, so ANY other staged
74
+ // change would ride along in it. A one-time migration can wait for a run
75
+ // where the index is the fixer's alone
76
+ const staged = git(root, ['diff', '--cached', '--name-only'])
77
+ if (staged.code === 0 && staged.out !== '') return `something else is already staged (${staged.out.split('\n')[0]})`
78
+ return null
79
+ }
80
+
81
+ const message = (paths: string[]): string =>
82
+ [
83
+ `chore(machine): untrack ${paths.join(', ')}`,
84
+ '',
85
+ `shipkit rewrites ${paths.length === 1 ? 'this path' : 'these paths'} on every run, so ${paths.length === 1 ? 'it was' : 'they were'} never source.`,
86
+ 'Tracked, it dirtied every tree and rode into unrelated commits as a',
87
+ 'mystery diff. Gitignore alone cannot fix that: it does not apply to a',
88
+ 'file already in the index, so the two halves land together.',
89
+ '',
90
+ 'The file stays on disk. Nothing reads a past version of it.',
91
+ ].join('\n')
92
+
93
+ /**
94
+ * ignore AND untrack, in one operation. Loud when it acts, loud when it
95
+ * refuses, silent when there is nothing to do.
96
+ *
97
+ * `frozen` is the release-shaped run: report only, touch nothing.
98
+ */
99
+ export function untrackGenerated(root: string, o: { frozen?: boolean; enabled?: boolean } = {}): UntrackOutcome {
100
+ if (o.enabled === false) return { did: 'nothing' }
101
+ if (o.frozen === true || process.env.SHIPKIT_FROZEN === '1') return { did: 'nothing' }
102
+ const paths = trackedGeneratedPaths(root)
103
+ if (paths.length === 0) return { did: 'nothing' }
104
+ const unsafe = unsafeState(root)
105
+ if (unsafe != null) return { did: 'refused', why: unsafe }
106
+
107
+ const rm = git(root, ['rm', '--cached', '--quiet', '--', ...paths])
108
+ if (rm.code !== 0) return { did: 'refused', why: `git rm --cached failed: ${rm.out.slice(0, 120)}` }
109
+ // through the hooks, never --no-verify. A rejected commit must not leave
110
+ // the staged deletion behind: that IS the failure mode this exists to stop
111
+ const commit = git(root, ['commit', '-m', message(paths)])
112
+ if (commit.code !== 0) {
113
+ git(root, ['reset', '--quiet', '--', ...paths])
114
+ return { did: 'refused', why: `the commit was rejected, index restored: ${commit.out.slice(0, 160)}` }
115
+ }
116
+ return { did: 'untracked', paths, commit: git(root, ['rev-parse', '--short', 'HEAD']).out }
117
+ }
@@ -12,7 +12,7 @@
12
12
  // none of them may name a machine-local tool or a private path.
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
14
  import { dirname, join } from 'path'
15
- import { defaultHubPort } from '../serve/run-serve.ts'
15
+ import { defaultHubPort } from '../serve/hub-port.ts'
16
16
 
17
17
  const FILES = join(import.meta.dir, 'files')
18
18
 
@@ -0,0 +1,17 @@
1
+ // the hub's default port, alone in its own module ON PURPOSE.
2
+ //
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
6
+ // the whole hub (react, the markdown renderer, the sanitiser, every panel)
7
+ // into a caller that only wanted a number, which measured 393ms of module
8
+ // load in the scaffold alone.
9
+ //
10
+ // A port derived from the repo NAME rather than assigned means two repos open
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.
13
+ export function defaultHubPort(name: string): number {
14
+ let h = 0
15
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
16
+ return 4800 + (h % 180)
17
+ }
@@ -1,12 +1,14 @@
1
1
  // shipkit serve — resolve the repo's toolbox and run the hub. What mounts
2
2
  // comes from convention + repo.config's `serve` block: the wiki graph is
3
3
  // always on (custom provider via serve.wikiGraph, else the generic md
4
- // scan), agent/ graph when the dir exists, the studio when .rv-assets/
4
+ // scan), the base-context wiki when a CLAUDE.md include names one, agent/
5
+ // graph when the dir exists, the studio when .rv-assets/
5
6
  // kit.ts loads. Port: --port > serve.port > a deterministic house-range
6
7
  // default (4800-4979, hashed from the repo name — stable across launches;
7
8
  // pin serve.port when two repos collide).
8
9
  import { existsSync, readFileSync } from 'fs'
9
- import { join } from 'path'
10
+ import { homedir } from 'os'
11
+ import { basename, dirname, join, resolve, sep } from 'path'
10
12
  import { fileURLToPath } from 'url'
11
13
  import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
12
14
  import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
@@ -15,30 +17,63 @@ import { graphsCollection } from '../collections/graphs.ts'
15
17
  import { pingTick } from '../collections/pings.ts'
16
18
  import { buildCollections } from '../collections/providers.ts'
17
19
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
20
+ import type { GraphMount } from '../wiki/graph-mount.ts'
18
21
  import { scanMdGraph } from '../wiki/scan-md.ts'
19
22
  import { serveHub, type HubDeps } from './serve-hub.ts'
23
+ import { defaultHubPort } from './hub-port.ts'
24
+ export { defaultHubPort }
20
25
 
21
26
  export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
22
27
 
23
- export function defaultHubPort(name: string): number {
24
- let h = 0
25
- for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
26
- return 4800 + (h % 180)
28
+
29
+ // the BASE-CONTEXT wiki: the shared wiki this repo's CLAUDE.md include chain
30
+ // points at (`@~/dev/<other-repo>/wiki/index.md`). It lives in ANOTHER repo and
31
+ // binds every agent working here, so the hub makes it browsable. Never linted
32
+ // from here. The local-include forms (`@./agent/index.md`,
33
+ // `@./CLAUDE.local.md`) and any index inside this repo are skipped: those are
34
+ // already their own mounts.
35
+ export function baseWikiRoot(root: string): string | null {
36
+ const abs = resolve(root)
37
+ for (const f of ['CLAUDE.md', 'CLAUDE.local.md', 'CLAUDE.private.md']) {
38
+ const p = join(abs, f)
39
+ if (!existsSync(p)) continue
40
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
41
+ const m = line.trim().match(/^@(\S+\.md)$/)
42
+ if (m == null) continue
43
+ const raw = m[1]!
44
+ const target = resolve(abs, raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw)
45
+ const dir = dirname(target)
46
+ if (!existsSync(target)) continue
47
+ if (dir === abs || dir.startsWith(abs + sep)) continue
48
+ return dir
49
+ }
50
+ }
51
+ return null
27
52
  }
28
53
 
54
+ // `~/dev/<repo>/wiki` reads as "<repo> wiki" — a dir literally named wiki says
55
+ // nothing on its own, so the owning repo names the card
56
+ const wikiLabel = (dir: string): string => (basename(dir) === 'wiki' ? `${basename(dirname(dir))} wiki` : basename(dir))
57
+
29
58
  export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o?: { port?: number }): Promise<HubDeps> {
30
- // the default wiki graph exists only when the repo HAS a wiki dir: a
31
- // game repo's hub advertising a wiki route was startup noise
32
- // 2026-08-05). A config-declared wikiGraph always mounts.
33
- const wikiGraph = c.serve?.wikiGraph != null
34
- ? { title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load }
35
- : existsSync(join(root, 'wiki'))
36
- ? { title: `${c.name} wiki`, load: () => scanMdGraph({ root }) }
37
- : null
59
+ // ONE list: the routes, the /hub.json tools and the graphs section all
60
+ // read it, so nothing can advertise a graph the server does not serve.
61
+ // The repo's OWN wiki mounts when it has one (a game repo's hub
62
+ // advertising an empty wiki route was startup noise), a config-declared
63
+ // provider always mounts, and the base-context wiki (the shared one named
64
+ // by the CLAUDE.md include chain) mounts wherever it is reachable: an
65
+ // agent working here obeys that wiki, so a reader can open it.
66
+ const graphs: GraphMount[] = []
67
+ if (c.serve?.wikiGraph != null)
68
+ graphs.push({ id: 'wiki', label: 'wiki graph', title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, lint: null, note: 'repo-owned lint' })
69
+ else if (existsSync(join(root, 'wiki')))
70
+ graphs.push({ id: 'wiki', label: 'wiki graph', title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
71
+ const baseWiki = baseWikiRoot(root)
72
+ if (baseWiki != null)
73
+ graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
38
74
  const agentRoot = join(root, 'agent')
39
- const agentGraph = existsSync(agentRoot)
40
- ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
41
- : null
75
+ if (existsSync(agentRoot))
76
+ graphs.push({ id: 'agent', label: 'agent graph', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
42
77
  // ONE page for every repo: the studio ALWAYS
43
78
  // mounts — kit-less repos get an empty media section, never a second
44
79
  // page. A broken kit degrades to the same empty kit, loudly.
@@ -60,15 +95,14 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
60
95
  const cli = fileURLToPath(new URL('../cli.ts', import.meta.url))
61
96
  const allCollections = () => [
62
97
  ...buildCollections(root, c, kit, { bootVersion: BOOT_VERSION, libVersion }),
63
- graphsCollection(root, { customWiki: c.serve?.wikiGraph != null, agentIndex: c.serve?.agentIndex }),
98
+ graphsCollection(graphs),
64
99
  ]
65
100
  const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
66
101
  return {
67
102
  root,
68
103
  name: c.name,
69
104
  port,
70
- wikiGraph,
71
- agentGraph,
105
+ graphs,
72
106
  studio,
73
107
  checkCmd: [process.execPath, cli, 'check'],
74
108
  needs: () => projectNeeds(allCollections()),
@@ -6,23 +6,21 @@
6
6
  import { existsSync } from 'fs'
7
7
  import { join } from 'path'
8
8
  import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
9
- import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
10
9
  import type { StudioRoutes } from '../assets/server/studio-server.ts'
11
10
  import { appendAskRemark, appendAssetNote, appendPostFeedback, askDone, askProof, markPosted, pickPostVariant } from '../collections/actions.ts'
12
11
  import type { Collection, Instance, NeedItem } from '../collections/collection-types.ts'
12
+ import type { GraphMount } from '../wiki/graph-mount.ts'
13
13
  import hubIndex from './hub-app/index.html'
14
14
 
15
- export type HubGraph = { title: string; load: () => MdGraph }
16
15
  export type HubTool = { path: string; label: string; desc: string }
17
16
  export type HubDeps = {
18
17
  root: string
19
18
  name: string
20
19
  /** 0 = random (tests); real launches pass repo.config/default */
21
20
  port: number
22
- /** null = this repo has no wiki to graph — the route stays unmounted */
23
- wikiGraph: HubGraph | null
24
- /** null = no agent/ dir */
25
- agentGraph: HubGraph | null
21
+ /** every md graph this hub serves, in card order: /graph/<id>. run-serve
22
+ * resolves the list, the routes AND the graphs section read it */
23
+ graphs: GraphMount[]
26
24
  /** the media API (mounted under /media/*); null only if the route
27
25
  * table failed to build — the page shows the WARN, never a second page */
28
26
  studio: StudioRoutes | null
@@ -43,11 +41,11 @@ export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop
43
41
 
44
42
  // the toolbox as data — stdout ok: lines and /hub.json render THIS list,
45
43
  // so they can never disagree
46
- export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph'>): HubTool[] {
47
- const tools: HubTool[] = [{ path: '/', label: 'hub', desc: 'THE page — media · asks · creds · posts · health · graphs' }]
48
- if (d.wikiGraph != null) tools.push({ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title })
49
- if (d.agentGraph != null) tools.push({ path: '/graph/agent', label: 'agent graph', desc: d.agentGraph.title })
50
- return tools
44
+ export function hubTools(d: Pick<HubDeps, 'graphs'>): HubTool[] {
45
+ return [
46
+ { path: '/', label: 'hub', desc: 'THE page — media · asks · creds · posts · health · graphs' },
47
+ ...d.graphs.map((g) => ({ path: `/graph/${g.id}`, label: g.label, desc: g.title })),
48
+ ]
51
49
  }
52
50
 
53
51
  // drill-down pages (graph canvases) get one fixed home link back to THE page
@@ -125,10 +123,10 @@ export function serveHub(d: HubDeps): Hub {
125
123
  return new Response(proc.stdout, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
126
124
  }
127
125
  const page = (s: string): Response => new Response(withHome(s), { headers: { 'content-type': 'text/html; charset=utf-8' } })
128
- if (d.wikiGraph != null && path === '/graph/wiki') return page(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
129
- if (d.wikiGraph != null && path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
130
- if (d.agentGraph != null && path === '/graph/agent') return page(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
131
- if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
126
+ for (const g of d.graphs) {
127
+ if (path === `/graph/${g.id}`) return page(graphHtml(g.load(), { title: g.title }))
128
+ if (path === `/graph/${g.id}/graph.json`) return Response.json(g.load())
129
+ }
132
130
  // the media API namespace — the studio route table, ONE prefix strip
133
131
  if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
134
132
  // stale bookmarks (/assets/, /status, …) land here — name the way home
package/src/shipkit.ts CHANGED
@@ -5,8 +5,9 @@ export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './re
5
5
  export { contractsConfigFrom, activeContracts, applyContractsResult } from './contracts/apply-contracts.ts'
6
6
  // the hub (`shipkit serve`): one server, one port, every tool a click away
7
7
  export { serveHub, hubTools } from './serve/serve-hub.ts'
8
- export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
9
- export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
8
+ export type { Hub, HubDeps, HubTool } from './serve/serve-hub.ts'
9
+ export type { GraphMount } from './wiki/graph-mount.ts'
10
+ export { runServe, resolveHubDeps, defaultHubPort, baseWikiRoot } from './serve/run-serve.ts'
10
11
  export { projectNeeds, redCount } from './collections/collection-types.ts'
11
12
  export type { Collection, Instance, NeedItem, NeedSeverity, CurationVerb } from './collections/collection-types.ts'
12
13
  export { buildCollections } from './collections/providers.ts'
@@ -0,0 +1,21 @@
1
+ // one MOUNTED md graph, as the hub serves it and as the graphs section shows
2
+ // it. ONE list per repo (serve/run-serve builds it): the route, the card and
3
+ // the lint verdict all read the same entries, so a card can never link at a
4
+ // route that is not served.
5
+ import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
6
+
7
+ export type GraphMount = {
8
+ /** route + card id: /graph/<id>, instance graph:<id> */
9
+ id: string
10
+ /** card text and tool label, e.g. `wiki graph`, `<repo> wiki` */
11
+ label: string
12
+ /** graph page title */
13
+ title: string
14
+ load: () => MdGraph
15
+ /** null = someone else owns this graph's lint (a repo-owned provider, or a
16
+ * wiki living in another repo): the card links, it never judges */
17
+ lint: { root: string; index?: string } | null
18
+ /** parenthesized on the CARD only, never in the terminal banner: why this
19
+ * graph carries no verdict here */
20
+ note?: string
21
+ }