baldart 4.54.0 → 4.55.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
@@ -5,6 +5,40 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.55.0] - 2026-06-19
9
+
10
+ **The i18n anti-hardcoded gate now also runs as a guarded lefthook pre-commit command — closing the direct-commit hole for projects that have BOTH the toolchain and i18n enabled.** Until now the gate ran only inside the BALDART workflows (`/new`, `/new2`, `/qa`, qa-sentinel, routine), so a *direct* `git commit` (a human, or an agent editing outside `/new`) bypassed it. When `features.has_toolchain` AND `features.has_i18n` are both true, a fresh `lefthook.yml` now gains an `i18n` pre-commit command that runs the standalone anti-hardcoded gate over `{staged_files}`. It is **guarded** (`sh -c 'if [ -f eslint.i18n.config.mjs ] || …; then exec <cmd> "$@"; fi'`): the gate's non-zero exit propagates (a hardcoded JSX string fails the commit), but when the gate config is absent it is a clean no-op (exit 0) — never a spurious block. Bypassable with `git commit --no-verify` (a strong default, not a wall); covers JSX text + allowlisted attributes (`jsx-only`); the non-JSX backstop stays with `code-reviewer`; consumers without the toolchain keep the `/new`/qa-sentinel gates as enforcement.
11
+
12
+ **Scope discipline (adversarial-reviewed).** The original idea also included a deterministic *registry-membership* pre-commit check (every `t('key')` must be in the registry). A 3-reviewer adversarial pass **rejected it** and it is NOT shipped: `I18N_REGISTRY_DRIFT` is already triple-covered (coder STEP 9 + code-reviewer + doc-reviewer); the `extract_command` activation gate was a fake proxy (the enumerator is a regex, and `extract_command` is auto-populated only for i18next/lingui/react-intl → off for the mainstream next-intl/vue/custom); the static-key regex would false-block correct commits (`useTranslation('ns')` + `t('key')`, overlay-overridable conventions); and it would break inside `/new` git worktrees (`.claude/skills/` is untracked there). The pre-commit hook was likewise scoped down — it rides the **existing** toolchain lefthook (no new installer, no new config key, no husky branch, no installing lefthook on a non-toolchain project, no js-yaml round-trip of user files).
13
+
14
+ **MINOR** (extends the toolchain lefthook's fresh-config behaviour, gated on existing flags; no new agent/skill/command, no new `baldart.config.yml` key, no install/layout change).
15
+
16
+ ### Added / Changed
17
+
18
+ - **`src/utils/toolchain-adapters/lefthook.js`** — `initConfig(cwd, opts)` accepts `opts.i18nCommand`; a fresh `lefthook.yml` gains the guarded `i18n` pre-commit command alongside `biome`. Non-destructive behaviour unchanged (writes only when absent).
19
+ - **`src/utils/toolchain-installer.js`** — `install()` / `initConfigs()` forward `opts.i18nCommand` to the adapters.
20
+ - **`src/commands/configure.js`** — the toolchain block resolves `i18nCommand` from `features.has_i18n` (the diff-scoped gate command, trailing ` .` stripped) and passes it through.
21
+ - **`src/commands/doctor.js`** — WARN-only backfill: when `has_toolchain` + `has_i18n` and `lefthook.yml` has no i18n command (toolchain predating v4.55.0), surface the exact snippet to paste. doctor never mutates a user-owned `lefthook.yml`.
22
+ - **`framework/agents/i18n-protocol.md`** — documents the pre-commit layer (gated on `has_toolchain`, `--no-verify` caveat) and records why the registry-membership check was rejected.
23
+
24
+ ## [4.54.1] - 2026-06-19
25
+
26
+ **Two bugs in the Codex relay of the `/new` review workflows — found live as a ~20-minute "freeze".** During a real `/new FEAT-0037` the Codex step of `new-card-review` appeared stuck; on-disk diagnosis (`/tmp/codexreview-wave-FEAT-0037*.md` + `ps`) showed **Codex had actually finished 9 minutes earlier** (full review, zero actionable findings) but the relay couldn't detect it, because of two independent, compounding bugs:
27
+
28
+ 1. **`$$` placeholder in the `/tmp` filenames.** The workflows built `/tmp/codextask-${cardId}-$$.txt` / `/tmp/codexreview-wave-${cardId}-$$.md`. In a JS template string `$$` is the literal two characters — the `Write` tool keeps it verbatim, but **bash expands `$$` to the PID of each subshell**, so the launch, the poll, and the extract Bash calls each referenced a *different* path that never agreed. Observed: the first launch `cat`-ed a PID-named task file that didn't exist → Codex ran with an empty task ("Provide a prompt…", 68 bytes); the relay retried with clean names (Codex then ran for real), but the poll loop kept `grep`-ing a `-$$.md` file with a fresh PID each iteration → never terminal.
29
+ 2. **Codex emits prose, not the sentinel block.** Even with the right file, Codex (GPT-5.x via the companion) wrote its findings as a prose report (`**Findings** No actionable… **Cleared Concerns** …`) instead of between `<<<FINDINGS_JSON>>>`/`<<<END_FINDINGS_JSON>>>`, so the relay's only terminal condition (`grep END_FINDINGS_JSON`) never matched → it spun to the 10-minute window before falling back to `code-reviewer`.
30
+
31
+ Nothing broke downstream (the fallback is wired), but every occurrence cost ~12 wasted minutes + a redundant full `code-reviewer` re-run. Fix:
32
+ - **Stable per-wave/batch filenames (no `$$`)** — `cardId`/`firstCardId` is already unique per wave/batch and the files are `/tmp` scratch truncated by `>` on launch, so the launch/poll/extract calls now agree on one literal path.
33
+ - **Robust terminal detection** — the relay also polls for the companion's reliable `Turn completed.` marker. New return logic: a non-empty awk extraction ⇒ `codexAvailable:true` + parsed findings; `Turn completed.`/`CODEX_NOT_FOUND` seen **but** the awk output empty (Codex finished with prose, no machine-readable block) ⇒ `codexAvailable:false` + **exit immediately** (route to the `code-reviewer` fallback — never parse findings out of prose, which would risk dropping a BLOCKER); only a full 10-minute window with no marker ⇒ false. So a prose-only completion now falls back at Codex-done time, not +10 min.
34
+ - **Task-prompt hardening (best-effort)** — Codex is told its FINAL message must be ONLY the sentinel block (no `Findings`/`Cleared Concerns`/`Validation` prose; cleared concerns go as `requires_action:false` entries inside the JSON), reducing how often the prose-fallback path is hit.
35
+
36
+ Verified by parsing both workflows (`node -c`), confirming zero `$$` remain, and running the exact `awk` + marker logic against the real incident fixture (prose-only → empty awk + `Turn completed.` → `false`/fallback) and a synthetic sentinel file (→ valid JSON, `true`). Scope is the two review workflows only; `new2.js`'s Codex usage is a different shape (a `$FILE` var, no sentinel-poll dependency; a synchronous `--wait` per-card call) and inherits the fix at the Final via its delegation to `new-final-review`. **PATCH** (relay bugfix; no new agent/skill/command, no `baldart.config.yml` key, no install/layout change).
37
+
38
+ ### Fixed
39
+
40
+ - **`framework/.claude/workflows/new-card-review.js`** + **`new-final-review.js`** — Codex relay: (1) dropped the `$$` placeholder from the `/tmp` task/review filenames (stable per-wave/batch names); (2) added `Turn completed.` as a poll terminal + a deterministic 3-way return rule (non-empty JSON ⇒ available; finished-without-block ⇒ unavailable, immediate fallback, never prose-parse; full-window-no-marker ⇒ unavailable); (3) task prompt now demands the FINAL message be ONLY the sentinel block.
41
+
8
42
  ## [4.54.0] - 2026-06-19
9
43
 
10
44
  **The i18n anti-hardcoded gate is now DIFF-SCOPED in every per-change context, and the two UI-authoring surfaces (`ui-expert`, `ui-design`) finally honor the i18n layer.** Surfaced by a real `/new FEAT-0037` run on a consumer (`mayo`) that is *partially* i18n-adopted: the coder had to manually triage 25 eslint-i18n errors + 4 parity failures and discovered they were **pre-existing baseline debt in files the card never touched** (`products/edit`, `products/new`, `sw-register`, …). Root cause: the i18n gate ran **whole-repo** (`npx eslint --config eslint.i18n.config.mjs .`) in every per-change consumer, while the *normal* lint gate was already diff-scoped — so on any mid-adoption project every card failed the i18n gate on unrelated baseline debt. The coder did the right thing (distrusted its own report, verified independently) but should never have had to. (The parity failure and the `Cannot find module '@/lib/i18n'` tsc note in that run were project-side, not framework — handled soundly by the coder.)
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.54.0
1
+ 4.55.0
@@ -229,13 +229,17 @@ const codexReviewTask =
229
229
  `Run a deep code review over this wave's committed diff. The code is already written and committed — find bugs, regressions, security issues, and quality problems, per the protocol in ${protocolRef} (Phase 3.7).\n\n` +
230
230
  `${waveBrief}\n\n${baselineBrief}\n\n` +
231
231
  `Run the mandatory false-positive check on every finding and suppress the unconvincing ones. Actionability: set requires_action:false on a VERIFIED observation that needs NO change (a cleared concern — MED/LOW only; a BLOCKER/HIGH is always requires_action:true).\n\n` +
232
- `Emit your findings as your FINAL message, between these EXACT sentinels and nothing else after the opening sentinel:\n` +
232
+ `Your FINAL message MUST be ONLY the sentinel block below — NOTHING else. Do NOT write a prose report, do NOT add "Findings" / "Cleared Concerns" / "Validation" headings before or after it. Put every cleared concern as a \`requires_action:false\` entry INSIDE the JSON array. Emit EXACTLY:\n` +
233
233
  `<<<FINDINGS_JSON>>>\n` +
234
234
  `[{"finding_id":"<CARD-ID>-F###","title":"...","severity":"BLOCKER|HIGH|MEDIUM|LOW","confidence":<0-100>,"evidence":"exact file:line + code quote","minimal_fix_direction":"...","domain":"doc|security|migration|code|perf|test","requires_action":true}]\n` +
235
235
  `<<<END_FINDINGS_JSON>>>\n` +
236
236
  `Use an empty array [] between the sentinels if there are no real bugs.`
237
- const codexTaskFile = `/tmp/codextask-${cards[0].cardId}-$$.txt`
238
- const codexReviewFile = `/tmp/codexreview-wave-${cards[0].cardId}-$$.md`
237
+ // Stable per-wave names — NO `$$`: in a JS template string `$$` is the literal two chars, which the
238
+ // Write tool keeps verbatim but bash expands to a per-subshell PID → the launch/poll/extract Bash calls
239
+ // each saw a DIFFERENT path and never agreed (observed live: poll grepped a nonexistent PID-named file
240
+ // forever). cardId is already unique per wave; the file is /tmp scratch truncated by `>` on launch.
241
+ const codexTaskFile = `/tmp/codextask-${cards[0].cardId}.txt`
242
+ const codexReviewFile = `/tmp/codexreview-wave-${cards[0].cardId}.md`
239
243
  const codexPrompt =
240
244
  `You are a THIN RELAY around the Codex companion — do NOT review or investigate code yourself, do NOT grep/sed/Read source to "confirm" findings (Codex's findings are authoritative and already FP-validated; re-verifying them is wasted work). Your ONLY job: launch Codex, wait, extract its JSON, return it.\n\n` +
241
245
  `The companion script is ALREADY CONFIRMED PRESENT at:\n ${codexScriptPath}\n\n` +
@@ -243,11 +247,17 @@ const codexPrompt =
243
247
  ` 1. Write the task text (everything after "TASK PROMPT:" at the end of this message) to ${codexTaskFile} using the Write tool — do NOT inline it into the shell command (avoids quote breakage).\n` +
244
248
  ` 2. Launch Codex in the BACKGROUND (run_in_background:true — a sync run hits the Bash timeout):\n` +
245
249
  ` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" > ${codexReviewFile} 2>&1\n` +
246
- ` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until it contains "<<<END_FINDINGS_JSON>>>" (terminal) OR "CODEX_NOT_FOUND" OR the full 10-minute window has elapsed.\n` +
250
+ ` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until it contains ANY of these — whichever appears FIRST ends the poll (do NOT keep waiting the full window once one is present):\n` +
251
+ ` • "<<<END_FINDINGS_JSON>>>" → Codex emitted the JSON block (success path)\n` +
252
+ ` • "Turn completed." → the companion's reliable end-of-run marker — Codex FINISHED even if it wrote prose instead of the block\n` +
253
+ ` • "CODEX_NOT_FOUND" → companion missing\n` +
254
+ ` • the full 10-minute window has elapsed\n` +
247
255
  ` 4. Extract the findings with this EXACT command (deterministic — skips the [codex] trace + truncated echo, takes the LAST complete sentinel pair):\n` +
248
256
  ` awk '/<<<FINDINGS_JSON>>>/{c=1;b="";next}/<<<END_FINDINGS_JSON>>>/{c=0;last=b;next}c{b=b $0 ORS}END{printf "%s",last}' ${codexReviewFile}\n` +
249
- ` Parse that output as JSON and return it verbatim as \`findings\` (its keys already match the schema). Do NOT re-verify, re-grep, or Read any source file.\n\n` +
250
- `Set codexAvailable:false ONLY if ${codexReviewFile} contains "CODEX_NOT_FOUND" or has no "<<<END_FINDINGS_JSON>>>" after the FULL 10-minute window NEVER because a single poll returned slowly. Otherwise codexAvailable:true.\n\n` +
257
+ ` 5. Decide the return do NOT re-verify, re-grep, or Read any source file:\n` +
258
+ ` awk output is a NON-EMPTY JSON array parse it and return it verbatim as \`findings\`, codexAvailable:true (its keys already match the schema).\n` +
259
+ ` • awk output is EMPTY but the file has "Turn completed." (or "CODEX_NOT_FOUND") → Codex finished WITHOUT the sentinel block (e.g. it wrote a prose report). Return codexAvailable:false, findings:[] and EXIT NOW — do NOT wait the rest of the window, and do NOT parse findings out of the prose (a prose-only completion routes to the code-reviewer fallback; never risk dropping a real finding by guessing).\n` +
260
+ ` • none of the markers ever appeared after the FULL 10-minute window → codexAvailable:false. NEVER set false because a single poll returned slowly.\n\n` +
251
261
  `TASK PROMPT:\n${codexReviewTask}`
252
262
 
253
263
  const tcGateLines = [
@@ -185,13 +185,17 @@ const codexReviewTask =
185
185
  `Run a deep code review over this batch diff, per the /codexreview protocol in ${protocolRef} (Step F.3). The code is already written and committed — find bugs, regressions, security issues, and quality problems.\n\n` +
186
186
  `${scopeBrief}\n\n${baselineBrief}\n\n` +
187
187
  `Run the mandatory false-positive check on every finding and suppress the unconvincing ones. Actionability: set requires_action:false on a VERIFIED observation that needs NO change (a cleared concern — MED/LOW only; a BLOCKER/HIGH is always requires_action:true).\n\n` +
188
- `Emit your findings as your FINAL message, between these EXACT sentinels and nothing else after the opening sentinel:\n` +
188
+ `Your FINAL message MUST be ONLY the sentinel block below — NOTHING else. Do NOT write a prose report, do NOT add "Findings" / "Cleared Concerns" / "Validation" headings before or after it. Put every cleared concern as a \`requires_action:false\` entry INSIDE the JSON array. Emit EXACTLY:\n` +
189
189
  `<<<FINDINGS_JSON>>>\n` +
190
190
  `[{"finding_id":"<CARD-ID>-F###","title":"...","severity":"BLOCKER|HIGH|MEDIUM|LOW","confidence":<0-100>,"evidence":"exact file:line + code quote","minimal_fix_direction":"...","domain":"doc|security|migration|code|perf|test","requires_action":true}]\n` +
191
191
  `<<<END_FINDINGS_JSON>>>\n` +
192
192
  `Use an empty array [] between the sentinels if there are no real bugs.`
193
- const codexTaskFile = `/tmp/codextask-batch-${a.firstCardId || 'batch'}-$$.txt`
194
- const codexReviewFile = `/tmp/codexreview-batch-${a.firstCardId || 'batch'}-$$.md`
193
+ // Stable per-batch names — NO `$$`: in a JS template string `$$` is the literal two chars, which the
194
+ // Write tool keeps verbatim but bash expands to a per-subshell PID → the launch/poll/extract Bash calls
195
+ // each saw a DIFFERENT path and never agreed (observed live: poll grepped a nonexistent PID-named file
196
+ // forever). firstCardId is unique per batch; the file is /tmp scratch truncated by `>` on launch.
197
+ const codexTaskFile = `/tmp/codextask-batch-${a.firstCardId || 'batch'}.txt`
198
+ const codexReviewFile = `/tmp/codexreview-batch-${a.firstCardId || 'batch'}.md`
195
199
  const codexPrompt =
196
200
  `You are a THIN RELAY around the Codex companion — do NOT review or investigate code yourself, do NOT grep/sed/Read source to "confirm" findings (Codex's findings are authoritative and already FP-validated). Your ONLY job: launch Codex, wait, extract its JSON, return it.\n\n` +
197
201
  `The companion script is ALREADY CONFIRMED PRESENT at:\n ${codexScriptPath}\n\n` +
@@ -199,11 +203,17 @@ const codexPrompt =
199
203
  ` 1. Write the task text (everything after "TASK PROMPT:" at the end of this message) to ${codexTaskFile} using the Write tool — do NOT inline it into the shell command (avoids quote breakage).\n` +
200
204
  ` 2. Launch Codex in the BACKGROUND (run_in_background:true — a sync run hits the Bash timeout):\n` +
201
205
  ` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" > ${codexReviewFile} 2>&1\n` +
202
- ` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until it contains "<<<END_FINDINGS_JSON>>>" (terminal) OR "CODEX_NOT_FOUND" OR the full 10-minute window has elapsed.\n` +
206
+ ` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until it contains ANY of these — whichever appears FIRST ends the poll (do NOT keep waiting the full window once one is present):\n` +
207
+ ` • "<<<END_FINDINGS_JSON>>>" → Codex emitted the JSON block (success path)\n` +
208
+ ` • "Turn completed." → the companion's reliable end-of-run marker — Codex FINISHED even if it wrote prose instead of the block\n` +
209
+ ` • "CODEX_NOT_FOUND" → companion missing\n` +
210
+ ` • the full 10-minute window has elapsed\n` +
203
211
  ` 4. Extract the findings with this EXACT command (deterministic — skips the [codex] trace + truncated echo, takes the LAST complete sentinel pair):\n` +
204
212
  ` awk '/<<<FINDINGS_JSON>>>/{c=1;b="";next}/<<<END_FINDINGS_JSON>>>/{c=0;last=b;next}c{b=b $0 ORS}END{printf "%s",last}' ${codexReviewFile}\n` +
205
- ` Parse that output as JSON and return it verbatim as \`findings\` (its keys already match the schema). Do NOT re-verify, re-grep, or Read any source file.\n\n` +
206
- `Set codexAvailable:false ONLY if ${codexReviewFile} contains "CODEX_NOT_FOUND" or has no "<<<END_FINDINGS_JSON>>>" after the FULL 10-minute window NEVER because a single poll returned slowly. Otherwise codexAvailable:true.\n\n` +
213
+ ` 5. Decide the return do NOT re-verify, re-grep, or Read any source file:\n` +
214
+ ` awk output is a NON-EMPTY JSON array parse it and return it verbatim as \`findings\`, codexAvailable:true (its keys already match the schema).\n` +
215
+ ` • awk output is EMPTY but the file has "Turn completed." (or "CODEX_NOT_FOUND") → Codex finished WITHOUT the sentinel block (e.g. it wrote a prose report). Return codexAvailable:false, findings:[] and EXIT NOW — do NOT wait the rest of the window, and do NOT parse findings out of the prose (a prose-only completion routes to the code-reviewer fallback; never risk dropping a real finding by guessing).\n` +
216
+ ` • none of the markers ever appeared after the FULL 10-minute window → codexAvailable:false. NEVER set false because a single poll returned slowly.\n\n` +
207
217
  `TASK PROMPT:\n${codexReviewTask}`
208
218
 
209
219
  const docPrompt =
@@ -96,6 +96,22 @@ Translations are NOT stored here — they live in the native locale files.
96
96
  routine — run whole-repo (`.`) by design, because they reconcile the entire
97
97
  codebase, not a single change. A diff-scoped gate failure is therefore always a
98
98
  string the change itself introduced — never baseline noise to triage.
99
+
100
+ **Pre-commit layer (since v4.55.0) — only when `features.has_toolchain` is also
101
+ on.** The gates above run inside the BALDART workflows; a **direct** `git commit`
102
+ (a human, or an agent editing outside `/new`) bypasses them. So when BOTH
103
+ `features.has_i18n` and `features.has_toolchain` are true, the curated lefthook
104
+ pre-commit gains a **guarded `i18n` command** that runs the anti-hardcoded gate
105
+ over `{staged_files}` on every commit. It is wired automatically into a fresh
106
+ `lefthook.yml` by `baldart configure`/`add`; for a toolchain installed before
107
+ v4.55.0, `baldart doctor` surfaces the snippet to paste (it never mutates a
108
+ user-owned `lefthook.yml`). The command is guarded (no-op when the gate config is
109
+ absent) and **bypassable with `git commit --no-verify`** — a strong default, not
110
+ a wall. Consumers WITHOUT the toolchain are not covered at direct-commit time;
111
+ the `/new` + `qa-sentinel` gates remain their enforcement. (A deterministic
112
+ registry-membership pre-commit check was evaluated and rejected: it is already
113
+ triple-covered by the review owners below, its key-enumeration is fragile across
114
+ stacks, and it breaks inside `/new` git worktrees.)
99
115
  2. **Semantic backstop (review)** — non-JSX imperative strings are OUTSIDE the
100
116
  linter's deterministic reach, so `code-reviewer` flags them as **HIGH**,
101
117
  **syntactically scoped**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "4.54.0",
3
+ "version": "4.55.0",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -981,6 +981,16 @@ async function interactivePrompts(merged, detected) {
981
981
  const migs = ti.migrations(); // curated tools blocked by an incumbent
982
982
  const toInstall = [];
983
983
 
984
+ // i18n pre-commit gate (since v4.55.0) — when has_i18n, a FRESH lefthook.yml
985
+ // also gets a guarded i18n command (the anti-hardcoded gate over staged
986
+ // files), so direct commits are caught too. Resolve + strip the trailing
987
+ // full-sweep ` .` (consistent with the diff-scoped gates). null when i18n off
988
+ // → lefthook stays Biome-only. Existing lefthook.yml is never mutated here
989
+ // (doctor WARNs with the snippet instead).
990
+ const i18nCmd = merged.features.has_i18n
991
+ ? I18nGate.commandForCwd(process.cwd()).replace(/\s+\.$/, '')
992
+ : null;
993
+
984
994
  // Clean path — preselect (default Y).
985
995
  if (recommended.length) {
986
996
  const want = await UI.confirm(`Install the curated toolchain (${recommended.join(', ')}) as devDependencies now?`, true);
@@ -1001,7 +1011,7 @@ async function interactivePrompts(merged, detected) {
1001
1011
  }
1002
1012
 
1003
1013
  if (toInstall.length) {
1004
- const res = ti.install({ tools: toInstall });
1014
+ const res = ti.install({ tools: toInstall, i18nCommand: i18nCmd });
1005
1015
  for (const t of res.installed) UI.success(`Installed ${t}.`);
1006
1016
  for (const f of res.failed) UI.warning(`Install failed for ${f.tool}: ${f.error}`);
1007
1017
  for (const s of res.skipped) UI.info(`Skipped ${s.tool}: ${s.reason}`);
@@ -1010,7 +1020,7 @@ async function interactivePrompts(merged, detected) {
1010
1020
  // Resolve the active tool set (every in-scope tool usable now) → configs + commands.
1011
1021
  const active = ti.activeTools();
1012
1022
  if (active.length) {
1013
- const cfgRes = ti.initConfigs({ tools: active });
1023
+ const cfgRes = ti.initConfigs({ tools: active, i18nCommand: i18nCmd });
1014
1024
  for (const c of cfgRes) {
1015
1025
  if (c.status === 'written') UI.success(`Wrote default ${c.tool} config.`);
1016
1026
  else if (c.status === 'skipped' && c.reason === 'exists') UI.info(`Kept existing ${c.tool} config (not overwritten).`);
@@ -446,6 +446,21 @@ async function detectState(cwd, opts = {}) {
446
446
  && (!I18nGate.isConfigPresent(cwd) || !I18nGate.depsInstalled(cwd))) {
447
447
  state.i18nGateMissing = true;
448
448
  }
449
+ // i18n pre-commit gate wired into lefthook? (since v4.55.0) — only when
450
+ // the toolchain (lefthook) is in play. WARN-only: a fresh lefthook.yml
451
+ // gets the guarded i18n command at configure time, but a consumer whose
452
+ // toolchain was installed before v4.55.0 has a Biome-only one. doctor
453
+ // NEVER mutates a user lefthook.yml — it surfaces the snippet to add.
454
+ state.i18nPrecommitSnippetNeeded = false;
455
+ if (config.features.has_toolchain === true) {
456
+ const lhPath = path.join(cwd, 'lefthook.yml');
457
+ if (fs.existsSync(lhPath)) {
458
+ const lh = fs.readFileSync(lhPath, 'utf8');
459
+ if (!/eslint\.i18n\.config\.mjs/.test(lh) && !/\.eslintrc\.i18n\.json/.test(lh)) {
460
+ state.i18nPrecommitSnippetNeeded = true;
461
+ }
462
+ }
463
+ }
449
464
  }
450
465
  } catch (_) { /* never block doctor on i18n probe */ }
451
466
 
@@ -942,6 +957,26 @@ function planActions(state) {
942
957
  },
943
958
  });
944
959
  }
960
+ if (state.i18nEnabled && state.i18nPrecommitSnippetNeeded) {
961
+ const i18nCmd = I18nGate.commandForCwd(state.cwd).replace(/\s+\.$/, '');
962
+ const snippet = [
963
+ ' commands:',
964
+ ' i18n:',
965
+ ' glob: "*.{js,jsx,ts,tsx,mjs,cjs}"',
966
+ ` run: sh -c 'if [ -f eslint.i18n.config.mjs ] || [ -f .eslintrc.i18n.json ]; then exec ${i18nCmd} "$@"; fi' _ {staged_files}`,
967
+ ].join('\n');
968
+ actions.push({
969
+ key: 'i18n-precommit-snippet',
970
+ label: 'Add the i18n anti-hardcoded gate to your lefthook pre-commit (manual — snippet shown)',
971
+ why: 'features.has_i18n + the toolchain are both on, but lefthook.yml has no i18n command, so a DIRECT `git commit` (outside /new) is not gated for hardcoded user-facing strings. A fresh install wires this automatically; your toolchain predates v4.55.0. doctor will NOT edit your lefthook.yml — it shows the block to paste under pre-commit.',
972
+ autoOk: false, // never mutates a user-owned lefthook.yml — surfaces the snippet only
973
+ run: async () => {
974
+ UI.info('Add this under your `pre-commit:` block in lefthook.yml (merge into the existing `commands:` map):');
975
+ UI.info(snippet);
976
+ UI.info('The gate is guarded (no-op if the eslint.i18n config is absent) and bypassable with `git commit --no-verify`. The /new + qa-sentinel gates remain regardless.');
977
+ },
978
+ });
979
+ }
945
980
 
946
981
  // External-tool version currency (since v4.38.0). One non-blocking upgrade
947
982
  // action per managed tool confirmed behind upstream — the tool-dependency
@@ -37,11 +37,22 @@ class LefthookAdapter {
37
37
  /**
38
38
  * Write a minimal lefthook.yml (pre-commit → Biome on staged files) only when
39
39
  * absent. Never overwrites. Returns `{ status, reason?, path }`. Never throws.
40
+ *
41
+ * `opts.i18nCommand` (since v4.55.0) — when set (only on a `features.has_i18n`
42
+ * project, resolved + trailing-` .`-stripped by the caller), the fresh config
43
+ * ALSO gains an `i18n` pre-commit command that runs the standalone
44
+ * anti-hardcoded gate over `{staged_files}`. It is **guarded**: it runs the gate
45
+ * only if the gate's config file exists on disk and `exec`s it so the gate's
46
+ * exit code propagates (a hardcoded JSX string fails the commit); when the
47
+ * config is absent (e.g. the user declined gate setup, or it runs before the
48
+ * gate is written in the same `configure` pass) it is a clean no-op (exit 0),
49
+ * never a spurious block. Closes the direct-commit hole for toolchain+i18n
50
+ * consumers — see agents/i18n-protocol.md.
40
51
  */
41
- initConfig(cwd = this.cwd) {
52
+ initConfig(cwd = this.cwd, opts = {}) {
42
53
  const target = path.join(cwd, this.configFile);
43
54
  if (fs.existsSync(target)) return { status: 'skipped', reason: 'exists', path: target };
44
- const yml = [
55
+ const lines = [
45
56
  '# Managed by BALDART toolchain layer — customize freely.',
46
57
  '# Runs Biome over staged files before each commit. Edit/remove as needed.',
47
58
  'pre-commit:',
@@ -50,8 +61,20 @@ class LefthookAdapter {
50
61
  ' biome:',
51
62
  ' glob: "*.{js,ts,jsx,tsx,json,jsonc,css}"',
52
63
  ' run: npx biome check --no-errors-on-unmatched {staged_files}',
53
- '',
54
- ].join('\n');
64
+ ];
65
+ if (opts.i18nCommand) {
66
+ // Guarded: skip when the gate config is absent (exit 0), else exec so the
67
+ // gate's non-zero exit blocks the commit. Single-quoted sh -c is a plain
68
+ // YAML scalar (no `: `/`#`) — verified to round-trip exactly.
69
+ const guard = `sh -c 'if [ -f eslint.i18n.config.mjs ] || [ -f .eslintrc.i18n.json ]; then exec ${opts.i18nCommand} "$@"; fi' _ {staged_files}`;
70
+ lines.push(
71
+ ' i18n:',
72
+ ' glob: "*.{js,jsx,ts,tsx,mjs,cjs}"',
73
+ ` run: ${guard}`,
74
+ );
75
+ }
76
+ lines.push('');
77
+ const yml = lines.join('\n');
55
78
  try {
56
79
  fs.writeFileSync(target, yml, 'utf8');
57
80
  return { status: 'written', path: target };
@@ -93,7 +93,7 @@ class ToolchainInstaller {
93
93
  * `{ ok, installed:[], failed:[{tool,error}], skipped:[{tool,reason}] }`.
94
94
  * Never throws. NOT run in CI / `--non-interactive` by the command layer.
95
95
  */
96
- install({ tools = this.recommend(), spinner = true } = {}) {
96
+ install({ tools = this.recommend(), spinner = true, i18nCommand = null } = {}) {
97
97
  if (!this.hasPackageJson()) {
98
98
  return { ok: false, installed: [], failed: [], skipped: tools.map((t) => ({ tool: t, reason: 'no package.json' })) };
99
99
  }
@@ -113,7 +113,7 @@ class ToolchainInstaller {
113
113
  // commented-out example, then register no hooks) sees OURS — a biome
114
114
  // pre-commit — and registers it. Non-destructive (skips if present).
115
115
  if (typeof adapter.initConfig === 'function') {
116
- try { adapter.initConfig(this.cwd); } catch (_) { /* non-fatal */ }
116
+ try { adapter.initConfig(this.cwd, { i18nCommand }); } catch (_) { /* non-fatal */ }
117
117
  }
118
118
  execSync(adapter.installCommand(), { cwd: this.cwd, stdio: 'pipe', timeout: 300000 });
119
119
  if (this._toolUsable(name)) {
@@ -144,14 +144,14 @@ class ToolchainInstaller {
144
144
  * Write each tool's default config when absent (non-destructive). Returns
145
145
  * `[{ tool, status:'written'|'skipped'|'noop', reason?, path? }]`. Never throws.
146
146
  */
147
- initConfigs({ tools = this.inScope() } = {}) {
147
+ initConfigs({ tools = this.inScope(), i18nCommand = null } = {}) {
148
148
  const out = [];
149
149
  for (const name of tools) {
150
150
  let adapter;
151
151
  try { adapter = adapters.getAdapter(name, this.cwd); }
152
152
  catch { out.push({ tool: name, status: 'noop', reason: 'unknown adapter' }); continue; }
153
153
  if (typeof adapter.initConfig !== 'function') { out.push({ tool: name, status: 'noop' }); continue; }
154
- try { out.push({ tool: name, ...adapter.initConfig(this.cwd) }); }
154
+ try { out.push({ tool: name, ...adapter.initConfig(this.cwd, { i18nCommand }) }); }
155
155
  catch (err) { out.push({ tool: name, status: 'noop', reason: (err.message || '').split('\n')[0] }); }
156
156
  }
157
157
  return out;