rvlib-shipkit 0.11.0 → 0.13.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 (60) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +8 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Reflect.ts +116 -0
  12. package/src/cli/cmd_Rules.ts +32 -0
  13. package/src/cli/cmd_Serve.ts +1 -1
  14. package/src/cli.ts +5 -2
  15. package/src/collections/actions.ts +19 -0
  16. package/src/collections/collection-types.ts +13 -0
  17. package/src/collections/graphs.ts +34 -0
  18. package/src/collections/pings.ts +5 -1
  19. package/src/collections/posts.ts +20 -1
  20. package/src/collections/providers.ts +163 -50
  21. package/src/contracts/apply-contracts.ts +47 -0
  22. package/src/contracts/run-contracts.ts +8 -3
  23. package/src/core/canonical-scripts.ts +46 -0
  24. package/src/core/check-suite.ts +6 -3
  25. package/src/core/locked-write.ts +39 -0
  26. package/src/core/motherhome.ts +87 -0
  27. package/src/core/run-check.ts +28 -15
  28. package/src/core/run-ci.ts +4 -0
  29. package/src/core/write-status.ts +7 -26
  30. package/src/init-repo.ts +24 -11
  31. package/src/reflect/prompt-corpus.ts +305 -0
  32. package/src/repo-config.ts +20 -38
  33. package/src/rules/SK001-package-json-shape.ts +156 -0
  34. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  35. package/src/rules/SK003-claude-md-shape.ts +64 -0
  36. package/src/rules/SK004-gitignore-policy.ts +105 -0
  37. package/src/rules/SK005-rvlib-versions.ts +99 -0
  38. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  39. package/src/rules/SK007-github-visibility.ts +63 -0
  40. package/src/rules/rule-registry.ts +32 -0
  41. package/src/rules/rule.ts +62 -0
  42. package/src/rules/run-fix.ts +57 -0
  43. package/src/serve/hub-app/col-card.tsx +192 -0
  44. package/src/serve/hub-app/cols-svc.ts +136 -0
  45. package/src/serve/hub-app/geometry-view.tsx +155 -0
  46. package/src/serve/hub-app/health-svc.ts +49 -0
  47. package/src/serve/hub-app/hub-app.tsx +236 -215
  48. package/src/serve/hub-app/hub-st.ts +169 -0
  49. package/src/serve/hub-app/hub-types.ts +72 -0
  50. package/src/serve/hub-app/hub.css +286 -59
  51. package/src/serve/hub-app/index.html +1 -1
  52. package/src/serve/hub-app/md.ts +24 -0
  53. package/src/serve/hub-app/media-svc.ts +429 -0
  54. package/src/serve/hub-app/media-view.tsx +299 -0
  55. package/src/serve/run-serve.ts +23 -9
  56. package/src/serve/serve-hub.ts +62 -47
  57. package/src/shipkit.ts +9 -5
  58. package/src/assets/server/studio-page.ts +0 -687
  59. package/src/core/package-json-check.ts +0 -88
  60. package/src/serve/hub-pages.ts +0 -38
@@ -0,0 +1,39 @@
1
+ // ONE file-lock + atomic-write concern (extracted from write-status when
2
+ // motherhome became the second multi-writer): the lock spans a caller's
3
+ // whole read-modify-write, the write lands via tmp + rename so a killed
4
+ // process can never leave a torn file, and the loser of a race SKIPS —
5
+ // never corrupts, never blocks.
6
+ import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
7
+ import { basename, dirname, join } from 'path'
8
+
9
+ // a writer that died without unlocking must not wedge the file forever
10
+ const LOCK_STALE_MS = 120_000
11
+
12
+ export type LockOutcome<T> = { ok: true; value: T } | { ok: false; warn: string }
13
+
14
+ export function withFileLock<T>(path: string, fn: () => T): LockOutcome<T> {
15
+ const lockPath = `${path}.lock`
16
+ const grab = (): boolean => {
17
+ try { writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); return true } catch { return false }
18
+ }
19
+ if (!grab()) {
20
+ let stale = false
21
+ try { stale = Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {
22
+ // lock vanished between grab and stat — the racing writer just
23
+ // finished; still skip this round rather than fight over it
24
+ }
25
+ if (stale) rmSync(lockPath, { force: true })
26
+ if (!stale || !grab()) return { ok: false, warn: `${path} locked by another writer — skipped this round` }
27
+ }
28
+ try {
29
+ return { ok: true, value: fn() }
30
+ } finally {
31
+ rmSync(lockPath, { force: true })
32
+ }
33
+ }
34
+
35
+ export function atomicWrite(path: string, content: string): void {
36
+ const tmpPath = join(dirname(path), `.${basename(path)}.tmp-${process.pid}`)
37
+ writeFileSync(tmpPath, content)
38
+ renameSync(tmpPath, path)
39
+ }
@@ -0,0 +1,87 @@
1
+ // Report to the motherhome — upsert this repo's row into the docsBase
2
+ // wiki's `build/shipkit/local-projects.md`, when that page ALREADY EXISTS
3
+ // on this disk. The lib NEVER creates the page and never writes into a
4
+ // relative or URL docsBase: the page is a tracked wiki file, so a real
5
+ // docs checkout has it, and a consumer that merely set docsBase as a
6
+ // fix-pointer prefix must never find surprise files in its docs tree.
7
+ // The whole read-modify-write runs under the shared file lock
8
+ // (core/locked-write) — concurrent reporters never lose rows or tear the
9
+ // file; the loser skips and self-heals on its next run. Rows whose repo
10
+ // path no longer exists are pruned on every write. Churn control: a row
11
+ // rewrites only when its DATA changes or its last-seen day is older than
12
+ // 7 days. Never fails the calling command.
13
+ import { existsSync, readFileSync } from 'fs'
14
+ import { homedir } from 'os'
15
+ import { join, resolve } from 'path'
16
+ import type { RepoConfig } from '../repo-config.ts'
17
+ import { atomicWrite, withFileLock } from './locked-write.ts'
18
+
19
+ const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
20
+ const END = '<!-- <<< shipkit local-projects -->'
21
+ const STALE_DAYS = 7
22
+
23
+ type Row = { name: string; root: string; version: string; mode: string; seen: string }
24
+
25
+ const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
26
+
27
+ const parseRow = (line: string): Row | null => {
28
+ const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
29
+ return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
30
+ }
31
+
32
+ function libVersion(): string {
33
+ try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
34
+ }
35
+
36
+ // how this repo consumes the lib — `?` on an unreadable package.json (a
37
+ // read failure must never be rendered as the fact 'path')
38
+ function consumptionMode(root: string): string {
39
+ try {
40
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
41
+ const spec = { ...pkg.dependencies, ...pkg.devDependencies }['rvlib-shipkit']
42
+ if (spec == null) return 'path'
43
+ if (spec.startsWith('link:')) return 'link'
44
+ if (spec.startsWith('file:')) return 'file'
45
+ return 'npm'
46
+ } catch { return '?' }
47
+ }
48
+
49
+ export type MotherhomeOutcome = { wrote: boolean; note?: string }
50
+
51
+ export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
52
+ try {
53
+ if (config.docsBase == null) return { wrote: false }
54
+ const base = config.docsBase.startsWith('~/') ? join(homedir(), config.docsBase.slice(2)) : config.docsBase
55
+ // absolute local paths only — a relative docsBase would resolve
56
+ // against CWD, a URL is a display prefix
57
+ if (!base.startsWith('/')) return { wrote: false }
58
+ const page = join(base, 'build/shipkit/local-projects.md')
59
+ if (!existsSync(page)) return { wrote: false }
60
+
61
+ const mode = consumptionMode(root)
62
+ // source-consumed code is not the published release its version
63
+ // string names — disclose it
64
+ const version = mode === 'npm' ? libVersion() : `${libVersion()}+src`
65
+ const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
66
+
67
+ const r = withFileLock(page, (): MotherhomeOutcome => {
68
+ const lines = readFileSync(page, 'utf8').split('\n')
69
+ const b = lines.indexOf(BEGIN)
70
+ const e = lines.indexOf(END)
71
+ if (b === -1 || e === -1 || e < b) return { wrote: false, note: `WARN motherhome upsert skipped — markers missing/corrupted in ${page}` }
72
+ const rows = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
73
+ const cur = rows.find((row) => row.root === me.root)
74
+ const dataSame = cur != null && cur.name === me.name && cur.version === me.version && cur.mode === me.mode
75
+ const fresh = cur != null && (Date.parse(today) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
76
+ if (dataSame && fresh) return { wrote: false }
77
+ // prune rows for repos that no longer exist at their recorded path
78
+ const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
79
+ atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
80
+ return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})` }
81
+ })
82
+ // lock lost to a racing reporter — self-heals on the next run
83
+ return r.ok ? r.value : { wrote: false }
84
+ } catch (err) {
85
+ return { wrote: false, note: `WARN motherhome upsert failed (${String(err).slice(0, 120)}) — registry may be stale` }
86
+ }
87
+ }
@@ -8,9 +8,10 @@ import { join, relative } from 'path'
8
8
  import { CheckSuite, type CheckOutcome } from './check-suite.ts'
9
9
  import { discoverChecks } from './discover-checks.ts'
10
10
  import { spawnWithTimeout } from './spawn-timeout.ts'
11
- import { activeContracts, applyContractsResult, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
12
- import { evaluatePackageJson } from './package-json-check.ts'
13
- import { evaluateContracts } from '../contracts/contracts-engine.ts'
11
+ import { loadRepoConfig } from '../repo-config.ts'
12
+ import { reportToMotherhome } from './motherhome.ts'
13
+ import { RULES } from '../rules/rule-registry.ts'
14
+ import { runRuleLint } from '../rules/rule.ts'
14
15
  import { formatLine, parseWireOutput } from './wire.ts'
15
16
  import { writeStatus } from './write-status.ts'
16
17
 
@@ -22,6 +23,8 @@ const CHECK_TIMEOUT_MS = 120_000
22
23
  export type CheckOpts = {
23
24
  json?: boolean
24
25
  fresh?: boolean
26
+ /** report-only for self-maintaining rules (SK005 never touches package.json) */
27
+ frozen?: boolean
25
28
  /** refresh STATUS.md after the run (default true — O4: every repo gets it) */
26
29
  status?: boolean
27
30
  /** line sink — commands pass their context stream; defaults to console */
@@ -33,20 +36,24 @@ export type CheckOpts = {
33
36
  export async function runCheck(root: string, o: CheckOpts = {}): Promise<number> {
34
37
  const print = o.print ?? ((l: string) => console.log(l))
35
38
  const c = await loadRepoConfig(root)
39
+ // env carries the flags to child checks — restored after the run so a
40
+ // long-lived caller (doctor, TUI, hub) is never poisoned by one
41
+ // fresh/frozen invocation
42
+ const prevFresh = process.env.SHIPKIT_FRESH
43
+ const prevFrozen = process.env.SHIPKIT_FROZEN
36
44
  if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
45
+ if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
37
46
  const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache'), onResult: o.onResult })
38
- suite.addCheck({
39
- name: 'contracts',
40
- logic: async (t) => {
41
- applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
42
- },
43
- })
44
- // built-in shape probe (meta/rv-conventions/package-json.md) — doctor
45
- // surface only, deliberately absent from the ci gate
46
- suite.addCheck({
47
- name: 'package.json',
48
- logic: (t) => evaluatePackageJson(t, root, c),
49
- })
47
+ // built-in SK rules (rules/rule-registry.ts) — id-tagged, crash-safe,
48
+ // fix-pointer floor (rules/rule.ts runRuleLint owns the semantics);
49
+ // doctor surface only, deliberately absent from the ci gate
50
+ for (const rule of RULES)
51
+ suite.addCheck({
52
+ name: rule.title,
53
+ remainValidFor: rule.ttl,
54
+ cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
55
+ logic: (t) => runRuleLint(rule, t, { root, config: c }),
56
+ })
50
57
  const childEnv: Record<string, string | undefined> = {
51
58
  ...process.env,
52
59
  ...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
@@ -69,12 +76,18 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
69
76
  })
70
77
  }
71
78
  const r = await suite.execute()
79
+ if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
80
+ if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
81
+ // report to the motherhome (docsBase wiki on this disk) — never fails
82
+ // the check, notes only when it wrote or broke
83
+ const mh = reportToMotherhome(root, c)
72
84
  const statusOutcome = o.status !== false
73
85
  ? writeStatus(root, { name: c.name, command: 'shipkit check', lines: r.lines.map((l) => formatLine(l, false)), exitCode: r.exitCode })
74
86
  : null
75
87
  if (o.json === true) { print(JSON.stringify(r)); return r.exitCode }
76
88
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
77
89
  for (const l of r.lines) print(formatLine(l, color))
90
+ if (mh.note != null) print(mh.note)
78
91
  if (statusOutcome != null && !statusOutcome.written) print(`WARN ${statusOutcome.warn}`)
79
92
  return r.exitCode
80
93
  }
@@ -5,6 +5,7 @@
5
5
  // atomic + lock) unless status:false.
6
6
  import { join } from 'path'
7
7
  import { allTasks, loadRepoConfig } from '../repo-config.ts'
8
+ import { reportToMotherhome } from './motherhome.ts'
8
9
  import { runTasks, type TaskResult } from './task-runner.ts'
9
10
  import { writeStatus } from './write-status.ts'
10
11
 
@@ -44,6 +45,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
44
45
  onTaskFinish: o.onTaskFinish,
45
46
  })
46
47
  const code = summary.ok ? 0 : 1
48
+ // motherhome row (docsBase wiki on this disk) — never affects the gate
49
+ const mh = reportToMotherhome(root, c)
50
+ if (mh.note != null) print(mh.note)
47
51
  if (o.status !== false) {
48
52
  const st = writeStatus(root, { name: c.name, command: 'shipkit ci', lines: collected, exitCode: code })
49
53
  if (!st.written) print(`WARN ${st.warn}`)
@@ -1,11 +1,11 @@
1
1
  // THE STATUS.md writer — every repo gets one (O4, one-runner GO
2
2
  // 2026-07-22): timestamp header + verdict + fenced compact render, the
3
- // shape rv-doctor pioneered. Atomic (tmp file + rename) and lock-guarded:
3
+ // shape rv-doctor pioneered. Atomic + lock-guarded via core/locked-write:
4
4
  // concurrent runners never interleave — the loser skips with a WARN
5
5
  // outcome, never corrupts the file.
6
6
 
7
- import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
8
7
  import { join } from 'path'
8
+ import { atomicWrite, withFileLock } from './locked-write.ts'
9
9
 
10
10
  export type StatusInput = {
11
11
  /** repo name — headline */
@@ -20,25 +20,10 @@ export type StatusInput = {
20
20
  export type StatusOutcome = { written: boolean; warn?: string }
21
21
 
22
22
  const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '')
23
- // a runner that died without unlocking must not wedge STATUS.md forever
24
- const LOCK_STALE_MS = 120_000
25
23
 
26
24
  export function writeStatus(root: string, s: StatusInput): StatusOutcome {
27
- const lockPath = join(root, 'STATUS.md.lock')
28
- const grab = (): boolean => {
29
- try { writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); return true } catch { return false }
30
- }
31
- if (!grab()) {
32
- let stale = false
33
- try { stale = Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {
34
- // lock vanished between grab and stat — the racing writer just
35
- // finished; still skip this round rather than fight over it
36
- }
37
- if (stale) rmSync(lockPath, { force: true })
38
- if (!stale || !grab())
39
- return { written: false, warn: `STATUS.md locked by another runner (${lockPath}) — skipped this write` }
40
- }
41
- try {
25
+ const target = join(root, 'STATUS.md')
26
+ const r = withFileLock(target, () => {
42
27
  const clean = s.lines.map(stripAnsi)
43
28
  const fails = clean.filter((l) => l.startsWith('FAIL')).length
44
29
  const warns = clean.filter((l) => l.startsWith('WARN')).length
@@ -60,11 +45,7 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
60
45
  ${clean.join('\n').trim()}
61
46
  \`\`\`
62
47
  `
63
- const tmpPath = join(root, `.STATUS.md.tmp-${process.pid}`)
64
- writeFileSync(tmpPath, body)
65
- renameSync(tmpPath, join(root, 'STATUS.md'))
66
- return { written: true }
67
- } finally {
68
- rmSync(lockPath, { force: true })
69
- }
48
+ atomicWrite(target, body)
49
+ })
50
+ return r.ok ? { written: true } : { written: false, warn: `STATUS.md locked by another runner — skipped this write` }
70
51
  }
package/src/init-repo.ts CHANGED
@@ -7,7 +7,10 @@
7
7
  // by path instead of from node_modules.
8
8
  import { existsSync, readFileSync, writeFileSync } from 'fs'
9
9
  import { join } from 'path'
10
- import { defineRepo, resolveFloor } from './repo-config.ts'
10
+ import { canonicalBodies } from './core/canonical-scripts.ts'
11
+ import { reportToMotherhome } from './core/motherhome.ts'
12
+ import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
13
+ import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
11
14
 
12
15
  const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'rvlib-shipkit'
13
16
 
@@ -28,7 +31,18 @@ export type InitOpts = {
28
31
 
29
32
  export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
30
33
  const configPath = join(root, 'repo.config.ts')
31
- if (existsSync(configPath)) { console.log(`skip: repo.config.ts already exists`); return 0 }
34
+ if (existsSync(configPath)) {
35
+ console.log(`skip: repo.config.ts already exists`)
36
+ // adoption path: re-running init on an existing repo still converges
37
+ // its .gitignore policy block (same fixer as `check --fix SK004`),
38
+ // with the repo's own contributed lines honored
39
+ const cfg = await loadRepoConfig(root)
40
+ const g = await gitignorePolicy.fix!({ root, config: cfg })
41
+ console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
42
+ const mh = reportToMotherhome(root, cfg)
43
+ if (mh.note != null) console.log(mh.note)
44
+ return 0
45
+ }
32
46
  const pkgPath = join(root, 'package.json')
33
47
  if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
34
48
  const pkgRaw = readFileSync(pkgPath, 'utf8')
@@ -38,23 +52,22 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
38
52
  console.log('ok: repo.config.ts created')
39
53
  const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
40
54
  pkg.scripts ??= {}
41
- const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
42
55
  let added = 0
43
- // canonical script NAME → shipkit command (hub runs `shipkit serve`) —
44
- // vocabulary owned by meta/rv-conventions/package-json.md
45
- const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve', mv: 'mv' } as const
46
- for (const [k, cmd] of Object.entries(CANONICAL)) {
47
- const v = run(cmd)
56
+ // canonical script bodies — ONE source (core/canonical-scripts.ts),
57
+ // shared with rule SK001's autofix; vocabulary owned by
58
+ // meta/rv-conventions/package-json.md
59
+ for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
48
60
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
49
61
  else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
50
62
  }
51
- // baseline `test` is universal (bun test) — lint/fmt bodies are
52
- // stack-specific, the scaffold pages own those
53
- if (pkg.scripts['test'] == null) { pkg.scripts['test'] = 'bun test'; added++ }
54
63
  if (added > 0) {
55
64
  writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
56
65
  console.log(`ok: ${added} package.json script(s) added`)
57
66
  }
67
+ // the .gitignore policy block (contract git policy) — same fixer as
68
+ // `shipkit check --fix SK004`, one code path
69
+ const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
70
+ console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
58
71
  // show what the gate actually is — a thin floor must be seen at init
59
72
  // time, not discovered after a bad commit slipped through
60
73
  const floor = resolveFloor(defineRepo({ name }), root)
@@ -0,0 +1,305 @@
1
+ // shipkit reflect — mine the user's TYPED prompts out of Claude Code transcripts.
2
+ // Transcripts live at <configRoot>/projects/<slug>/*.jsonl where <slug> is the
3
+ // repo's absolute path with EVERY non-alphanumeric char replaced by '-'.
4
+ //
5
+ // A corpus is only as good as what it excludes: the harness writes many
6
+ // user-role entries the human never typed (tool results, command wrappers,
7
+ // compaction summaries, subagent prompts). Every filter below exists because
8
+ // one of them polluted a real extraction pass.
9
+ import { existsSync, readFileSync, readdirSync } from 'fs'
10
+ import { homedir } from 'os'
11
+ import { basename, join, resolve } from 'path'
12
+
13
+ export type PromptEntry = {
14
+ uuid: string
15
+ timestamp: string
16
+ sessionId: string
17
+ /** config root the prompt came from, e.g. `.claude` */
18
+ account: string
19
+ text: string
20
+ /** 'queued' = typed WHILE the agent was working (a `queue-operation` entry).
21
+ * Same human, higher signal: corrections and standing conventions land there. */
22
+ origin: 'typed' | 'queued'
23
+ }
24
+
25
+ export type ConfigRoot = { account: string; projects: string }
26
+
27
+ const siblingConfigDirs = (home: string): string[] => {
28
+ if (!existsSync(home)) return []
29
+ return readdirSync(home)
30
+ .filter((d) => d.startsWith('.claude-'))
31
+ .map((d) => join(home, d))
32
+ }
33
+
34
+ /** EVERY Claude Code config root on this machine, unioned: $CLAUDE_CONFIG_DIR
35
+ * (`:` or `,` separated) PLUS ~/.claude PLUS any sibling ~/.claude-<name>
36
+ * holding a projects/ dir. Additive on purpose — a second local account keeps
37
+ * its own transcripts, and the env var only names the account running RIGHT
38
+ * NOW, so honoring it alone hides every session of the other one (field bug
39
+ * 2026-07-24: a corpus mined that way lost a third of his prompts). */
40
+ export function discoverConfigRoots(home: string = homedir()): ConfigRoot[] {
41
+ const fromEnv = (process.env.CLAUDE_CONFIG_DIR ?? '')
42
+ .split(/[:,]/)
43
+ .map((p) => p.trim())
44
+ .filter((p) => p !== '')
45
+ const seen = new Set<string>()
46
+ const roots: ConfigRoot[] = []
47
+ for (const dir of [...fromEnv, join(home, '.claude'), ...siblingConfigDirs(home)]) {
48
+ const projects = join(resolve(dir), 'projects')
49
+ if (seen.has(projects) || !existsSync(projects)) continue
50
+ seen.add(projects)
51
+ roots.push({ account: basename(resolve(dir)), projects })
52
+ }
53
+ return roots
54
+ }
55
+
56
+ /** Claude Code slugs EVERY non-alphanumeric char to '-', not just '/' */
57
+ export const projectSlug = (repoPath: string): string => resolve(repoPath).replace(/[^a-zA-Z0-9]/g, '-')
58
+
59
+ // wrappers the harness injects as user-role entries — none of them are typed
60
+ const NOISE_PREFIXES = [
61
+ '<local-command-caveat>',
62
+ '<local-command-stdout>',
63
+ '<command-name>',
64
+ '<command-message>',
65
+ '<system-reminder>',
66
+ '<task-notification>',
67
+ 'Caveat: The messages below',
68
+ '[Request interrupted',
69
+ 'This session is being continued from a previous conversation',
70
+ ]
71
+
72
+ export function isTypedPrompt(text: string): boolean {
73
+ const trimmed = text.trim()
74
+ if (trimmed === '') return false
75
+ return !NOISE_PREFIXES.some((p) => trimmed.startsWith(p))
76
+ }
77
+
78
+ type RawEntry = {
79
+ type?: unknown
80
+ entrypoint?: unknown
81
+ uuid?: unknown
82
+ timestamp?: unknown
83
+ sessionId?: unknown
84
+ isMeta?: unknown
85
+ isSidechain?: unknown
86
+ isCompactSummary?: unknown
87
+ /** queue-operation entries only */
88
+ operation?: unknown
89
+ content?: unknown
90
+ message?: { role?: unknown; content?: unknown }
91
+ }
92
+
93
+ /** His words out of one transcript line, or null when the line is not a prompt
94
+ * he typed. isSidechain = a subagent prompt the AGENT wrote; isMeta and
95
+ * isCompactSummary = harness-generated. Content is a string for a plain prompt,
96
+ * an array of blocks when the message carried pastes or attachments (the text
97
+ * blocks in there are still his). */
98
+ export function promptTextOf(line: string): { text: string; entry: RawEntry } | null {
99
+ let parsed: unknown
100
+ try {
101
+ parsed = JSON.parse(line)
102
+ } catch {
103
+ return null // truncated tail line of a live session
104
+ }
105
+ if (parsed == null || typeof parsed !== 'object') return null
106
+ const entry = parsed as RawEntry
107
+ if (entry.type !== 'user') return null
108
+ if (entry.isMeta === true || entry.isSidechain === true || entry.isCompactSummary === true) return null
109
+ if (entry.entrypoint === 'sdk-cli') return null // a script drove this session, nobody typed it
110
+ if (entry.message?.role !== 'user') return null
111
+ const content = entry.message.content
112
+ let text: string
113
+ if (typeof content === 'string') text = content
114
+ else if (Array.isArray(content)) {
115
+ const parts = content
116
+ .filter((b): b is { type: string; text: string } => b?.type === 'text' && typeof b?.text === 'string')
117
+ .map((b) => b.text)
118
+ if (parts.length === 0) return null // pure tool_result turn
119
+ text = parts.join('\n')
120
+ } else return null
121
+ // the harness appends reminder blocks INSIDE real prompts — strip, keep the text
122
+ text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
123
+ // a slash-command entry is a harness WRAPPER around typed text:
124
+ // <command-name>/cmd</command-name><command-args>THE TEXT</command-args>.
125
+ // Dropping the whole entry throws away real instructions and repro pastes.
126
+ const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)
127
+ if (args?.[1] != null) {
128
+ const body = args[1].trim()
129
+ if (body === '') return null // bare invocation, nothing typed
130
+ const name = text.match(/<command-name>([\s\S]*?)<\/command-name>/)
131
+ text = name?.[1] == null ? body : `${name[1].trim()} ${body}`
132
+ }
133
+ if (!isTypedPrompt(text)) return null
134
+ return { text, entry }
135
+ }
136
+
137
+ /** A prompt he typed while the agent was still working: the harness parks it as
138
+ * `{type:'queue-operation', operation:'enqueue', content}` and, if the run ends
139
+ * first, it may NEVER become a user message. Skipping these loses exactly the
140
+ * mid-flight corrections and standing conventions a retrospective is for
141
+ * (measured 2026-07-26: 72 such texts invisible in this repo alone). */
142
+ export function queuedTextOf(line: string): { text: string; entry: RawEntry } | null {
143
+ let parsed: unknown
144
+ try {
145
+ parsed = JSON.parse(line)
146
+ } catch {
147
+ return null
148
+ }
149
+ if (parsed == null || typeof parsed !== 'object') return null
150
+ const entry = parsed as RawEntry
151
+ if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue') return null
152
+ if (typeof entry.content !== 'string') return null
153
+ const text = entry.content.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
154
+ if (!isTypedPrompt(text)) return null
155
+ return { text, entry }
156
+ }
157
+
158
+ export function extractPrompts(jsonl: string, ctx: { sessionId: string; account: string }): PromptEntry[] {
159
+ const out: PromptEntry[] = []
160
+ for (const line of jsonl.split('\n')) {
161
+ if (line.trim() === '') continue
162
+ const queued = queuedTextOf(line)
163
+ const hit = queued ?? promptTextOf(line)
164
+ if (hit == null) continue
165
+ const timestamp = typeof hit.entry.timestamp === 'string' ? hit.entry.timestamp : ''
166
+ const fallbackId = `${queued == null ? 'typed' : 'queue'}:${ctx.sessionId}:${timestamp}`
167
+ out.push({
168
+ uuid: typeof hit.entry.uuid === 'string' ? hit.entry.uuid : fallbackId,
169
+ timestamp,
170
+ sessionId: typeof hit.entry.sessionId === 'string' ? hit.entry.sessionId : ctx.sessionId,
171
+ account: ctx.account,
172
+ text: hit.text,
173
+ origin: queued == null ? 'typed' : 'queued',
174
+ })
175
+ }
176
+ return out
177
+ }
178
+
179
+ /** Genuine repetition is SIGNAL (saying it twice raises priority), so dedupe
180
+ * must remove only mechanical copies:
181
+ * - a resumed/forked session replaying an entry (same uuid, or same text at
182
+ * the same instant),
183
+ * - a queued prompt that later became a user message (same text, any gap —
184
+ * one keystroke, two records).
185
+ * Anything else repeated is kept, so the corpus never manufactures a priority
186
+ * he did not give. Input must be chronological. */
187
+ const REPEAT_WINDOW_MS = 5 * 60 * 1000
188
+
189
+ const normalize = (text: string): string => text.replace(/\s+/g, ' ').trim().toLowerCase()
190
+
191
+ export function dedupePrompts(prompts: PromptEntry[]): PromptEntry[] {
192
+ const seenUuid = new Set<string>()
193
+ const lastSeenAt = new Map<string, number>()
194
+ /** queued texts not yet matched by their user-message twin — consumption is
195
+ * ONE-time, so a genuine re-ask of the same words later still counts */
196
+ const awaitingConsume = new Set<string>()
197
+ const out: PromptEntry[] = []
198
+ for (const p of prompts) {
199
+ if (seenUuid.has(p.uuid)) continue
200
+ seenUuid.add(p.uuid)
201
+ const key = normalize(p.text)
202
+ const at = Number.isNaN(Date.parse(p.timestamp)) ? 0 : Date.parse(p.timestamp)
203
+ const prev = lastSeenAt.get(key)
204
+ if (prev != null) {
205
+ if (awaitingConsume.has(key) && p.origin === 'typed') {
206
+ awaitingConsume.delete(key) // the queued keystroke arriving as a message
207
+ lastSeenAt.set(key, at)
208
+ continue
209
+ }
210
+ if (at - prev < REPEAT_WINDOW_MS) continue // replay, or a resend seconds apart
211
+ }
212
+ lastSeenAt.set(key, at)
213
+ if (p.origin === 'queued') awaitingConsume.add(key)
214
+ out.push(p)
215
+ }
216
+ return out
217
+ }
218
+
219
+ /** Sessions whose transcript was pruned by retention survive only as a
220
+ * truncated firstPrompt in the per-project sessions-index.json. */
221
+ function indexFallbackPrompts(dir: string, account: string, have: Set<string>): PromptEntry[] {
222
+ const indexPath = join(dir, 'sessions-index.json')
223
+ if (!existsSync(indexPath)) return []
224
+ let index: { entries?: unknown }
225
+ try {
226
+ index = JSON.parse(readFileSync(indexPath, 'utf8')) as { entries?: unknown }
227
+ } catch {
228
+ return []
229
+ }
230
+ const entries = Array.isArray(index.entries) ? index.entries : []
231
+ const out: PromptEntry[] = []
232
+ for (const raw of entries) {
233
+ const e = raw as { sessionId?: string; firstPrompt?: string; created?: string; fullPath?: string }
234
+ if (typeof e.sessionId !== 'string' || have.has(e.sessionId)) continue
235
+ if (typeof e.fullPath === 'string' && existsSync(e.fullPath)) continue
236
+ if (typeof e.firstPrompt !== 'string' || !isTypedPrompt(e.firstPrompt)) continue
237
+ out.push({
238
+ uuid: `index:${e.sessionId}`,
239
+ timestamp: typeof e.created === 'string' ? e.created : '',
240
+ sessionId: e.sessionId,
241
+ account,
242
+ text: `${e.firstPrompt}\n\n[transcript pruned — first prompt only, truncated]`,
243
+ origin: 'typed',
244
+ })
245
+ }
246
+ return out
247
+ }
248
+
249
+ export type RepoCorpus = {
250
+ /** project dirs actually scanned, across every config root */
251
+ dirs: string[]
252
+ /** config roots searched — printed when nothing was found */
253
+ roots: string[]
254
+ prompts: PromptEntry[]
255
+ }
256
+
257
+ /** Every prompt he typed in a repo: all config roots, the repo's project dir
258
+ * PLUS its worktree dirs (`<slug>--claude-worktrees-*`), deduped, chronological. */
259
+ export function collectRepoPrompts(repoPath: string, roots: ConfigRoot[] = discoverConfigRoots()): RepoCorpus {
260
+ const slug = projectSlug(repoPath)
261
+ const dirs: string[] = []
262
+ const prompts: PromptEntry[] = []
263
+ for (const root of roots) {
264
+ const projectDirs = readdirSync(root.projects).filter(
265
+ (d) => d === slug || d.startsWith(`${slug}--claude-worktrees-`),
266
+ )
267
+ for (const projectDir of projectDirs) {
268
+ const dir = join(root.projects, projectDir)
269
+ dirs.push(dir)
270
+ const sessions = new Set<string>()
271
+ for (const file of readdirSync(dir)) {
272
+ if (!file.endsWith('.jsonl')) continue
273
+ const sessionId = file.replace(/\.jsonl$/, '')
274
+ sessions.add(sessionId)
275
+ prompts.push(
276
+ ...extractPrompts(readFileSync(join(dir, file), 'utf8'), { sessionId, account: root.account }),
277
+ )
278
+ }
279
+ prompts.push(...indexFallbackPrompts(dir, root.account, sessions))
280
+ }
281
+ }
282
+ prompts.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
283
+ return { dirs, roots: roots.map((r) => r.projects), prompts: dedupePrompts(prompts) }
284
+ }
285
+
286
+ /** Fence a prompt so its own backticks and markdown cannot break the document */
287
+ function fenced(text: string): string {
288
+ const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length))
289
+ const fence = '`'.repeat(Math.max(3, longest + 1))
290
+ return `${fence}text\n${text}\n${fence}`
291
+ }
292
+
293
+ /** Chronological corpus, one numbered section per prompt — meant to be read as
294
+ * a corpus, not as a task list. */
295
+ export function corpusMarkdown(repoPath: string, prompts: PromptEntry[]): string {
296
+ const queued = prompts.filter((p) => p.origin === 'queued').length
297
+ const count = queued === 0 ? `${prompts.length}` : `${prompts.length}, ${queued} queued mid-run`
298
+ const lines: string[] = [`# prompt corpus — ${resolve(repoPath)} (${count})`, '']
299
+ prompts.forEach((p, i) => {
300
+ const when = p.timestamp.replace('T', ' ').slice(0, 16)
301
+ const tag = p.origin === 'queued' ? ' · queued' : ''
302
+ lines.push(`## ${i + 1} · ${when} · ${p.account} · ${p.sessionId.slice(0, 8)}${tag}`, '', fenced(p.text), '')
303
+ })
304
+ return `${lines.join('\n')}\n`
305
+ }