baldart 3.33.1 → 3.35.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,37 @@ 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
+ ## [3.35.0] - 2026-05-30
9
+
10
+ De-duplicates the review chain in `/new` and adds a **Review Profile Selector** so the per-card pre-merge gate scales its *depth* by risk instead of running the full multi-agent `/codexreview` on every card — including trivial ones. The per-card gate stays **unconditional** (it always runs); only its breadth varies, and the safety-critical Codex adversarial pass + false-positive gate run on every card regardless of profile. Copertura invariata: every line is still code-reviewed once, every doc doc-reviewed once. **No new `baldart.config.yml` keys** — the profile is computed at runtime from signals already present (the Phase 3.7 high-risk-trigger detector + the QA profile), and lean mode is an internal caller→skill file contract, so the schema-propagation rule does not apply.
11
+
12
+ ### Changed — `/new` review chain (de-dup, coverage-invariant)
13
+
14
+ - **[framework/.claude/skills/new/SKILL.md](framework/.claude/skills/new/SKILL.md)** + **[framework/.claude/commands/codexreview.md](framework/.claude/commands/codexreview.md)**: per-card Phase 3.7 (and Team Mode D.4b) now hand `/codexreview` a lean contract file (`/tmp/codexreview-lean-<CARD-ID>.json`) so it **reuses the Phase-1 architecture baseline** (`/tmp/arch-baseline-<CARD-ID>.md`) instead of re-spawning `codebase-architect`, and **skips the duplicate `doc-reviewer`** (Phase 3 already audits the same diff). `/codexreview` gains a backward-compatible **Step -0.5 (lean-mode detection)** with consume-once semantics; standalone `/codexreview <CARD-ID>` (no contract file) behaves exactly as before.
15
+ - **doc-reviewer lens-merge**: Phase 3's `doc-reviewer` mandate now also carries the *spec/docs-drift → bug* lens that was previously exclusive to `/codexreview` agent #4 — so dropping the duplicate loses no coverage.
16
+ - **Final review de-dup**: single-card batches short-circuit F.1–F.4 (the lone card already passed Phase 3.7 unchanged) while still running the F.5 final build; multi-card batches scope the cross-card review to a **computed** surface (files touched by ≥2 cards ∪ `depends_on`-linked pairs), never a model-decided skip.
17
+ - **Team Mode doc-reviewer collapse**: `doc-reviewer` went from ~3× per card (D.2 + D.4a + D.4b) to ~1× per group — D.2 attributes findings per-card, D.4a consumes them (no re-spawn), D.4b skips its copy via the lean contract.
18
+
19
+ ### Added — Review Profile Selector (data-gated)
20
+
21
+ - **[framework/.claude/skills/new/SKILL.md](framework/.claude/skills/new/SKILL.md)**: Phase 3.7 Step C selects `light` ⟺ (0 high-risk triggers AND QA profile ∈ {skip, light}), else `full`. `light` drops the breadth agents (qa-sentinel, api-perf-cost-auditor, doc-reviewer) + Step 3.5 CoVe but ALWAYS keeps `code-reviewer` + Codex adversarial + the false-positive gate (BUG-0530 invariant preserved — `light` ≠ skip). Mirrors the existing QA Profile Selector pattern. Ships behind a **data gate**: the mapping stays hard-coded to `full` until consumer telemetry (`docs/metrics/` Fix Application Log) confirms `light`-eligible cards have not historically produced verified BLOCKER/HIGH at full depth.
22
+
23
+ ## [3.34.0] - 2026-05-30
24
+
25
+ Adds an **assert-active git-hooks health check** to `baldart doctor` and the `worktree-manager` verify step. A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the repo's versioned hooks lived in `scripts/git-hooks/` — the pre-commit schema guard and pre-push were **silently inactive for days**. The existing worktree-manager verify (`git config core.hooksPath || true`) only *read* the value without asserting anything, so it never caught the drift. Same class as the LSP bug: the framework "verifies" by reading a proxy instead of asserting the real state. This moves it from read-only to assert-active. **WARN-only** — the check never mutates `core.hooksPath` (a wrong heuristic + auto-fix would be worse than the original bug); it surfaces the exact remediation command and lets the user apply it. **No new `baldart.config.yml` keys** — it's a universal, always-on, filesystem-autodetected check, so the schema-propagation rule does not apply.
26
+
27
+ ### Added — git-hooks health check (assert-active)
28
+
29
+ - **[src/utils/githooks.js](src/utils/githooks.js)**: new, self-contained, fail-safe utility (distinct from `src/utils/hooks.js`, which manages `.claude/settings.json` hooks). `getStatus(cwd)` detects the repo's versioned-hooks directory (`.husky`, `.githooks`, `scripts/git-hooks`, `githooks` — first that exists and contains a standard-named hook), resolves the **active** hooks dir explicitly (`core.hooksPath` → relative resolved against `git rev-parse --show-toplevel`, absolute verbatim; unset → `<git-dir>/hooks`, handling the worktree `.git`-is-a-file case), and asserts the active dir serves the versioned hooks. **`git rev-parse --git-path hooks` is deliberately NOT used** — empirically it returns the configured value relative/unresolved and historically some git versions ignored `core.hooksPath` for it. Returns `checked:false` (silent, `ok:true`) when no managed hooks exist or on any error, so the check never produces a false WARN.
30
+ - **[src/utils/githooks.js](src/utils/githooks.js)**: pure exported `isActiveServing(activeAbs, expectedAbs)` — identical-or-nested decision (covers husky v9, which sets `core.hooksPath=.husky/_` with user hooks in `.husky/`). Paths are canonicalized (symlink-resolved, handling non-existent leaves) before comparison so a symlinked ancestor (macOS `/var`→`/private/var`, symlinked repo/home) never yields a false INACTIVE. Executability (`-x`) is asserted only for raw dirs git execs directly; husky user hooks (not required `+x` in v9) skip the assert.
31
+ - **[src/commands/doctor.js](src/commands/doctor.js)**: probe wired into `detectState` (`state.gitHooks`), a `Git hooks` line in `renderDiagnostic` (silent when `checked:false`), and an **advisory print-only** action in `planActions` (`githooks-health`) whose `run()` prints the WARN + fix command and **never mutates** `core.hooksPath` (safe under `--auto`).
32
+ - **[framework/.claude/skills/worktree-manager/SKILL.md](framework/.claude/skills/worktree-manager/SKILL.md)**: step 4 (isolation setup) replaces the silent `git config core.hooksPath || true` read with a lean **assert** — detects the versioned-hooks dir, resolves the active dir, and echoes a loud `⚠️ WARNING` + the `git config core.hooksPath <dir>` fix on mismatch (matches husky v9 `.husky/_` and `<dir>/*` as active). The worktree shares `core.hooksPath` via `.git/commondir`, so the same resolution applies.
33
+
34
+ ### Tests
35
+
36
+ - **[src/utils/__tests__/githooks-active.test.js](src/utils/__tests__/githooks-active.test.js)**: new — 8 pure fixtures for `isActiveServing` (the mayo bug, husky v9 nesting, identical dirs, prefix-not-a-boundary, trailing-sep normalization).
37
+ - **[scripts/test-githooks.sh](scripts/test-githooks.sh)**: new — drives **real** git repos: misconfig (`hooksPath=.git/hooks`, hooks in `scripts/git-hooks`) → inactive + fix; correctly wired → ok; non-executable raw hook → ok=false + chmod fix; husky v9 → active, `-x` assert skipped; no managed hooks → `checked:false` (silent).
38
+
8
39
  ## [3.33.1] - 2026-05-30
9
40
 
10
41
  Fixes a **false-positive in the LSP layer's verify step** that let `/lsp-bootstrap` (and `baldart configure` / `doctor`) report a language server as `verified` while every real LSP call failed with `Executable not found in $PATH` — a dead layer masked by `codebase-architect`'s silent Grep fallback. Root cause was a **resolution mismatch**: verify used `npx --no-install typescript-language-server --version` (resolves from `node_modules/.bin`), but the consumer — Claude Code's LSP tool — spawns the server **by name from `$PATH`**. The npm-dev install (`npm install --save-dev`) put the binary in `node_modules/.bin`, which is *not* on `$PATH`, so verify passed and spawn failed. The npm-managed adapters now install **globally** so the binary lands on `$PATH`, and verify checks `$PATH` reachability the *same way the consumer spawns it*. **No new `baldart.config.yml` keys** — reachability is computed, not configured, so the schema-propagation rule does not apply.
package/README.md CHANGED
@@ -239,7 +239,7 @@ The `ui-expert` agent is upgraded from a generic baseline to a world-class UI/UX
239
239
 
240
240
  ### LSP Symbol Search Layer (new in v3.10.0)
241
241
 
242
- When `features.has_lsp_layer: true`, `codebase-architect` and the code-exploration skills (`context-primer`, `bug`, `prd`, `new`, `simplify`) prefer LSP `find-references` / `go-to-definition` over Grep for identifier-shaped queries — the filtering happens **before** Claude reads files, so a common function name no longer dumps thousands of textual matches into context. Opt-in at `baldart configure`; BALDART installs the matching language servers (npm devDeps for TypeScript/Python, system commands printed for Go/Rust/Ruby). Grep remains the fallback for free-text queries and degraded states. See [`framework/agents/code-search-protocol.md`](framework/agents/code-search-protocol.md).
242
+ When `features.has_lsp_layer: true`, `codebase-architect` and the code-exploration skills (`context-primer`, `bug`, `prd`, `new`, `simplify`) prefer LSP `find-references` / `go-to-definition` over Grep for identifier-shaped queries — the filtering happens **before** Claude reads files, so a common function name no longer dumps thousands of textual matches into context. Opt-in at `baldart configure`; BALDART installs the matching language servers (global npm install for TypeScript/Python — the binary must be on `$PATH` because Claude Code's LSP tool spawns it by name; system commands printed for Go/Rust/Ruby). Grep remains the fallback for free-text queries and degraded states. See [`framework/agents/code-search-protocol.md`](framework/agents/code-search-protocol.md).
243
243
 
244
244
  ### Commands
245
245
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.33.1
1
+ 3.35.0
@@ -27,6 +27,48 @@ Deduplicate, then:
27
27
 
28
28
  ---
29
29
 
30
+ ## Step -0.5 — Lean Mode Detection (caller-supplied contract)
31
+
32
+ `/new` invokes this skill per-card as a pre-merge gate (Phase 3.7 / Team D.4b) and passes an
33
+ out-of-band **contract file** so it can reuse work already done in earlier phases and scale review
34
+ depth. Detection is deterministic and applies to THIS run only.
35
+
36
+ For the (single) card in scope, check for `/tmp/codexreview-lean-<CARD-ID>.json`.
37
+
38
+ - **Absent** → **full mode**. This is the default for standalone `/codexreview <CARD-ID>`
39
+ invocations — nothing below is skipped; behave exactly as documented in Steps 0–4.
40
+ - **Present** → parse it:
41
+
42
+ ```json
43
+ {
44
+ "profile": "light" | "full",
45
+ "arch_baseline_path": "/tmp/arch-baseline-<CARD-ID>.md",
46
+ "diff_summary_path": "<completion-report or git-diff path>",
47
+ "skip_doc_reviewer": true
48
+ }
49
+ ```
50
+
51
+ Apply:
52
+ - `arch_baseline_path` readable → **skip Step 1** (do NOT re-spawn `codebase-architect`); load the
53
+ baseline from that file and ALSO attach the `diff_summary_path` contents to every review agent so
54
+ they see what changed against the existing-architecture map. *(A1)*
55
+ - `skip_doc_reviewer: true` → **omit agent #4 (`doc-reviewer`)** in Step 2. Doc concerns are covered
56
+ by `/new` Phase 3, which runs `doc-reviewer` (with the spec/docs-drift lens) on the same diff
57
+ immediately before this gate. *(A2)*
58
+ - `profile: "light"` → ALSO omit agent #2 (`qa-sentinel`) and agent #3 (`api-perf-cost-auditor`),
59
+ and **skip Step 3.5 (CoVe)**. Agent #1 (`code-reviewer`), agent #5 (**Codex adversarial**), and
60
+ the Step 3 false-positive gate ALWAYS run. *(B1)*
61
+ - `profile: "full"` (or missing) → full agent set, minus #4 if `skip_doc_reviewer`.
62
+
63
+ **Invariant**: lean mode NEVER suppresses the run itself — at minimum `code-reviewer` + Codex
64
+ adversarial + Step 3 FP-gate execute on every card. If the contract file is malformed/unreadable,
65
+ fall back to **full mode** and note it. **Consume-once**: delete `/tmp/codexreview-lean-<CARD-ID>.json`
66
+ immediately after parsing it, so a later standalone `/codexreview <CARD-ID>` on the same card never
67
+ inherits a stale lean contract from a prior `/new` run. Record the resolved mode in the Step 4 report
68
+ `Method` section, e.g. `lean: profile=light, architect=reused, doc-reviewer=skipped(Phase 3), cove=skipped`.
69
+
70
+ ---
71
+
30
72
  ## Step 0 — Resolve Scope
31
73
 
32
74
  For each provided card ID:
@@ -49,6 +91,11 @@ If a card cannot be found, mark it as `UNKNOWN` and ask the user before continui
49
91
 
50
92
  ## Step 1 — Architecture Baseline (MUST)
51
93
 
94
+ > **Lean skip (Step -0.5)**: if a contract file resolved `arch_baseline_path`, SKIP this step
95
+ > entirely — read the baseline from that file instead of invoking `codebase-architect`, carry it
96
+ > (plus the attached `diff_summary_path`) as the mandatory context for downstream agents, and go to
97
+ > Step 2. In full mode (no contract file), run this step as written.
98
+
52
99
  Before running any review agents, invoke `codebase-architect` (via Task tool) for each card scope to map:
53
100
 
54
101
  - Existing architecture and critical patterns
@@ -96,6 +143,10 @@ Launch these agents in parallel for each card:
96
143
  4. `doc-reviewer` — spec/docs drift that can cause incorrect behavior
97
144
  5. **Codex (GPT-5.5)** — adversarial review via `codex-companion.mjs`
98
145
 
146
+ > **Lean agent set (Step -0.5)**: when a contract file is active, launch only the agents the resolved
147
+ > mode permits — `skip_doc_reviewer` omits #4; `profile: light` additionally omits #2 and #3. Agents
148
+ > #1 (`code-reviewer`) and #5 (Codex) ALWAYS launch. In full mode (no contract file), launch all five.
149
+
99
150
  **Codex invocation rules (agent #5):**
100
151
 
101
152
  Codex is NOT invoked via the `Agent` tool (the `codex:codex-rescue` subagent_type is not registered in the harness). Instead, invoke it directly with Bash:
@@ -152,6 +203,10 @@ Only `VERIFIED` findings can be reported as bugs.
152
203
 
153
204
  ## Step 3.5 — Cross-Agent CoVe Pass (MUST — anti-echo, anti-ripple)
154
205
 
206
+ > **Lean skip (Step -0.5)**: when the contract file resolved `profile: light`, SKIP this CoVe pass.
207
+ > Light profile is only ever selected for 0-trigger + QA-light cards (small, non-refactor diffs)
208
+ > where the ripple surface is minimal. Full profile (and all standalone runs) execute it as written.
209
+
155
210
  After Step 3 false-positive gating, the orchestrator runs ONE additional Chain-of-Verification pass on the consolidated VERIFIED pool. This catches two failure modes the per-agent CoVe doesn't:
156
211
 
157
212
  1. **Cross-agent echo allucination**: 2+ agents independently produce the same hallucinated finding (e.g. wrong file path that "looks plausible") and reinforce each other through Step 3 cross-validation.
@@ -183,7 +238,7 @@ Write one consolidated report per run to `/tmp/codexreview-report-<TIMESTAMP>.md
183
238
  Report structure:
184
239
 
185
240
  1. `Scope Reviewed`
186
- 2. `Method` (agents used + validation flow; note whether Codex was available or `CODEX_UNAVAILABLE`; note whether Step 3.5 CoVe ran fully or hit budget cap)
241
+ 2. `Method` (agents used + validation flow; note whether Codex was available or `CODEX_UNAVAILABLE`; note whether Step 3.5 CoVe ran fully or hit budget cap; in lean mode, record the resolved contract — `lean: profile=<light|full>, architect=<reused|spawned>, doc-reviewer=<skipped(Phase 3)|run>, cove=<skipped|run>`)
187
242
  3. `Verified Bugs` (ordered: BLOCKER -> HIGH -> MEDIUM -> LOW; tag findings with `[codex]` if originating from Codex; tag `[ripple-expanded]` if Step 3.5 added `ripple_files`; tag `[cove_unverified]` if Step 3.5 budget exhausted before reaching it)
188
243
  4. `Needs Manual Confirmation`
189
244
  5. `False Positives Discarded` (with reason)
@@ -374,6 +374,7 @@ For each card, execute these phases in order:
374
374
  - If **FIXES NEEDED**: apply corrections to the tracker notes for this card (do NOT modify the backlog YAML). Carry the corrected requirements into the Phase 2 briefing.
375
375
  - If **PASS**: proceed.
376
376
  5. **Update tracker**: phase = "1-claim DONE", log codebase-architect key findings (1-2 lines) and plan-auditor result (PASS or corrections applied).
377
+ 5b. **Persist the architecture baseline for reuse (since v3.35.0)** — write the FULL codebase-architect findings (file paths, type signatures, patterns, high-risk paths) to `/tmp/arch-baseline-<CARD-ID>.md`. Phase 3.7's per-card `/codexreview` reuses this file instead of re-spawning `codebase-architect` (lean mode), so capture enough detail to ground a reviewer — not just the 1-2 tracker lines.
377
378
 
378
379
  ### Phase 2 — Implement (self-healing, up to 3 retries)
379
380
  6. **Update tracker**: phase = "2-implement".
@@ -1151,6 +1152,12 @@ skill's Phase 1 falls back to deriving Gherkin scenarios from
1151
1152
  - Check each invariant in the checklist above and flag any that are unmet
1152
1153
  - Flag any doc in "Related docs to check" that should be stale but isn't marked yet
1153
1154
  - Run your standard doc audit on all changed files
1155
+ - **Spec/docs-drift → bug lens (MANDATORY since v3.35.0)**: flag any place where the
1156
+ implementation contradicts a documented contract/spec in a way that can cause incorrect
1157
+ behavior (response shape diverging from `api/<module>.md`, field semantics diverging from
1158
+ `data-model.md`, etc.). This lens was previously exclusive to `/codexreview` agent #4; the
1159
+ per-card Phase 3.7 gate now skips that duplicate (lean mode), so THIS pass MUST carry it.
1160
+ Report as a doc finding with the conflicting code location + the doc it violates.
1154
1161
  ```
1155
1162
  Doc-reviewer collects findings WITHOUT making changes.
1156
1163
  14. **Obsidian Corpus Dispatch**: Parse section H from doc-reviewer findings. If `Trigger: YES`, dispatch the `obsidian-sync` agent (`.claude/agents/obsidian-sync.md`) with the listed paths after all fixes are applied (step 16). If `Trigger: NO`, skip. This is non-blocking -- do not wait for obsidian-sync to complete before proceeding.
@@ -1265,15 +1272,46 @@ Log the detector output in the tracker for traceability. **This step never short
1265
1272
 
1266
1273
  #### Step C — Invoke per-card `/codexreview` (ALWAYS)
1267
1274
 
1268
- For EVERY card (no conditional skip):
1275
+ For EVERY card (no conditional skip — the gate ALWAYS runs; only its DEPTH varies):
1276
+
1277
+ 0. **Select the review profile (deterministic — Review Profile Selector, since v3.35.0)** from
1278
+ signals ALREADY computed (no new thresholds):
1279
+ - **`light`** ⟺ Step A matched **zero** high-risk triggers **AND** this card's QA profile
1280
+ (Phase 3.5) ∈ {`skip`, `light`}.
1281
+ - **`full`** ⟺ otherwise (any trigger matched, OR QA ∈ {`balanced`, `deep`}).
1282
+
1283
+ `light` is **NOT** a skip: `/codexreview` still runs `code-reviewer` + Codex adversarial + the
1284
+ Step 3 false-positive gate. It drops only the breadth agents (qa-sentinel, api-perf-cost-auditor,
1285
+ doc-reviewer) and Step 3.5 CoVe — exactly the passes that yield ~nothing on a 0-trigger, QA-light
1286
+ diff. The BUG-0530 invariant ("never miss a blocker on a low-risk path") holds because the Codex
1287
+ adversarial pass — the one that catches those — always runs.
1288
+ **DATA GATE (BLOCKING for `light`)**: until the consumer's telemetry (`docs/metrics/` Fix
1289
+ Application Log) confirms that `light`-eligible cards have NOT historically produced verified
1290
+ BLOCKER/HIGH at FULL depth, hard-code `profile = full` here. Flip to the selector only after the
1291
+ data passes (no config key — change the line).
1292
+
1293
+ Then dump the card diff (reviewer grounding) and write the lean contract `/codexreview` consumes:
1294
+ ```bash
1295
+ cd <worktree-path>
1296
+ git diff develop...HEAD -- <card files from ownership map> > /tmp/diff-<CARD-ID>.txt 2>/dev/null \
1297
+ || git diff HEAD~1 > /tmp/diff-<CARD-ID>.txt
1298
+ ```
1299
+ Write `/tmp/codexreview-lean-<CARD-ID>.json`:
1300
+ ```json
1301
+ { "profile": "light|full", "arch_baseline_path": "/tmp/arch-baseline-<CARD-ID>.md",
1302
+ "diff_summary_path": "/tmp/diff-<CARD-ID>.txt", "skip_doc_reviewer": true }
1303
+ ```
1304
+ `skip_doc_reviewer` is ALWAYS true: Phase 3 already ran `doc-reviewer` on this diff (with the
1305
+ spec/docs-drift→bug lens since v3.35.0), so `/codexreview` agent #4 would be a duplicate.
1269
1306
 
1270
1307
  1. **Log gate invocation** in the tracker under `## Pre-Merge Codex Review`:
1271
1308
 
1272
1309
  ```
1273
1310
  ## Pre-Merge Codex Review
1274
1311
  - Triggered: unconditional (mandatory pre-merge gate)
1312
+ - Profile: light | full — reason: <triggers=0, qa=light | trigger #N matched | qa=deep>
1275
1313
  - Matched high-risk triggers: <list from detector, or "none">
1276
- - Action: invoking /codexreview <CARD-ID> (per-card, pre-merge gate)
1314
+ - Action: invoking /codexreview <CARD-ID> (per-card, pre-merge gate; lean contract written)
1277
1315
  ```
1278
1316
 
1279
1317
  2. **Invoke `/codexreview`** for the single card via the Skill tool:
@@ -1283,7 +1321,10 @@ For EVERY card (no conditional skip):
1283
1321
  args: <CARD-ID>
1284
1322
  ```
1285
1323
 
1286
- The orchestrator's `/codexreview` skill runs all 5 review agents (incl. adversarial Codex) + Step 3 false-positive gate + Step 3.5 cross-agent CoVe + Step 4 consolidated report. This is the same pipeline used post-batch in Phase 7 — here it runs per-card and BEFORE commit.
1324
+ `/codexreview` Step -0.5 consumes `/tmp/codexreview-lean-<CARD-ID>.json`: reuses the Phase-1
1325
+ architecture baseline (no re-spawn), drops the duplicate doc-reviewer, and for `profile: light`
1326
+ also drops qa-sentinel + api-perf-cost-auditor + Step 3.5 CoVe. Codex adversarial + Step 3
1327
+ FP-gate + Step 4 report ALWAYS run. (`full` = standard pipeline minus the duplicate doc-reviewer.)
1287
1328
 
1288
1329
  3. **Read the consolidated report** from `/tmp/codexreview-report-<TIMESTAMP>.md`. Extract:
1289
1330
  - Verified BLOCKER count
@@ -1359,6 +1400,21 @@ Never block the pipeline indefinitely — recover and continue per the rules abo
1359
1400
 
1360
1401
  Once ALL cards are committed in the worktree:
1361
1402
 
1403
+ > **Final-review scope gate (deterministic, since v3.35.0)** — the per-card Phase 3.7 `/codexreview`
1404
+ > gate has ALREADY reviewed every card individually before its commit. The final review exists to
1405
+ > catch what a per-card pass structurally cannot: **cross-card interactions**. Branch on batch size:
1406
+ >
1407
+ > - **Single card (N=1)**: SKIP Steps F.1–F.4 (the lone card is byte-identical to what Phase 3.7
1408
+ > already passed — re-review adds nothing). Log:
1409
+ > `"Final review (F.1–F.4) skipped — single card already passed /codexreview at Phase 3.7 (PASS), code unchanged since commit"`.
1410
+ > Then jump to **Step F.5 and RUN the final build** (`npm run lint && npx tsc --noEmit && npm run build`)
1411
+ > — that gate is NOT skipped.
1412
+ > - **Multiple cards (N>1)**: run F.1–F.5 but **scope the review to the cross-card surface only**
1413
+ > (computed, not discretionary) — see F.1 step 4. If that surface is empty, log
1414
+ > `"cross-card review: no shared surface (intersection=∅, depends_on=∅) — F.3 limited to integration sanity"`
1415
+ > and run a minimal integration pass (build + type-check across the union) instead of a full
1416
+ > per-line re-review. This is a computed scope reduction, NEVER a model-decided skip.
1417
+
1362
1418
  ### Step F.1 — Resolve scope
1363
1419
 
1364
1420
  1. **Read the tracker file** to get the full picture: card IDs, files changed, commit hashes.
@@ -1368,7 +1424,11 @@ Once ALL cards are committed in the worktree:
1368
1424
  git diff --name-only <base-branch>...HEAD
1369
1425
  ```
1370
1426
  3. Read each card's backlog YAML to collect `acceptance_criteria`, `files_likely_touched`.
1371
- 4. Build `review_scope_files` as union of card-indicated files + git-touched files.
1427
+ 4. Build `review_scope_files` as union of card-indicated files + git-touched files. **For N>1 (per
1428
+ the Final-review scope gate above), then REDUCE `review_scope_files` to the cross-card subset**:
1429
+ files appearing in ≥2 cards' File Ownership Map entries ∪ files of any `depends_on`-linked card
1430
+ pair. Record both the full union and the reduced set in the tracker; downstream F.3 reviews the
1431
+ reduced set.
1372
1432
 
1373
1433
  ### Step F.2 — Architecture baseline
1374
1434
 
@@ -2020,6 +2080,11 @@ Return: file paths, type signatures, existing patterns. Max 30 lines.
2020
2080
 
2021
2081
  This is the ONLY context the orchestrator accumulates per group. After passing it to the coders, it can be purged.
2022
2082
 
2083
+ **Persist for review reuse (since v3.35.0)** — also write these findings to
2084
+ `/tmp/arch-baseline-group-<FIRST-CARD-ID>.md`. D.4b's per-card `/codexreview` lean contract points
2085
+ its `arch_baseline_path` at this file, so the review reuses the group baseline instead of re-spawning
2086
+ `codebase-architect`.
2087
+
2023
2088
  #### Step B: Spawn parallel coder agents
2024
2089
 
2025
2090
  For each card in the current group, spawn a coder agent using the Agent tool. ALL agents for the group MUST be spawned in a **SINGLE message** (multiple Agent tool calls) to run truly in parallel.
@@ -2112,7 +2177,7 @@ After ALL agents in the group complete successfully:
2112
2177
 
2113
2178
  1. **D.1 — Build verification (group)** — Run `npm run build` in the worktree to verify combined changes compile. If build fails, identify which card's changes broke it (from `git diff` per card), spawn a targeted fix-coder for those files only.
2114
2179
 
2115
- 2. **D.2 — Combined static review (group)** — Invoke **code-reviewer** + **doc-reviewer** IN PARALLEL (read-only, same as Phase 3) across ALL changes in the group. This is ONE combined review, not per-card efficient and catches cross-card issues. Per-card doc-review still runs in D.4a.
2180
+ 2. **D.2 — Combined static review (group)** — Invoke **code-reviewer** + **doc-reviewer** IN PARALLEL (read-only) across ALL changes in the group. ONE combined pass — catches cross-card issues. The doc-reviewer MUST **attribute every finding to a specific card** (by file → ownership map) and apply the full Phase 3 mandate INCLUDING the spec/docs-drift→bug lens (since v3.35.0), so this single pass satisfies each card's doc gate. D.4a consumes these per-card findings (no second doc-reviewer spawn).
2116
2181
 
2117
2182
  3. **D.3 — Apply fixes (group)** — If D.2 findings exist, spawn ONE fix-coder to apply all fixes in a single pass. Run build + lint after.
2118
2183
 
@@ -2124,9 +2189,9 @@ After ALL agents in the group complete successfully:
2124
2189
 
2125
2190
  4. **D.4 — QA gate (group)** — Select the HIGHEST QA profile across all cards in the group (e.g., if one card is BALANCED and another is DEEP, use DEEP). Invoke **qa-sentinel** once for the group.
2126
2191
 
2127
- 4a. **D.4a — Phase 3 Doc Review per-card** — For EACH card in the group, invoke `doc-reviewer` scoped to that card's File Ownership Map + card YAML (mirrors Phase 3 of the sequential path). If findings exist, spawn a targeted fix-coder for that card only. This is in addition to the combined D.2 doc-reviewer pass D.2 catches cross-card issues, D.4a verifies each card meets its own doc gate before commit.
2192
+ 4a. **D.4a — Per-card doc gate (consumes D.2, since v3.35.0)** — Do NOT re-spawn `doc-reviewer`. For EACH card in the group, take that card's doc findings from D.2's per-card-attributed output; if any exist, spawn a targeted fix-coder for that card only. (Previously D.4a re-ran doc-reviewer per card a duplicate of D.2; collapsed to a single attributed D.2 pass. D.4b's per-card `/codexreview` also skips its doc-reviewer via the lean contract, so doc-review now runs once per group instead of ~3× per card.)
2128
2193
 
2129
- 4b. **D.4b — Pre-Merge Codex Review Gate (per-card, MANDATORY — UNCONDITIONAL)** — Invoke `/codexreview` for EACH card in the group, one at a time, BEFORE any commit. This mirrors Phase 3.7 of the sequential path and is non-skippable regardless of file paths or perceived risk. Apply the same fix sub-loop: if the consolidated report shows verified BLOCKER/HIGH findings, spawn a fix-coder and re-run `/codexreview <CARD-ID>` (max 2 retries per card). If still BLOCKER/HIGH after retries, ask the user before proceeding to D.5. The D.5 commits MUST NOT happen until every card in the group has a PASS verdict (or explicit user override via `AskUserQuestion`). Log results in the tracker under `## Pre-Merge Codex Review` per card.
2194
+ 4b. **D.4b — Pre-Merge Codex Review Gate (per-card, MANDATORY — UNCONDITIONAL)** — Invoke `/codexreview` for EACH card in the group, one at a time, BEFORE any commit. This mirrors Phase 3.7 of the sequential path and is non-skippable regardless of file paths or perceived risk. Apply the same fix sub-loop: if the consolidated report shows verified BLOCKER/HIGH findings, spawn a fix-coder and re-run `/codexreview <CARD-ID>` (max 2 retries per card). If still BLOCKER/HIGH after retries, ask the user before proceeding to D.5. The D.5 commits MUST NOT happen until every card in the group has a PASS verdict (or explicit user override via `AskUserQuestion`). Log results in the tracker under `## Pre-Merge Codex Review` per card. **Lean + profile (since v3.35.0)**: before each card's `/codexreview`, apply the same Review Profile Selector and write the same `/tmp/codexreview-lean-<CARD-ID>.json` contract as sequential Phase 3.7 Step C — `arch_baseline_path` pointing at `/tmp/arch-baseline-group-<FIRST-CARD-ID>.md`, `skip_doc_reviewer: true`, and `profile: light|full` from (0 triggers + QA ∈ {skip,light}). LIGHT still runs Codex adversarial + code-reviewer + FP-gate (subject to the same DATA GATE: keep `full` until telemetry confirms).
2130
2195
 
2131
2196
  5. **D.5 — Commit** — One commit per card using explicit staging from the ownership map. **NO stash in worktrees** (stashes are globally shared via `refs/stash` — see Phase 4 WORKTREE COMMIT RULE):
2132
2197
  ```bash
@@ -2178,7 +2243,7 @@ Before starting group N, verify ALL cards in groups 0..N-1 are status: done. If
2178
2243
 
2179
2244
  ### Post-batch (same as sequential mode)
2180
2245
 
2181
- After all groups are complete, run the same Final Review, Phase 6 (merge), and Phase 7 (production readiness) as documented above. No changes needed for these phases.
2246
+ After all groups are complete, run the same Final Review, Phase 6 (merge), and Phase 7 (production readiness) as documented above. Team mode is always N>1, so the Final Review applies its deterministic **cross-card-only scope** (per the Final-review scope gate) — not a full per-line re-review.
2182
2247
 
2183
2248
  ---
2184
2249
 
@@ -411,8 +411,32 @@ else
411
411
  echo "PORT=$PORT" >> .env.local
412
412
  fi
413
413
 
414
- # 5. Verify husky hooks work (they share via .git/commondir)
415
- git config core.hooksPath || true
414
+ # 5. ASSERT git hooks are ACTIVE not just readable.
415
+ # The worktree shares core.hooksPath via .git/commondir, so the same
416
+ # resolution applies here as in the main checkout. Reading the value
417
+ # (the old `git config core.hooksPath || true`) is NOT enough: it never
418
+ # catches the failure mode where core.hooksPath points at an empty dir
419
+ # while the repo's versioned hooks live elsewhere (pre-commit / pre-push
420
+ # silently inactive). Detect the versioned-hooks dir and assert it is served.
421
+ HOOK_SRC=""
422
+ for d in .husky .githooks scripts/git-hooks githooks; do
423
+ if [ -d "$d" ] && ls "$d" 2>/dev/null | grep -qE '^(pre-commit|pre-push|commit-msg)$'; then
424
+ HOOK_SRC="$d"; break
425
+ fi
426
+ done
427
+ if [ -n "$HOOK_SRC" ]; then
428
+ ACTIVE="$(git config --get core.hooksPath || echo .git/hooks)"
429
+ # Lean literal-string match (HOOK_SRC is relative). Full path resolution +
430
+ # canonicalization lives in `baldart doctor` (src/utils/githooks.js); an
431
+ # absolute core.hooksPath here would warn spuriously — rare, run doctor to confirm.
432
+ case "$ACTIVE" in
433
+ "$HOOK_SRC"|"$HOOK_SRC"/*|.husky/_) : ;; # active dir serves the versioned hooks (incl. husky v9 .husky/_)
434
+ *)
435
+ echo "⚠️ WARNING: git hooks INATTIVI — gli hook versionati sono in '$HOOK_SRC' ma core.hooksPath = '$ACTIVE'." >&2
436
+ echo " Esegui per riattivarli: git config core.hooksPath $HOOK_SRC" >&2
437
+ ;;
438
+ esac
439
+ fi
416
440
  ```
417
441
 
418
442
  ### 5. Verify baseline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.33.1",
3
+ "version": "3.35.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"
@@ -30,6 +30,7 @@ const GitUtils = require('../utils/git');
30
30
  const UI = require('../utils/ui');
31
31
  const State = require('../utils/state');
32
32
  const Hooks = require('../utils/hooks');
33
+ const GitHooks = require('../utils/githooks');
33
34
  const LspInstaller = require('../utils/lsp-installer');
34
35
  const UpdateNotifier = require('../utils/update-notifier');
35
36
  const cliPackageJson = require('../../package.json');
@@ -234,6 +235,16 @@ async function detectState(cwd, opts = {}) {
234
235
  }
235
236
  } catch (_) {}
236
237
 
238
+ // ---- Git-hooks health (since v3.34.0) ------------------------------
239
+ // Assert that the repo's versioned git hooks (pre-commit/pre-push/…) are
240
+ // actually being run — i.e. `core.hooksPath` resolves to the directory
241
+ // that holds them. A drift here (e.g. core.hooksPath=.git/hooks while the
242
+ // hooks live in scripts/git-hooks/) silently disables every hook. Distinct
243
+ // from the BALDART `.claude/settings.json` hooks probed above. Fully
244
+ // fail-safe: `checked:false` when no managed hooks exist or on any error.
245
+ state.gitHooks = { checked: false, ok: true };
246
+ try { state.gitHooks = GitHooks.getStatus(cwd); } catch (_) {}
247
+
237
248
  // ---- Agent symlink integrity (since v3.20.0) -----------------------
238
249
  // Source of truth for the BALDART agent list is the framework filesystem
239
250
  // directory itself (`.framework/framework/.claude/agents/*.md`). For each
@@ -503,6 +514,28 @@ function planActions(state) {
503
514
  });
504
515
  }
505
516
 
517
+ // Git-hooks health (since v3.34.0). ADVISORY ONLY — the action's run() never
518
+ // mutates `core.hooksPath` (a wrong heuristic + auto-fix is worse than the
519
+ // original silent-inactive bug). It surfaces the WARN + the exact remediation
520
+ // command so the user applies it deliberately. Present as an action (rather
521
+ // than a bare table WARN) so `doctor` doesn't contradict it with "Nothing to
522
+ // do". Safe to run in --auto since it only prints.
523
+ if (state.gitHooks && state.gitHooks.checked && !state.gitHooks.ok) {
524
+ actions.push({
525
+ key: 'githooks-health',
526
+ label: state.gitHooks.inactive
527
+ ? `Git hooks INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} are not being run`
528
+ : `Git hooks not executable — ${state.gitHooks.nonExecutable.join(', ')}`,
529
+ why: `${state.gitHooks.detail}. Fix: ${state.gitHooks.fixCommand}`,
530
+ autoOk: true, // advisory print-only; run() does NOT mutate config
531
+ run: () => {
532
+ UI.warning(state.gitHooks.detail);
533
+ UI.info('Run to re-activate the repo\'s versioned git hooks:');
534
+ console.log(` ${state.gitHooks.fixCommand}`);
535
+ },
536
+ });
537
+ }
538
+
506
539
  // Agent symlink integrity (since v3.20.0). Re-runs the agent merge loop to
507
540
  // recreate any missing per-agent symlink. Calls `SymlinkUtils.mergeAgents`
508
541
  // directly rather than dispatching to `update` — we want only the symlink
@@ -787,6 +820,29 @@ function renderDiagnostic(state) {
787
820
  console.log(` • pre-v3.18: ${state.hooksLegacy.join(', ')}`);
788
821
  }
789
822
 
823
+ // Git-hooks health (v3.34.0+). Silent when no versioned-hooks dir exists
824
+ // (`checked:false`) — most consumers have no managed git hooks and must see
825
+ // zero noise here.
826
+ if (state.gitHooks && state.gitHooks.checked) {
827
+ if (state.gitHooks.ok) {
828
+ console.log(statusLine(
829
+ 'Git hooks',
830
+ `${state.gitHooks.source} active (${state.gitHooks.expectedHooks.join(', ')})`,
831
+ 'ok'
832
+ ));
833
+ } else if (state.gitHooks.inactive) {
834
+ console.log(statusLine(
835
+ 'Git hooks',
836
+ `INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} not run by core.hooksPath`,
837
+ 'warn'
838
+ ));
839
+ console.log(` • fix: ${state.gitHooks.fixCommand}`);
840
+ } else {
841
+ console.log(statusLine('Git hooks', state.gitHooks.detail, 'warn'));
842
+ console.log(` • fix: ${state.gitHooks.fixCommand}`);
843
+ }
844
+ }
845
+
790
846
  if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
791
847
  console.log(statusLine(
792
848
  '.gitignore',
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fixture-driven test for the git-hooks active-vs-expected decision.
4
+ *
5
+ * Run: node src/utils/__tests__/githooks-active.test.js
6
+ * Exits 0 on success, 1 on any mismatch (CI-friendly).
7
+ *
8
+ * `isActiveServing` is the one decision that, if wrong, silently invalidates
9
+ * the entire git-hooks health check (src/utils/githooks.js). It is pure (path
10
+ * logic only) so it is fully unit-testable without a live repo — covering the
11
+ * husky-version branches that fs/git probing can't easily exercise.
12
+ */
13
+ const path = require('path');
14
+ const { isActiveServing } = require('../githooks');
15
+
16
+ // Absolute roots so the fixtures don't depend on cwd. Normalize through
17
+ // path.resolve the same way the implementation does.
18
+ const ROOT = path.resolve('/repo');
19
+ const j = (...p) => path.join(ROOT, ...p);
20
+
21
+ const FIXTURES = [
22
+ // ─── the mayo bug: core.hooksPath=.git/hooks, hooks in scripts/git-hooks ──
23
+ { active: j('.git', 'hooks'), expected: j('scripts', 'git-hooks'), want: false, note: 'mayo: inactive' },
24
+
25
+ // ─── husky v9: active=.husky/_ wrappers, user hooks in .husky ─────────────
26
+ { active: j('.husky', '_'), expected: j('.husky'), want: true, note: 'husky v9 nested' },
27
+ // ─── husky v5-v8: core.hooksPath=.husky, hooks in .husky ──────────────────
28
+ { active: j('.husky'), expected: j('.husky'), want: true, note: 'husky v8 identical' },
29
+
30
+ // ─── scripts convention, correctly wired ─────────────────────────────────
31
+ { active: j('scripts', 'git-hooks'), expected: j('scripts', 'git-hooks'), want: true, note: 'scripts identical' },
32
+
33
+ // ─── .githooks convention, wired to default .git/hooks (inactive) ─────────
34
+ { active: j('.git', 'hooks'), expected: j('.githooks'), want: false, note: '.githooks inactive' },
35
+
36
+ // ─── reverse nesting (expected within active) also counts as serving ──────
37
+ { active: j('.husky'), expected: j('.husky', '_'), want: true, note: 'reverse nesting' },
38
+
39
+ // ─── sibling dirs sharing a prefix must NOT match (prefix-not-boundary) ───
40
+ { active: j('hooks'), expected: j('hooks-extra'), want: false, note: 'prefix not a path boundary' },
41
+
42
+ // ─── trailing-slash / non-normalized inputs normalize equal ──────────────
43
+ { active: j('.husky') + path.sep, expected: j('.husky'), want: true, note: 'trailing sep normalized' },
44
+ ];
45
+
46
+ let failures = 0;
47
+ for (const f of FIXTURES) {
48
+ const got = isActiveServing(f.active, f.expected);
49
+ const ok = got === f.want;
50
+ if (!ok) {
51
+ failures++;
52
+ console.error(`✗ ${f.note}: isActiveServing(${f.active}, ${f.expected}) = ${got}, expected ${f.want}`);
53
+ } else {
54
+ console.log(`✓ ${f.note}`);
55
+ }
56
+ }
57
+
58
+ if (failures) {
59
+ console.error(`\n${failures} failure(s).`);
60
+ process.exit(1);
61
+ }
62
+ console.log(`\nAll ${FIXTURES.length} fixtures passed.`);
63
+ process.exit(0);
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Git-hooks health check — **assert-active**, not read-only.
3
+ *
4
+ * NOT to be confused with `src/utils/hooks.js`: that module manages the Claude
5
+ * Code hooks registered in `.claude/settings.json`. THIS module inspects the
6
+ * repo's own *git* hooks (pre-commit, pre-push, …) and asserts they are
7
+ * actually being executed by git — i.e. that `core.hooksPath` resolves to the
8
+ * directory where the repo's versioned hooks live.
9
+ *
10
+ * **Why it exists** (since v3.34.0)
11
+ *
12
+ * A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the
13
+ * repo's real, versioned hooks lived in `scripts/git-hooks/`. Result: the
14
+ * pre-commit schema guard and pre-push were silently inactive for days, and
15
+ * the worktree-manager verify step (`git config core.hooksPath || true`) only
16
+ * *read* the value without asserting anything — so it never caught the drift.
17
+ * Same class as the LSP bug: the framework "verifies" by reading a proxy
18
+ * instead of asserting the real state. This moves it from read-only to
19
+ * assert-active.
20
+ *
21
+ * **Resolution of the active hooks dir** (the crux)
22
+ *
23
+ * `git rev-parse --git-path hooks` is NOT used: empirically it returns the
24
+ * configured value *relative and unresolved*, and historically some git
25
+ * versions ignored `core.hooksPath` for `--git-path` entirely. Instead we
26
+ * resolve explicitly:
27
+ * - `core.hooksPath` set → relative resolves against the working-tree
28
+ * top (`git rev-parse --show-toplevel`),
29
+ * absolute is used verbatim.
30
+ * - `core.hooksPath` unset → `<git rev-parse --git-dir>/hooks` (handles
31
+ * the worktree case where `.git` is a file).
32
+ *
33
+ * **Design invariants**
34
+ *
35
+ * - Fail-safe: every probe is wrapped; any internal error yields
36
+ * `{ checked: false, ok: true }` so the check NEVER blocks legitimate work
37
+ * and never produces a false WARN.
38
+ * - Silent when nothing to assert: a repo with no versioned-hooks directory
39
+ * returns `checked: false` (most consumers have no managed hooks).
40
+ * - No config flag: this is a universal, always-on health check auto-detected
41
+ * from the filesystem, so the schema-change-propagation rule does not apply.
42
+ * - WARN only: this module never mutates `core.hooksPath`. It reports a
43
+ * `fixCommand` string; applying it is the caller's (user's) decision.
44
+ */
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+ const { execFileSync } = require('child_process');
49
+
50
+ // Standard git hook filenames. A managed-hooks directory is recognized by
51
+ // containing at least one of these (husky places them at `.husky/<name>`,
52
+ // raw dirs at `<dir>/<name>`).
53
+ const STANDARD_HOOKS = [
54
+ 'pre-commit', 'pre-push', 'commit-msg', 'prepare-commit-msg',
55
+ 'pre-rebase', 'pre-merge-commit', 'post-checkout', 'post-commit',
56
+ 'post-merge', 'post-rewrite', 'pre-applypatch', 'applypatch-msg',
57
+ ];
58
+
59
+ // Candidate versioned-hooks directories, in priority order. The first that
60
+ // exists AND contains a standard-named hook wins. `scripts/git-hooks` and
61
+ // `githooks` are conventions (not universal) — we enumerate a small known set
62
+ // rather than guess.
63
+ const MANAGED_DIRS = [
64
+ { dir: '.husky', source: 'husky' },
65
+ { dir: '.githooks', source: 'githooks' },
66
+ { dir: 'scripts/git-hooks', source: 'scripts' },
67
+ { dir: 'githooks', source: 'githooks' },
68
+ ];
69
+
70
+ function git(cwd, args) {
71
+ return execFileSync('git', args, {
72
+ cwd,
73
+ encoding: 'utf8',
74
+ stdio: ['ignore', 'pipe', 'ignore'],
75
+ }).trim();
76
+ }
77
+
78
+ /**
79
+ * Find the repo's versioned-hooks directory, or null when none is managed.
80
+ * @returns {{ dir: string, abs: string, source: string, hooks: string[] } | null}
81
+ */
82
+ function detectExpected(cwd) {
83
+ for (const cand of MANAGED_DIRS) {
84
+ const abs = path.join(cwd, cand.dir);
85
+ let names = [];
86
+ try {
87
+ if (!fs.statSync(abs).isDirectory()) continue;
88
+ names = fs.readdirSync(abs).filter((f) => STANDARD_HOOKS.includes(f));
89
+ } catch (_) { continue; }
90
+ if (names.length) return { dir: cand.dir, abs, source: cand.source, hooks: names };
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Resolve the directory git will actually look in for hooks.
97
+ * @returns {{ configured: string | null, abs: string }}
98
+ */
99
+ function resolveActiveDir(cwd) {
100
+ let configured = null;
101
+ try { configured = git(cwd, ['config', '--get', 'core.hooksPath']) || null; } catch (_) {}
102
+
103
+ if (configured) {
104
+ if (path.isAbsolute(configured)) return { configured, abs: configured };
105
+ // Relative core.hooksPath resolves against the working-tree top, not cwd.
106
+ let top = cwd;
107
+ try { top = git(cwd, ['rev-parse', '--show-toplevel']); } catch (_) {}
108
+ return { configured, abs: path.resolve(top, configured) };
109
+ }
110
+
111
+ // Unset → <git-dir>/hooks. git-dir may be relative or a worktree gitdir.
112
+ let gitDir;
113
+ try { gitDir = git(cwd, ['rev-parse', '--git-dir']); } catch (_) { gitDir = path.join(cwd, '.git'); }
114
+ if (!path.isAbsolute(gitDir)) gitDir = path.resolve(cwd, gitDir);
115
+ return { configured: null, abs: path.join(gitDir, 'hooks') };
116
+ }
117
+
118
+ /**
119
+ * Resolve a path to its canonical (symlink-free) form so two paths computed
120
+ * from different bases compare correctly. The active dir (from git's
121
+ * realpath-resolved toplevel/git-dir) and the expected dir (from cwd) can
122
+ * otherwise differ purely by a symlinked ancestor — e.g. macOS `/var` →
123
+ * `/private/var`, or a symlinked repo/home path — yielding a false INACTIVE.
124
+ *
125
+ * Handles non-existent leaves (the active `.git/hooks` may not exist on a
126
+ * misconfigured repo): realpath the deepest existing ancestor, re-append the
127
+ * rest. Falls back to a plain resolve if nothing along the path is realpath-able.
128
+ */
129
+ function canonicalize(p) {
130
+ let dir = path.resolve(p);
131
+ const tail = [];
132
+ for (;;) {
133
+ try {
134
+ const real = fs.realpathSync(dir);
135
+ return tail.length ? path.join(real, ...tail) : real;
136
+ } catch (_) {
137
+ const parent = path.dirname(dir);
138
+ if (parent === dir) return path.resolve(p); // reached fs root
139
+ tail.unshift(path.basename(dir));
140
+ dir = parent;
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * PURE decision: is the active hooks dir serving the versioned hooks?
147
+ *
148
+ * True when the two dirs are identical OR one is nested within the other.
149
+ * The nesting case covers husky v9, which sets `core.hooksPath=.husky/_`
150
+ * (wrappers) while the user hooks live in `.husky/`. Exported for unit tests —
151
+ * this is the one decision that, if wrong, silently invalidates the whole check.
152
+ */
153
+ function isActiveServing(activeAbs, expectedAbs) {
154
+ const a = path.resolve(activeAbs);
155
+ const e = path.resolve(expectedAbs);
156
+ if (a === e) return true;
157
+ return a.startsWith(e + path.sep) || e.startsWith(a + path.sep);
158
+ }
159
+
160
+ /**
161
+ * Inspect the repo's git-hooks health. Fail-safe.
162
+ * @returns {{
163
+ * checked: boolean, ok: boolean, source: string|null, configured: string|null,
164
+ * activeDir: string|null, expectedDir: string|null, expectedHooks: string[],
165
+ * inactive: boolean, nonExecutable: string[], detail: string, fixCommand: string|null
166
+ * }}
167
+ */
168
+ function getStatus(cwd = process.cwd()) {
169
+ const result = {
170
+ checked: false,
171
+ ok: true,
172
+ source: null,
173
+ configured: null,
174
+ activeDir: null,
175
+ expectedDir: null,
176
+ expectedHooks: [],
177
+ inactive: false,
178
+ nonExecutable: [],
179
+ detail: '',
180
+ fixCommand: null,
181
+ };
182
+
183
+ try {
184
+ const expected = detectExpected(cwd);
185
+ if (!expected) return result; // no managed hooks → silent, ok
186
+
187
+ const active = resolveActiveDir(cwd);
188
+ result.checked = true;
189
+ result.source = expected.source;
190
+ result.configured = active.configured;
191
+ result.activeDir = active.abs;
192
+ result.expectedDir = expected.dir;
193
+ result.expectedHooks = expected.hooks;
194
+
195
+ // Canonicalize both ends before comparing — they're computed from different
196
+ // bases (git realpath vs cwd) and a symlinked ancestor would otherwise
197
+ // produce a false INACTIVE.
198
+ if (!isActiveServing(canonicalize(active.abs), canonicalize(expected.abs))) {
199
+ result.inactive = true;
200
+ result.ok = false;
201
+ result.fixCommand = `git config core.hooksPath ${expected.dir}`;
202
+ result.detail = `core.hooksPath risolve a ${active.abs} ma gli hook versionati sono in ${expected.dir}`;
203
+ return result;
204
+ }
205
+
206
+ // Executability assert — only for raw dirs git execs directly. Husky sources
207
+ // user hooks via its `_` wrappers and v9 does NOT require them to be +x, so
208
+ // asserting -x there would manufacture false positives.
209
+ if (expected.source !== 'husky') {
210
+ for (const h of expected.hooks) {
211
+ try { fs.accessSync(path.join(expected.abs, h), fs.constants.X_OK); }
212
+ catch (_) { result.nonExecutable.push(h); }
213
+ }
214
+ if (result.nonExecutable.length) {
215
+ result.ok = false;
216
+ result.detail = `hook non eseguibili (manca il bit -x): ${result.nonExecutable.join(', ')}`;
217
+ result.fixCommand = `chmod +x ${result.nonExecutable.map((h) => path.join(expected.dir, h)).join(' ')}`;
218
+ }
219
+ }
220
+
221
+ return result;
222
+ } catch (_) {
223
+ // Never block doctor / worktree verify on a probe failure.
224
+ return { ...result, checked: false, ok: true };
225
+ }
226
+ }
227
+
228
+ module.exports = { getStatus, isActiveServing };
229
+ // Exposed for completeness / advanced callers and unit tests.
230
+ module.exports.detectExpected = detectExpected;
231
+ module.exports.resolveActiveDir = resolveActiveDir;
232
+ module.exports.STANDARD_HOOKS = STANDARD_HOOKS;
233
+ module.exports.MANAGED_DIRS = MANAGED_DIRS;