rvlib-shipkit 0.19.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,11 @@
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
+
3
9
  ## 0.19.0
4
10
 
5
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.19.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,
@@ -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
 
@@ -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,9 +4,37 @@
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
+ /** 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
+
10
38
  /** trailers and footers that name an assistant, assistant session trailers,
11
39
  * assistant share-domain urls (a session link is a louder tell than any
12
40
  * trailer), plus the robot emoji. the session/url family lists every
@@ -25,7 +53,7 @@ export function stripNonMessage(message: string): string {
25
53
  * `#` comment lines never land in the message and are ignored; a `Revert "…"`
26
54
  * title quotes the reverted subject verbatim and is exempt; a line-start
27
55
  * `- ` bullet is a list marker, not punctuation. */
28
- export function commitMessageOffenses(message: string): Offense[] {
56
+ export function commitMessageOffenses(message: string, opts?: { languageAllow?: string[] }): Offense[] {
29
57
  const offenses: Offense[] = []
30
58
  stripNonMessage(message).split('\n').forEach((raw, i) => {
31
59
  if (raw.startsWith('#')) return
@@ -37,6 +65,8 @@ export function commitMessageOffenses(message: string): Offense[] {
37
65
  offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
38
66
  if (AI_ATTRIBUTION.test(raw))
39
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' })
40
70
  })
41
71
  return offenses
42
72
  }
@@ -5,6 +5,7 @@
5
5
  // the hooks enforce. `shipkit hooks list` answers that without reading shell.
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
7
7
  import { dirname, join } from 'path'
8
+ import { fileURLToPath } from 'url'
8
9
  import { commitMessageOffenses } from './commit-message.ts'
9
10
  import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
10
11
  import { runRootIdentity } from './root-identity.ts'
@@ -23,13 +24,46 @@ 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
 
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
+
33
67
  /** wire `core.hooksPath` and write the two hook bodies. Idempotent.
34
68
  * `keywordFile` = the repo's configured leak-guard file, seeding target. */
35
69
  export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
@@ -149,9 +183,11 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
149
183
  const out: GateOutcome[] = []
150
184
  const message = readFileSync(messageFile, 'utf8')
151
185
  if (!(h.disable ?? []).includes('commit-message')) {
152
- 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)
153
189
  out.push(offenses.length === 0
154
- ? { 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'})`] }
155
191
  : {
156
192
  id: 'commit-message',
157
193
  ok: false,
@@ -192,7 +228,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
192
228
  ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
193
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`,
194
230
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
195
- `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)' : ''}`,
196
232
  `commit-msg leak-guard${leakState} — the message text`,
197
233
  ...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
198
234
  ]
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
  }