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.
- package/CHANGELOG.md +166 -0
- package/README.md +17 -3
- package/package.json +5 -2
- package/src/asks/asks-queue.ts +8 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +5 -2
- package/src/collections/actions.ts +19 -0
- package/src/collections/collection-types.ts +13 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +5 -1
- package/src/collections/posts.ts +20 -1
- package/src/collections/providers.ts +163 -50
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +24 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +20 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/rule-registry.ts +32 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +236 -215
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -59
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +23 -9
- package/src/serve/serve-hub.ts +62 -47
- package/src/shipkit.ts +9 -5
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- 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
|
+
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
}
|
package/src/core/run-ci.ts
CHANGED
|
@@ -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}`)
|
package/src/core/write-status.ts
CHANGED
|
@@ -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
|
|
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
|
|
28
|
-
const
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 {
|
|
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)) {
|
|
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
|
|
44
|
-
// vocabulary owned by
|
|
45
|
-
|
|
46
|
-
for (const [k,
|
|
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
|
+
}
|