baldart 4.54.1 → 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,22 @@ 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
+
8
24
  ## [4.54.1] - 2026-06-19
9
25
 
10
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:
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.54.1
1
+ 4.55.0
@@ -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.1",
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;