rvlib-shipkit 0.22.0 → 0.22.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.2
4
+
5
+ - **Frozen gates never write the motherhome row.** The bump preflight ate the machine dirt, then the gate INSIDE the bump re-wrote the local-projects row and the post-gate cleanliness check refused, the same wart's second head. A frozen run is release-shaped: it reports nothing to the motherhome, so the tree it proves clean stays clean. Interactive ci/check keep reporting.
6
+ - **The hub banner says only what exists.** It printed the hub URL twice (the banner plus the '/' tool row) and advertised a wiki graph in repos with no wiki: the default wiki graph now mounts only when a `wiki/` dir exists (a config-declared one always mounts), the '/' tool stays in /hub.json but never re-prints, and a game repo's startup reads one hub line plus the routes it really serves.
7
+
8
+ ## 0.22.1
9
+
10
+ - **Fix: the cache stamp is baked in at RUN time, replays print it verbatim.** 0.22.0 annotated only the REPLAY, so a fresh run and a cache hit rendered different strings and STATUS.md still diffed on every alternation (his catch, the second one on this line). A TTL clean pass now writes "(cached at <stamp> until <stamp>)" into the line once, live output and every replay are byte-identical, and the line changes only when a re-validation happens. Proven: fresh-then-cached runs produce a zero-diff STATUS body.
11
+
3
12
  ## 0.22.0
4
13
 
5
14
  - **Fix: the machine-row auto-commit could not see STATUS.md.** The git helper trims its whole output, so the FIRST porcelain line loses its leading space and the old column-3 slice read "TATUS.md", making the preflight silently decline the exact dirt it was built to eat. `dirtyPathList` parses status prefixes robustly now (renames and quoted paths included, regression-tested on the trimmed-first-line case), and a decline is never silent: the preflight names the non-machine paths it refused.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -143,13 +143,14 @@ export class CheckSuite {
143
143
  && entry != null && entry.version === r.version && entry.key === key
144
144
  && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
145
145
  if (fresh) {
146
- // absolute stamp, never "Xm ago": a relative age re-renders on
147
- // every run and turns committed STATUS.md checkpoints into
148
- // noise diffs (his call 2026-08-05). The line now changes only
149
- // when the cache actually refreshes.
150
- const lines = entry.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached ${fmtStamp(entry.at)})` } : l))
151
- out.push(...lines)
152
- this.onResult?.(r.name, { lines, status: 'pass', cached: true }) // only clean passes are cached
146
+ // the stamp was baked in when the check RAN (below): a replay
147
+ // prints the stored lines VERBATIM, so the live run and every
148
+ // later replay render the same string, and the line changes
149
+ // only when a re-validation happens (his call 2026-08-05,
150
+ // second round: annotating replays but not live runs still
151
+ // diffed on every fresh-vs-cached alternation)
152
+ out.push(...entry.lines)
153
+ this.onResult?.(r.name, { lines: entry.lines, status: 'pass', cached: true }) // only clean passes are cached
153
154
  continue
154
155
  }
155
156
  const t = new CheckCtx(this.toolPaths)
@@ -160,14 +161,18 @@ export class CheckSuite {
160
161
  else if (e instanceof ToolUnresolved) t.note(`${r.name} skipped (${e.message})`)
161
162
  else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
162
163
  }
163
- out.push(...t.lines)
164
- this.onResult?.(r.name, { lines: t.lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
164
+ // a skip line is an UNKNOWN, not a clean pass — replaying
165
+ // "registry unreachable" for a TTL asserts a dead network as
166
+ // fact long after it recovered
167
+ const cacheable = r.ttlMs > 0 && !t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')
168
+ const lines = cacheable
169
+ ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
170
+ : t.lines
171
+ out.push(...lines)
172
+ this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
165
173
  if (t.failed) anyFail = true
166
174
  if (r.ttlMs > 0) {
167
- // a skip line is an UNKNOWN, not a clean pass — replaying
168
- // "registry unreachable" for a TTL asserts a dead network as
169
- // fact long after it recovered
170
- if (!t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
175
+ if (cacheable) { cache[r.name] = { version: r.version, key, at: this.now(), lines }; cacheDirty = true }
171
176
  // a WARN/FAIL evicts the stale pass — a later run must re-probe,
172
177
  // not replay an ok recorded before the breakage
173
178
  else if (entry != null) { delete cache[r.name]; cacheDirty = true }
@@ -123,7 +123,9 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
123
123
  const r = await suite.execute()
124
124
  if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
125
125
  if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
126
- reportToMotherhome(root, c)
126
+ // frozen = a release-shaped run: reporting would re-dirty the tree the
127
+ // caller is about to demand clean (field wart 2026-08-05, second head)
128
+ if (o.frozen !== true) reportToMotherhome(root, c)
127
129
  return { lines: r.lines, blocked: r.exitCode !== 0, advisoryFailures, undeclared }
128
130
  }
129
131
 
@@ -120,9 +120,13 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
120
120
  } else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
121
121
  }
122
122
  const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
123
- // motherhome row (docsBase wiki on this disk) — never affects the gate
124
- const mh = reportToMotherhome(root, c)
125
- if (mh.note != null) print(mh.note)
123
+ // motherhome row (docsBase wiki on this disk) — never affects the gate,
124
+ // and never fires under frozen: a bump/release gate must not re-dirty
125
+ // the tree it just proved clean (field wart 2026-08-05, second head)
126
+ if (o.frozen !== true) {
127
+ const mh = reportToMotherhome(root, c)
128
+ if (mh.note != null) print(mh.note)
129
+ }
126
130
  if (o.status !== false) {
127
131
  // scope is named in the file: ci proves the gate, not repo health, and
128
132
  // a bare GREEN here would claim clear-to-ship on a repo with red checks
@@ -27,9 +27,14 @@ export function defaultHubPort(name: string): number {
27
27
  }
28
28
 
29
29
  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 (his catch
32
+ // 2026-08-05). A config-declared wikiGraph always mounts.
30
33
  const wikiGraph = c.serve?.wikiGraph != null
31
34
  ? { title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load }
32
- : { title: `${c.name} wiki`, load: () => scanMdGraph({ root }) }
35
+ : existsSync(join(root, 'wiki'))
36
+ ? { title: `${c.name} wiki`, load: () => scanMdGraph({ root }) }
37
+ : null
33
38
  const agentRoot = join(root, 'agent')
34
39
  const agentGraph = existsSync(agentRoot)
35
40
  ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
@@ -105,7 +110,8 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
105
110
  const hub = serveHub(deps)
106
111
  const base = `http://localhost:${hub.server.port}`
107
112
  io.print(`ok: ${c.name} hub → ${base}`)
108
- for (const t of hub.tools) io.print(`ok: ${t.label} → ${base}${t.path}`)
113
+ // the '/' tool IS the banner line above — printing it again was noise
114
+ for (const t of hub.tools) if (t.path !== '/') io.print(`ok: ${t.label} → ${base}${t.path}`)
109
115
  // the off-machine door (phone, other machines) — publicHost is config
110
116
  if (c.serve?.publicHost != null) io.print(`ok: public → http://${c.serve.publicHost}:${hub.server.port}/`)
111
117
  await new Promise(() => {}) // the hub owns the process — never resolve
@@ -121,7 +127,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
121
127
  // a re-run terminal stays as clickable as a first run — the
122
128
  // owner's /hub.json tool list is the same source of truth
123
129
  io.print(`ok: hub already served → http://localhost:${deps.port} (another window owns it)`)
124
- for (const t of owner.tools ?? []) io.print(`ok: ${t.label} → http://localhost:${deps.port}${t.path}`)
130
+ for (const t of owner.tools ?? []) if (t.path !== '/') io.print(`ok: ${t.label} → http://localhost:${deps.port}${t.path}`)
125
131
  return 0
126
132
  }
127
133
  io.printErr(`FAIL: port ${deps.port} is taken by ${owner?.name != null ? `repo '${owner.name}'` : 'another process'} — set serve: { port } in repo.config.ts to a free one`)
@@ -19,7 +19,8 @@ export type HubDeps = {
19
19
  name: string
20
20
  /** 0 = random (tests); real launches pass repo.config/default */
21
21
  port: number
22
- wikiGraph: HubGraph
22
+ /** null = this repo has no wiki to graph — the route stays unmounted */
23
+ wikiGraph: HubGraph | null
23
24
  /** null = no agent/ dir */
24
25
  agentGraph: HubGraph | null
25
26
  /** the media API (mounted under /media/*); null only if the route
@@ -44,7 +45,7 @@ export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop
44
45
  // so they can never disagree
45
46
  export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph'>): HubTool[] {
46
47
  const tools: HubTool[] = [{ path: '/', label: 'hub', desc: 'THE page — media · asks · creds · posts · health · graphs' }]
47
- tools.push({ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title })
48
+ if (d.wikiGraph != null) tools.push({ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title })
48
49
  if (d.agentGraph != null) tools.push({ path: '/graph/agent', label: 'agent graph', desc: d.agentGraph.title })
49
50
  return tools
50
51
  }
@@ -124,8 +125,8 @@ export function serveHub(d: HubDeps): Hub {
124
125
  return new Response(proc.stdout, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
125
126
  }
126
127
  const page = (s: string): Response => new Response(withHome(s), { headers: { 'content-type': 'text/html; charset=utf-8' } })
127
- if (path === '/graph/wiki') return page(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
128
- if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
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())
129
130
  if (d.agentGraph != null && path === '/graph/agent') return page(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
130
131
  if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
131
132
  // the media API namespace — the studio route table, ONE prefix strip