rvlib-shipkit 0.18.0 → 0.19.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0
4
+
5
+ - **The attribution tell list covers session trailers and assistant share urls.** `<assistant>-Session:` trailers (claude, chatgpt, copilot, cursor, aider, devin, gemini, codex) and claude.ai/claude.com/chatgpt.com/chat.openai.com urls now trip the shared predicate (commit-msg hook + SK009 history scan). Field case: 162 session links sat in a history both gates called clean.
6
+ - **SK009 scans ref names too**, local and remote-tracking. A checkpoint tag named after an assistant (`FABLE-checkpoint-3`) outs the agent as loudly as a trailer. Bare match only for names with no other meaning (claude, chatgpt, copilot, aider, devin); vendor and codename words (cursor, openai, gemini, fable, sonnet, haiku, opus) name legitimate branches, so they only match in a `-checkpoint`/`-session` shape.
7
+ - **The leak guard reads a machine-global list.** Every run also scans `~/.rv-private/banned-keywords.txt` when it exists: one private list per machine, armed in every repo at once, never copied into a repo (a copy rots and a copy can leak). `hooks install` seeds the repo's own configured keyword file with generic secret-shape starters, never touching an existing one. Both files absent stays a loud skip.
8
+ - **The itch listing answered-check catches empty fields.** A `## field` heading with no body line under it is an unanswered field wearing no `<placeholder>`; the contract names it now. Prose-level open decisions stay a human read.
9
+
3
10
  ## 0.18.0
4
11
 
5
12
  - **The comfy backend runs on comfy-ts.** `.rv-comfy/workflows/` holds `*.cflow.ts` workflow modules (one `host.defineWorkflow` per graph family, plus a `_host.ts` the modules import). Exported json graphs and `.ts` sidekick pairs are gone, and the raw HTTP engine (`comfy-gen.ts`) and pair loader (`workflow-loader.ts`) are deleted with them. Every module must declare `vars.prompt` (`v.prompt`) and `vars.seed` (`v.seed`), shipkit drives both.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
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",
@@ -20,7 +20,8 @@ export class HooksInstallCommand extends Command {
20
20
  bin = Option.String('--bin', 'bunx rvlib-shipkit', { description: 'how the hook body invokes shipkit (in-repo consumers pass `bun <path to cli.ts>`)' })
21
21
 
22
22
  async execute(): Promise<number> {
23
- const r = installHooks(process.cwd(), this.bin)
23
+ const config = await loadRepoConfig(process.cwd()).catch(() => null)
24
+ const r = installHooks(process.cwd(), this.bin, config?.hooks?.keywordFile)
24
25
  for (const w of r.written) this.context.stdout.write(`ok: wrote ${w}\n`)
25
26
  if (r.written.length === 0) this.context.stdout.write('ok: hook bodies already current\n')
26
27
  this.context.stdout.write(`${r.note.startsWith('WARN') ? r.note : `ok: ${r.note}`}\n`)
@@ -21,8 +21,24 @@ export default defineContract({
21
21
  custom('.rv-itch/listing.md answered', (env) => {
22
22
  const p = join(env.root, '.rv-itch/listing.md')
23
23
  if (!existsSync(p)) return []
24
- const left = [...readFileSync(p, 'utf8').matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
25
- return left.length === 0 ? [] : [`.rv-itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`]
24
+ const text = readFileSync(p, 'utf8')
25
+ const out: string[] = []
26
+ const left = [...text.matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
27
+ if (left.length > 0) out.push(`.rv-itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`)
28
+ // a heading with an EMPTY body is an unanswered field wearing no
29
+ // placeholder (prose-level open decisions stay a human read).
30
+ // fenced blocks stripped first (a ## inside one is not a field),
31
+ // and an inline `## Field: answer` heading counts as answered
32
+ const sections = text.replace(/^```[\s\S]*?^```/gm, '').split(/^## /m).slice(1)
33
+ const empty = sections
34
+ .filter((s) => {
35
+ const [head = '', ...body] = s.split('\n')
36
+ const inline = head.includes(':') && head.slice(head.indexOf(':') + 1).trim() !== ''
37
+ return !inline && body.every((l) => l.trim() === '')
38
+ })
39
+ .map((s) => s.split('\n')[0]!.trim())
40
+ if (empty.length > 0) out.push(`.rv-itch/listing.md field(s) with no answer under the heading: ${empty.slice(0, 3).join(', ')}${empty.length > 3 ? ' …' : ''}`)
41
+ return out
26
42
  }),
27
43
  // itch DISPLAYS 630x500 and accepts larger — atLeast, never exact-size
28
44
  png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
@@ -7,9 +7,12 @@
7
7
  export type OffenseKind = 'dash' | 'attribution'
8
8
  export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
9
9
 
10
- /** trailers and footers that name an assistant, plus the robot emoji. */
10
+ /** trailers and footers that name an assistant, assistant session trailers,
11
+ * assistant share-domain urls (a session link is a louder tell than any
12
+ * trailer), plus the robot emoji. the session/url family lists every
13
+ * assistant, not just the one that bit first. */
11
14
  export const AI_ATTRIBUTION =
12
- /(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|🤖/i
15
+ /(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|\b(?:claude|chatgpt|copilot|cursor|aider|devin|gemini|codex)-session:|\b(?:claude\.(?:ai|com)|chatgpt\.com|chat\.openai\.com)\b|🤖/i
13
16
 
14
17
  /** everything below git's scissors line is the verbose-mode diff, which git
15
18
  * strips before committing, diff content is not ours to police. */
@@ -33,7 +36,7 @@ export function commitMessageOffenses(message: string): Offense[] {
33
36
  if (body.includes(' - '))
34
37
  offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
35
38
  if (AI_ATTRIBUTION.test(raw))
36
- offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, "Generated with" footer, or robot emoji' })
39
+ offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, session trailer, claude.ai/chatgpt.com url, "Generated with" footer, or robot emoji' })
37
40
  })
38
41
  return offenses
39
42
  }
@@ -46,13 +46,24 @@ export type LeakGuardOpts = {
46
46
  staged?: boolean
47
47
  }
48
48
 
49
+ /** the machine-global list every repo's guard also reads: one private list
50
+ * per machine, never copied into a repo (a copy rots, and a not-yet-ignored
51
+ * .rv-private/ would make the list itself committable). */
52
+ export function globalKeywordFile(): string | null {
53
+ const home = process.env.HOME ?? ''
54
+ if (home === '') return null
55
+ const p = join(home, '.rv-private', 'banned-keywords.txt')
56
+ return existsSync(p) ? p : null
57
+ }
58
+
49
59
  export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResult {
50
60
  const rel = o.keywordFile ?? '.rv-private/banned-keywords.txt'
51
61
  const file = join(root, rel)
52
- if (!existsSync(file)) return { status: 'skipped', reason: `${rel} not found — no keyword list, no leak guard` }
53
- const patterns = parsePatterns(readFileSync(file, 'utf8'))
62
+ const sources = [existsSync(file) ? file : null, globalKeywordFile()].filter((f): f is string => f != null)
63
+ if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no ~/.rv-private/banned-keywords.txt — no keyword list, no leak guard` }
64
+ const patterns = parsePatterns(sources.map((f) => readFileSync(f, 'utf8')).join('\n'))
54
65
  const count = patterns.substrings.length + patterns.regexes.length
55
- if (count === 0) return { status: 'skipped', reason: `${rel} has no patterns` }
66
+ if (count === 0) return { status: 'skipped', reason: `keyword file(s) have no patterns (${sources.join(', ')})` }
56
67
 
57
68
  const hits: LeakHit[] = []
58
69
  if (o.staged !== false) {
@@ -4,9 +4,9 @@
4
4
  // while its next commit is already doomed, and nothing can enumerate what
5
5
  // the hooks enforce. `shipkit hooks list` answers that without reading shell.
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
7
- import { join } from 'path'
7
+ import { dirname, join } from 'path'
8
8
  import { commitMessageOffenses } from './commit-message.ts'
9
- import { runLeakGuard } from './leak-guard.ts'
9
+ import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
10
10
  import { runRootIdentity } from './root-identity.ts'
11
11
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
12
12
  import type { RepoConfig } from '../repo-config.ts'
@@ -30,8 +30,9 @@ export type HooksConfig = {
30
30
  const HOOK_DIR = 'hooks'
31
31
  const HOOKS = ['pre-commit', 'commit-msg'] as const
32
32
 
33
- /** wire `core.hooksPath` and write the two hook bodies. Idempotent. */
34
- export function installHooks(root: string, binInvocation: string): { written: string[]; note: string } {
33
+ /** wire `core.hooksPath` and write the two hook bodies. Idempotent.
34
+ * `keywordFile` = the repo's configured leak-guard file, seeding target. */
35
+ export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
35
36
  const dir = join(root, HOOK_DIR)
36
37
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
37
38
  const written: string[] = []
@@ -44,12 +45,41 @@ export function installHooks(root: string, binInvocation: string): { written: st
44
45
  if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
45
46
  }
46
47
  const cfg = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
48
+ const seeded = seedKeywordFile(root, keywordFile)
49
+ if (seeded != null) written.push(seeded)
47
50
  return {
48
51
  written,
49
52
  note: cfg.exitCode === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.exitCode}) — hooks will not fire`,
50
53
  }
51
54
  }
52
55
 
56
+ // a starter so the leak guard bites from day one instead of skipping forever:
57
+ // generic secret shapes only. private names live in the machine-global
58
+ // ~/.rv-private/banned-keywords.txt, which the guard reads on every run
59
+ // (never copied into a repo: a copy rots and a copy can leak)
60
+ const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
61
+ # message) for these rows. one row = one case-insensitive substring, or
62
+ # re:<regex>. add repo-specific names here; machine-wide private names go in
63
+ # ~/.rv-private/banned-keywords.txt, read automatically alongside this file.
64
+ # this file is gitignored on purpose: the list itself is private.
65
+ re:-----BEGIN [A-Z ]*PRIVATE KEY-----
66
+ re:AKIA[0-9A-Z]{16}
67
+ re:sk-[A-Za-z0-9_-]{20,}
68
+ re:ghp_[A-Za-z0-9]{36}
69
+ re:xox[baprs]-[A-Za-z0-9-]{10,}
70
+ `
71
+
72
+ /** seed the configured keyword file with the generic starter so the guard is
73
+ * ARMED, not a skip. an existing file is never touched. */
74
+ function seedKeywordFile(root: string, keywordFile?: string): string | null {
75
+ const rel = keywordFile ?? '.rv-private/banned-keywords.txt'
76
+ const dest = join(root, rel)
77
+ if (existsSync(dest)) return null
78
+ mkdirSync(dirname(dest), { recursive: true })
79
+ writeFileSync(dest, KEYWORD_STARTER)
80
+ return `${rel} (starter shapes)`
81
+ }
82
+
53
83
  export type GateOutcome = { id: string; ok: boolean; lines: string[] }
54
84
 
55
85
  /** the pre-commit gate: built-in leak guard, then the repo's own commands. */
@@ -156,9 +186,9 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
156
186
  const kw = h.keywordFile ?? '.rv-private/banned-keywords.txt'
157
187
  // a gate listed as active but unable to fire is worse than no listing:
158
188
  // this surface exists so nobody has to guess
159
- const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
189
+ const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.rv-private list)`
160
190
  return [
161
- `pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
191
+ `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.rv-private/banned-keywords.txt`,
162
192
  ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
163
193
  `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
164
194
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
@@ -53,14 +53,25 @@ export const commitAiTells: Rule = {
53
53
  const sample = (hits: { hash: string }[]): string => hits.slice(0, 5).map((h) => h.hash).join(' ') + (hits.length > 5 ? ' …' : '')
54
54
  const attributed = commits.filter((c) => hasAiAttribution(c.body))
55
55
  const dashed = commits.filter((c) => hasDashPunctuation(c.body))
56
+ // ref NAMES tell too (a FABLE-checkpoint-3 tag outs the agent as
57
+ // loudly as a trailer). bare match only for names with no other
58
+ // meaning; vendor and codename words (cursor, openai, gemini, opus,
59
+ // fable, …) name legitimate branches and features, they only match in
60
+ // a checkpoint/session shape
61
+ const refTell = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
62
+ const refs = await t.exec(['git', 'for-each-ref', '--format=%(refname:short)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: env.root, timeoutMs: 30_000 })
63
+ if (refs.code !== 0) t.lines.push({ kind: 'skip', text: `ref names unverifiable — git for-each-ref exit ${refs.code}` })
64
+ const tellRefs = refs.code === 0 ? refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '' && refTell.test(r)) : []
65
+ if (tellRefs.length > 0)
66
+ t.fail(`${tellRefs.length} ref name(s) carry an AI tell in a declared-public repo (${tellRefs.slice(0, 5).join(' ')}${tellRefs.length > 5 ? ' …' : ''}) — rename or delete the refs before the flip; a pushed tag also needs the remote delete`)
56
67
  if (attributed.length > 0)
57
- t.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
68
+ t.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / a session trailer or claude.ai url / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
58
69
  if (dashed.length > 0)
59
70
  t.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
60
71
  const silenced = attributionSilenced(env.root)
61
72
  if (!silenced)
62
73
  t.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
63
- if (attributed.length === 0 && dashed.length === 0 && silenced)
64
- t.ok(`${commits.length} commits scanned, attribution silenced in .claude/settings.json — no AI tells`)
74
+ if (attributed.length === 0 && dashed.length === 0 && tellRefs.length === 0 && silenced)
75
+ t.ok(`${commits.length} commits + ${refs.code === 0 ? refs.out.split('\n').filter((r) => r.trim() !== '').length : 0} ref names scanned, attribution silenced in .claude/settings.json — no AI tells`)
65
76
  },
66
77
  }