rvlib-shipkit 0.18.0 → 0.20.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ - **Commit gates arm themselves.** An interactive `shipkit ci` run now installs the git hooks whenever the repo has a `repo.config.ts` and `core.hooksPath` is unset or already `hooks`: hook bodies written, leak-guard keyword starter seeded, `core.hooksPath` set, idempotent. The field case: a whole drill session committed with dark gates because init was treated as "create repo.config.ts". A FOREIGN `core.hooksPath` (husky and friends) is respected and named, never taken. Hook bodies prefer the repo's own `node_modules/rvlib-shipkit` copy over the cli that happens to be running, so a repo keeps its pinned version. Scoped to the interactive gate on purpose: CI verify mode, `bump` and `release` gate runs never mutate the tree they are about to ship, and arming reports on its own line, never as a fixer rewrite.
6
+ - **`start` is the from-anywhere bootstrap, `init` says it is cwd-only.** His friction: "no idea what shipkit start does" and init never said where it acts. `shipkit start <name>` now also WIRES the project (runs the init scaffold) when `~/dev/<name>` already has a package.json, so a pre-existing repo goes zero to shipkit'd with one command from any cwd; an empty new dir prints the next step on stderr. stdout stays the dir alone (the cd-alias contract, frozen by test). Both usage texts state their scope and point at each other.
7
+ - **The commit-message gate blocks French.** Git surfaces are English, always. New `language` offense in the shared predicate: an accented letter, or two distinct French function words on one line, trips it. Quoted spans (backticks, double quotes, guillemets) are exempt as product copy under discussion. Single quotes stay in scope because they are French apostrophes. `hooks.languageAllow` in repo.config exempts listed words (proper nouns that carry accents without being a language choice), and `hooks.disable: ['commit-language']` lifts only the French check, never the dash and attribution guards. Write-time only: SK009 history audits still scan attribution and dashes, so no repo retro-fails on old French commits.
8
+
9
+ ## 0.19.0
10
+
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+
3
16
  ## 0.18.0
4
17
 
5
18
  - **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.20.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",
package/src/cli/cmd_Ci.ts CHANGED
@@ -35,6 +35,7 @@ export class CiCommand extends Command {
35
35
  json: this.json,
36
36
  fix: this.fix,
37
37
  fixRules: this.fixRules,
38
+ armHooks: true,
38
39
  frozen: this.frozen,
39
40
  skipGroups: this.skip,
40
41
  withIds: this.withIds,
@@ -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`)
@@ -8,9 +8,9 @@ export class InitCommand extends Command {
8
8
 
9
9
  static override usage = Command.Usage({
10
10
  category: 'Setup',
11
- description: 'scaffold repo.config.ts + ci/check/contracts package.json scripts, then print how the floor resolved',
11
+ description: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. Scaffolds repo.config.ts + ci/check/contracts package.json scripts, then prints how the floor resolved. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
12
12
  examples: [
13
- ['bootstrap the current repo', 'shipkit init'],
13
+ ['wire the repo you are standing in', 'shipkit init'],
14
14
  ['in-repo-source consumer: scripts run the CLI by path', 'shipkit init --bin wiki/build/shipkit/rvlib-shipkit/src/cli.ts'],
15
15
  ],
16
16
  })
@@ -9,10 +9,11 @@ export class StartCommand extends Command {
9
9
 
10
10
  static override usage = Command.Usage({
11
11
  category: 'Setup',
12
- description: 'ensure <root>/<name> exists, optionally with a CLAUDE.md include line. Prints the dir on stdout so a shell alias can cd there',
12
+ description: 'create (or enter) ~/dev/<name> from ANY cwd: ensures the dir, seeds CLAUDE.md include lines, and wires shipkit on the spot (repo.config.ts via init) when the project already has a package.json. An empty new dir prints the next step instead. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
13
13
  examples: [
14
- ['start (or enter) a repo', 'shipkit start pong'],
14
+ ['create or enter ~/dev/pong, wiring it if it is already a project', 'shipkit start pong'],
15
15
  ['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
16
+ ['adopt a pre-existing repo from anywhere', 'shipkit start my-old-project'],
16
17
  ],
17
18
  })
18
19
 
@@ -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 }),
@@ -27,6 +27,8 @@ export type CiOpts = {
27
27
  fix?: boolean
28
28
  /** also apply the structural SK rule fixers during the fix pass */
29
29
  fixRules?: boolean
30
+ /** self-arm the git hooks in the fix pass (interactive ci entry only) */
31
+ armHooks?: boolean
30
32
  /** run only the checks that can block (core/run-check onlyBlocking) */
31
33
  onlyBlockingChecks?: boolean
32
34
  /** task ids the caller does not need. A sub-unit release skips the tasks
@@ -76,7 +78,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
76
78
  // mechanical failures are not information, so they never cost a round trip
77
79
  let fixFailed = false
78
80
  if (o.fix !== false)
79
- fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
81
+ fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
80
82
  else sink('skip: fix pass (--no-fix)')
81
83
  const all = allTasks(c, root)
82
84
  const { disabled, unfilled } = all
@@ -11,6 +11,7 @@
11
11
  import { existsSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
+ import { armHooks } from '../hooks/run-hooks.ts'
14
15
  import type { RepoConfig } from '../repo-config.ts'
15
16
  import { runFix } from '../rules/run-fix.ts'
16
17
  import { spawnWithTimeout } from './spawn-timeout.ts'
@@ -105,6 +106,10 @@ function treeState(root: string): Map<string, string> | null {
105
106
  export type FixPassOpts = {
106
107
  /** also apply the structural SK rule fixers (default false) */
107
108
  rules?: boolean
109
+ /** also self-arm the git hooks (default false — ONLY the interactive
110
+ * `shipkit ci` entry passes true; bump and release gates must never
111
+ * mutate the tree they are about to ship) */
112
+ armHooks?: boolean
108
113
  print: (line: string) => void
109
114
  }
110
115
 
@@ -120,6 +125,15 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
120
125
  // verify mode: same fixers, but rewriting anything means the COMMIT was
121
126
  // not clean, and the gate says so instead of quietly repairing it
122
127
  const verify = process.env.CI != null && process.env.CI !== ''
128
+ // commit gates arm themselves on the interactive gate: a repo.config with
129
+ // dark hooks is the half-wired state that let a whole drill session
130
+ // commit ungated (2026-08-04). Runs BEFORE the tree snapshot: arming is
131
+ // its own reported concern, never counted as a fixer rewrite.
132
+ if (o.armHooks === true && !verify) {
133
+ const armed = armHooks(root, config)
134
+ if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail})`)
135
+ else if (armed.state === 'foreign') o.print(`skip: hooks (${armed.detail})`)
136
+ }
123
137
  const { slots, unfilled, disabled } = resolveFixers(root, config)
124
138
  for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
125
139
  for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
@@ -4,12 +4,43 @@
4
4
  //
5
5
  // mechanical text rules only, so this stays publishable.
6
6
 
7
- export type OffenseKind = 'dash' | 'attribution'
7
+ export type OffenseKind = 'dash' | 'attribution' | 'language'
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
+ /** git surfaces are English, always (identity/writing-voice.md). cheap French
11
+ * detector: an accented letter, or two distinct French function words on one
12
+ * line. quoted spans are exempt: they carry product copy under discussion
13
+ * ("Modification refusée"), not the message's own voice. single quotes stay
14
+ * in (they are French apostrophes: stripping l'écran would hide the French).
15
+ * `languageAllow` (repo.config hooks) exempts listed words, for proper nouns
16
+ * that carry accents without being a language choice. */
17
+ const QUOTED_SPANS = /`[^`\n]*`|"[^"\n]*"|“[^”\n]*”|«[^»\n]*»/g
18
+ const FRENCH_ACCENTS = /[àâçéèêëîïôùûœÀÂÇÉÈÊËÎÏÔÙÛŒ]/
19
+ const FRENCH_STOPWORDS =
20
+ /\b(?:le|la|les|un|une|des|du|au|aux|et|est|sont|avec|pour|dans|sur|pas|que|qui|ne|se|ce|cette|ces|chaque|tout|toute|tous|toutes|jamais|toujours|mais|donc|sans|sous|vers|chez|depuis|encore|aussi|devient|deviennent|nouvelle|ancienne)\b/gi
21
+
22
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
23
+
24
+ export function frenchHits(line: string, languageAllow: string[] = []): boolean {
25
+ let prose = line.replace(QUOTED_SPANS, ' ')
26
+ for (const word of languageAllow) prose = prose.replace(new RegExp(`(?:^|(?<=[^\\p{L}]))${escapeRe(word)}(?:'s)?(?=$|[^\\p{L}])`, 'giu'), ' ')
27
+ if (FRENCH_ACCENTS.test(prose)) return true
28
+ const words = new Set<string>()
29
+ // an ALL-CAPS token is an acronym (UN, EST, CE), not a French word
30
+ for (const m of prose.matchAll(FRENCH_STOPWORDS)) {
31
+ const w = m[0]
32
+ if (w.length > 1 && w === w.toUpperCase()) continue
33
+ words.add(w.toLowerCase())
34
+ }
35
+ return words.size >= 2
36
+ }
37
+
38
+ /** trailers and footers that name an assistant, assistant session trailers,
39
+ * assistant share-domain urls (a session link is a louder tell than any
40
+ * trailer), plus the robot emoji. the session/url family lists every
41
+ * assistant, not just the one that bit first. */
11
42
  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
43
+ /(?: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
44
 
14
45
  /** everything below git's scissors line is the verbose-mode diff, which git
15
46
  * strips before committing, diff content is not ours to police. */
@@ -22,7 +53,7 @@ export function stripNonMessage(message: string): string {
22
53
  * `#` comment lines never land in the message and are ignored; a `Revert "…"`
23
54
  * title quotes the reverted subject verbatim and is exempt; a line-start
24
55
  * `- ` bullet is a list marker, not punctuation. */
25
- export function commitMessageOffenses(message: string): Offense[] {
56
+ export function commitMessageOffenses(message: string, opts?: { languageAllow?: string[] }): Offense[] {
26
57
  const offenses: Offense[] = []
27
58
  stripNonMessage(message).split('\n').forEach((raw, i) => {
28
59
  if (raw.startsWith('#')) return
@@ -33,7 +64,9 @@ export function commitMessageOffenses(message: string): Offense[] {
33
64
  if (body.includes(' - '))
34
65
  offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
35
66
  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' })
67
+ 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' })
68
+ if (frenchHits(raw, opts?.languageAllow))
69
+ offenses.push({ line: i + 1, text: raw, kind: 'language', why: 'French: git surfaces are English, always (identity/writing-voice.md); quote product copy in "…" or `…` to exempt it' })
37
70
  })
38
71
  return offenses
39
72
  }
@@ -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,10 @@
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
+ import { fileURLToPath } from 'url'
8
9
  import { commitMessageOffenses } from './commit-message.ts'
9
- import { runLeakGuard } from './leak-guard.ts'
10
+ import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
10
11
  import { runRootIdentity } from './root-identity.ts'
11
12
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
12
13
  import type { RepoConfig } from '../repo-config.ts'
@@ -23,15 +24,49 @@ export type HooksConfig = {
23
24
  /** staged-path prefixes that may never enter git (local state like
24
25
  * '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
25
26
  blockedPaths?: string[]
26
- /** disable a built-in gate, visible in `hooks list`, never silent */
27
- disable?: ('leak-guard' | 'commit-message' | 'root-identity')[]
27
+ /** words the commit-message language gate never counts as French
28
+ * (proper nouns that carry accents without being a language choice) */
29
+ languageAllow?: string[]
30
+ /** disable a built-in gate, visible in `hooks list`, never silent.
31
+ * 'commit-language' lifts ONLY the French check, keeping the dash and
32
+ * AI-attribution guards armed. */
33
+ disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
28
34
  }
29
35
 
30
36
  const HOOK_DIR = 'hooks'
31
37
  const HOOKS = ['pre-commit', 'commit-msg'] as const
32
38
 
33
- /** wire `core.hooksPath` and write the two hook bodies. Idempotent. */
34
- export function installHooks(root: string, binInvocation: string): { written: string[]; note: string } {
39
+ /** how a self-written hook body should invoke shipkit in this repo: the
40
+ * repo's own node_modules copy when it has one (survives shim churn, pins
41
+ * the repo's version), else the cli that is running right now. */
42
+ export function hookBinInvocation(root: string): string {
43
+ const local = join(root, 'node_modules/rvlib-shipkit/src/cli.ts')
44
+ if (existsSync(local)) return 'bun node_modules/rvlib-shipkit/src/cli.ts'
45
+ const running = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.ts')
46
+ const prefix = root.endsWith('/') ? root : root + '/'
47
+ return running.startsWith(prefix) ? `bun ${running.slice(prefix.length)}` : `bun ${running}`
48
+ }
49
+
50
+ export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git'; detail: string }
51
+
52
+ /** self-arming: a repo.config with dark commit gates is the half-wired state
53
+ * that let a whole drill session commit ungated (2026-08-04). Idempotent,
54
+ * and a FOREIGN core.hooksPath is respected and named, never taken. */
55
+ export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
56
+ const inRepo = Bun.spawnSync(['git', 'rev-parse', '--is-inside-work-tree'], { cwd: root })
57
+ if (inRepo.exitCode !== 0) return { state: 'no-git', detail: 'not a git repo' }
58
+ const p = Bun.spawnSync(['git', 'config', 'core.hooksPath'], { cwd: root })
59
+ const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
60
+ if (current !== '' && current !== HOOK_DIR) return { state: 'foreign', detail: `core.hooksPath=${current}, foreign, left alone` }
61
+ const r = installHooks(root, hookBinInvocation(root), config.hooks?.keywordFile)
62
+ if (r.note.startsWith('WARN')) return { state: 'foreign', detail: r.note }
63
+ if (r.written.length === 0 && current === HOOK_DIR) return { state: 'current', detail: 'hooks already armed' }
64
+ return { state: 'armed', detail: `wrote ${r.written.join(', ') || 'nothing'}, ${r.note}` }
65
+ }
66
+
67
+ /** wire `core.hooksPath` and write the two hook bodies. Idempotent.
68
+ * `keywordFile` = the repo's configured leak-guard file, seeding target. */
69
+ export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
35
70
  const dir = join(root, HOOK_DIR)
36
71
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
37
72
  const written: string[] = []
@@ -44,12 +79,41 @@ export function installHooks(root: string, binInvocation: string): { written: st
44
79
  if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
45
80
  }
46
81
  const cfg = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
82
+ const seeded = seedKeywordFile(root, keywordFile)
83
+ if (seeded != null) written.push(seeded)
47
84
  return {
48
85
  written,
49
86
  note: cfg.exitCode === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.exitCode}) — hooks will not fire`,
50
87
  }
51
88
  }
52
89
 
90
+ // a starter so the leak guard bites from day one instead of skipping forever:
91
+ // generic secret shapes only. private names live in the machine-global
92
+ // ~/.rv-private/banned-keywords.txt, which the guard reads on every run
93
+ // (never copied into a repo: a copy rots and a copy can leak)
94
+ const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
95
+ # message) for these rows. one row = one case-insensitive substring, or
96
+ # re:<regex>. add repo-specific names here; machine-wide private names go in
97
+ # ~/.rv-private/banned-keywords.txt, read automatically alongside this file.
98
+ # this file is gitignored on purpose: the list itself is private.
99
+ re:-----BEGIN [A-Z ]*PRIVATE KEY-----
100
+ re:AKIA[0-9A-Z]{16}
101
+ re:sk-[A-Za-z0-9_-]{20,}
102
+ re:ghp_[A-Za-z0-9]{36}
103
+ re:xox[baprs]-[A-Za-z0-9-]{10,}
104
+ `
105
+
106
+ /** seed the configured keyword file with the generic starter so the guard is
107
+ * ARMED, not a skip. an existing file is never touched. */
108
+ function seedKeywordFile(root: string, keywordFile?: string): string | null {
109
+ const rel = keywordFile ?? '.rv-private/banned-keywords.txt'
110
+ const dest = join(root, rel)
111
+ if (existsSync(dest)) return null
112
+ mkdirSync(dirname(dest), { recursive: true })
113
+ writeFileSync(dest, KEYWORD_STARTER)
114
+ return `${rel} (starter shapes)`
115
+ }
116
+
53
117
  export type GateOutcome = { id: string; ok: boolean; lines: string[] }
54
118
 
55
119
  /** the pre-commit gate: built-in leak guard, then the repo's own commands. */
@@ -119,9 +183,11 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
119
183
  const out: GateOutcome[] = []
120
184
  const message = readFileSync(messageFile, 'utf8')
121
185
  if (!(h.disable ?? []).includes('commit-message')) {
122
- const offenses = commitMessageOffenses(message)
186
+ const languageOff = (h.disable ?? []).includes('commit-language')
187
+ const offenses = commitMessageOffenses(message, { languageAllow: h.languageAllow })
188
+ .filter((o) => o.kind !== 'language' || !languageOff)
123
189
  out.push(offenses.length === 0
124
- ? { id: 'commit-message', ok: true, lines: ['ok: commit message clean (no dash punctuation, no AI attribution)'] }
190
+ ? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation, no AI attribution${languageOff ? '' : ', English'})`] }
125
191
  : {
126
192
  id: 'commit-message',
127
193
  ok: false,
@@ -156,13 +222,13 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
156
222
  const kw = h.keywordFile ?? '.rv-private/banned-keywords.txt'
157
223
  // a gate listed as active but unable to fire is worse than no listing:
158
224
  // 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)`
225
+ const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.rv-private list)`
160
226
  return [
161
- `pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
227
+ `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.rv-private/banned-keywords.txt`,
162
228
  ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
163
229
  `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
230
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
165
- `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
231
+ `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
166
232
  `commit-msg leak-guard${leakState} — the message text`,
167
233
  ...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
168
234
  ]
@@ -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
  }
package/src/start-repo.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
8
8
  import { homedir } from 'os'
9
9
  import { join } from 'path'
10
+ import { initRepo } from './init-repo.ts'
10
11
 
11
12
  export type StartOpts = {
12
13
  /** parent dir for repos — default ~/dev */
@@ -19,7 +20,7 @@ export type StartOpts = {
19
20
  skip?: string[]
20
21
  }
21
22
 
22
- export function startRepo(name: string, opts: StartOpts = {}): number {
23
+ export async function startRepo(name: string, opts: StartOpts = {}): Promise<number> {
23
24
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
24
25
  console.error(`FAIL: bad repo name '${name}' — letters/digits/._- only, no leading dot, no slashes`)
25
26
  return 2
@@ -55,6 +56,21 @@ export function startRepo(name: string, opts: StartOpts = {}): number {
55
56
  }
56
57
  }
57
58
  }
59
+ // start is the from-anywhere verb: a dir that is already a real project
60
+ // (package.json) gets wired on the spot, so a pre-existing repo goes
61
+ // 0 → shipkit'd with the one command. init itself stays cwd-only, like
62
+ // git init. A brand-new empty dir cannot be wired yet, say what is next.
63
+ if (!existsSync(join(dir, 'repo.config.ts'))) {
64
+ if (existsSync(join(dir, 'package.json'))) {
65
+ // initRepo reports on stdout, but start's stdout contract is THE
66
+ // DIR ONLY (a sourced alias cd's to it) — reroute to stderr here
67
+ const realLog = console.log
68
+ console.log = ((...a: unknown[]) => console.error(...a)) as typeof console.log
69
+ try { await initRepo(dir, {}) } finally { console.log = realLog }
70
+ } else {
71
+ console.error(`next: scaffold the app in ${dir} (package.json), then \`shipkit init\` inside it — the commit gates arm on the first ci run`)
72
+ }
73
+ }
58
74
  console.log(dir)
59
75
  return 0
60
76
  }