From cf28eb075b306829d2644cb415734d93eb1d0d2c Mon Sep 17 00:00:00 2001 From: Nicolas Marino <26677779+NicolasMarino@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:33:43 -0500 Subject: [PATCH 1/3] feat(claude): keep local SDD rules alive across gentle-ai syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gentle-ai owns ~/.claude/skills/, ~/.claude/agents/ and most of CLAUDE.md, so local rules that outrank it had nowhere durable to live. Two different durability rules turn out to apply, and this change uses both. InjectMarkdownSection rebuilds CLAUDE.md as before + block + after for one gentle-ai marker at a time, so anything past the last marker survives a sync untouched. CLAUDE.local-overrides.md is appended there under its own sentinels. Agent definitions get no such courtesy — every file in the embed is overwritten wholesale — so `memory: project` on the six reviewer agents is re-applied instead. The rules themselves (L1-L7) come from measuring 10 archived SDD changes in reels-lab, where the pipeline found zero implementation defects by code review: every FAIL was a missing test, and the one change that needed six remediation rounds was a single requirement that never enumerated its entry points. They push that work upstream into spec and tasks rather than paying for it in verification rounds. reconcile.sh is idempotent and runs from three places, so drift never needs fixing by hand: the installer, the gentle-ai wrapper (after install, sync and upgrade — the only commands that cause drift), and --check for CI. It refuses to edit a CLAUDE.md whose gentle-ai markers are unbalanced, because that means an interrupted sync and the block would land inside a region about to be rewritten. Verification: - claude/test-overrides.sh: 22/22 pass, against a throwaway CLAUDE_DIR - claude/test-hooks.sh: 35/35 still pass - shellcheck --exclude=SC1091,SC2088: clean - scripts/claude.sh end to end: settings merge preserved SessionStart, UserPromptSubmit, autoMode and outputStyle - drift simulated by deleting `memory: project` from jd-judge-a: --check reported it, the wrapper restored it, file byte-identical to the original Rollback: delete claude/gentle-ai-overrides/ and claude/test-overrides.sh, revert the reconcile block in scripts/claude.sh and the wrapper in zsh/functions.zsh. Nothing else depends on them. --- README.md | 2 + claude/README.md | 69 +++++- .../CLAUDE.local-overrides.md | 69 ++++++ claude/gentle-ai-overrides/reconcile.sh | 196 ++++++++++++++++++ claude/settings.fragment.json | 3 +- claude/test-overrides.sh | 193 +++++++++++++++++ scripts/claude.sh | 20 ++ zsh/functions.zsh | 33 +++ 8 files changed, 579 insertions(+), 6 deletions(-) create mode 100644 claude/gentle-ai-overrides/CLAUDE.local-overrides.md create mode 100755 claude/gentle-ai-overrides/reconcile.sh create mode 100755 claude/test-overrides.sh diff --git a/README.md b/README.md index a990b75..0bf8955 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ This will install Homebrew, packages from the Brewfile, Oh My Zsh with plugins, **Claude Code** - Global `PreToolUse` guard hooks (secret scanning, destructive-command blocking) merged into `~/.claude/settings.json` — see [claude/README.md](claude/README.md) +- Local SDD rules that outrank `gentle-ai`'s own, with a reconciler that + restores them after every sync #### Installing a new tool ```bash diff --git a/claude/README.md b/claude/README.md index ecc39ff..61a1c3e 100644 --- a/claude/README.md +++ b/claude/README.md @@ -15,8 +15,10 @@ Installed by `scripts/claude.sh`, which is called from `install.sh`. | `hooks/bash-guard.sh` | `PreToolUse` on `Bash` — commit attribution, destructive commands, CLI preference | | `hooks/write-guard.sh` | `PreToolUse` on `Edit\|Write\|MultiEdit` — credential scan | | `statusline.sh` | Status line: model, branch, session cost, context and rate-limit budget | -| `settings.fragment.json` | The `hooks` and `statusLine` blocks merged into `~/.claude/settings.json` | +| `settings.fragment.json` | The `hooks`, `statusLine` and `subagentPromptCacheTtl` keys merged into `~/.claude/settings.json` | | `test-hooks.sh` | 35 golden inputs, both directions | +| `gentle-ai-overrides/` | Local SDD rules that outrank gentle-ai's own, and the reconciler that keeps them alive | +| `test-overrides.sh` | 22 golden inputs for the reconciler, run against a throwaway `CLAUDE_DIR` | ## bash-guard.sh @@ -65,10 +67,16 @@ passes everything — silently. ## What is deliberately NOT here Everything under `~/.claude` carrying a `` marker — -`skills/`, `agents/`, `commands/`, `output-styles/`, and the global -`CLAUDE.md` — is generated and owned by `gentle-ai sync`. Tracking it here -would vendor a package manager's output and fight the next sync. The Brewfile -declares `gentleman-programming/tap/gentle-ai`; the tool owns its own files. +`skills/`, `agents/`, `commands/`, `output-styles/`, and the marked regions of +the global `CLAUDE.md` — is generated and owned by `gentle-ai sync`. Tracking +it here would vendor a package manager's output and fight the next sync. The +Brewfile declares `gentleman-programming/tap/gentle-ai`; the tool owns its own +files. + +The exception is [`gentle-ai-overrides/`](#gentle-ai-overrides), which tracks +only what gentle-ai provably does not own: the region of `CLAUDE.md` past its +last marker, plus one frontmatter key the reconciler re-applies after each +sync. Nothing generated is copied into this repo. Session state (`projects/`, `sessions/`, `history.jsonl`, `security/`) is neither config nor portable. @@ -102,3 +110,54 @@ not pass. Remove its entry from `settings.fragment.json` and rerun `scripts/claude.sh`, or edit `~/.claude/settings.json` directly for a one-machine change. Hooks reload mid-session, so the change takes effect on the next tool call. + +## gentle-ai-overrides/ + +`gentle-ai` owns `~/.claude/skills/`, `~/.claude/agents/` and most of +`~/.claude/CLAUDE.md`. This directory is the one seam where local rules can +outrank it without forking the tool. + +### Why it can work at all + +`gentle-ai sync` rewrites `CLAUDE.md` through +`filemerge.InjectMarkdownSection`, which rebuilds the file as +`before + block + after` for one `` region at a time. +Anything outside every marker is never touched. So `CLAUDE.local-overrides.md` +is appended past the last marker under its own +`` sentinels, and survives on its own. + +Agent definitions get no such courtesy: every file in gentle-ai's embed is +overwritten wholesale on each install (`WriteFileAtomic`, no merge). The +`memory: project` key on the reviewer agents therefore has to be re-applied, +which is the reconciler's second job. + +### The rules + +`CLAUDE.local-overrides.md` carries L1–L7. They come from measuring 10 archived +SDD changes in `reels-lab`, where the pipeline found zero implementation +defects by code review: every FAIL was a missing test, and the one change that +needed six remediation rounds was a single requirement that never enumerated +its entry points. The rules push work upstream — test tasks per scenario, +entry points per validation requirement — instead of paying for it in +verification rounds. The raw table is in the knowledge vault under +`03_Resources/Tech/IA Engineering/`. + +### Keeping them alive + +```bash +gentle-ai-overrides/reconcile.sh # apply, print what changed +gentle-ai-overrides/reconcile.sh --quiet # apply, print only on change +gentle-ai-overrides/reconcile.sh --check # report drift, exit 1, change nothing +``` + +Three things run it, so it should never need running by hand: + +- `scripts/claude.sh`, on install. +- The `gentle-ai` wrapper in `zsh/functions.zsh`, after `install`, `sync` and + `upgrade` — the only commands that cause drift. It forwards every argument + and preserves the exit code. +- `--check` in CI or a pre-commit hook, if drift should ever fail a build. + +It refuses to touch a `CLAUDE.md` whose gentle-ai markers are unbalanced: that +means a sync was interrupted, and splicing into a half-written file would put +the block inside a region about to be rewritten. diff --git a/claude/gentle-ai-overrides/CLAUDE.local-overrides.md b/claude/gentle-ai-overrides/CLAUDE.local-overrides.md new file mode 100644 index 0000000..da2d1b9 --- /dev/null +++ b/claude/gentle-ai-overrides/CLAUDE.local-overrides.md @@ -0,0 +1,69 @@ +## Local SDD Overrides (take precedence over the gentle-ai blocks above) + +Derived from a measured baseline of 10 archived changes in `reels-lab` (2026-08-30). +Across that corpus the pipeline found **zero implementation defects by code review**: +all 3 FAIL verdicts were missing-test findings, and the single 6-round change was one +requirement that never enumerated its entry points. The raw table lives in the +knowledge vault under `03_Resources/Tech/IA Engineering/Auditoría SDD - Rondas hasta PASS.md`. + +### L1. One test task per spec scenario (`sdd-tasks`) + +`sdd-verify` marks any scenario without a test that actually ran as CRITICAL, however +correct the code is proven to be by other means. So `sdd-tasks` MUST emit an explicit +test task for every scenario in the delta specs, named with the scenario it covers. +A task list whose test tasks do not cover every scenario is incomplete — say so and fix +it before `sdd-apply`, rather than letting verify discover the gap later. + +### L2. Validation requirements MUST enumerate entry points (`sdd-spec`) + +A requirement of the form "X is validated" is incomplete. Every requirement that +constrains inbound data MUST carry one scenario per entry surface that can reach the +code path — CLI command, worker/queue boundary, HTTP handler, library API, each named +explicitly. Enumerate the surfaces by searching the codebase for call sites, never from +the proposal's prose alone. + +### L3. Tiered preflight (overrides the four-question hard gate) + +The `SDD Session Preflight` hard gate is scaled to what the command can actually do: + +| Command | Preflight collected | +| --- | --- | +| `/sdd-status`, `/sdd-explore` | none — read-only, no artifacts, no PR | +| `/sdd-new`, `/sdd-ff`, `propose` → `tasks` | pace + artifact store only | +| `/sdd-apply` and beyond | all four, asked once the tasks forecast exists | + +Collect the later groups when first needed and cache them, in one `AskUserQuestion` +call per tier. Never block a read-only command on a delivery decision. + +### L4. Fan out `sdd-explore` + +Exploration is breadth-first and read-only, so run it as three scoped explorers in +parallel — current state, prior art, constraints/entry points — then synthesize one +report. No file conflicts are possible. + +### L5. Partitioned apply, and only when the partition is proven + +`sdd-tasks` declares work units with the exact list of files each one writes. Units +whose file sets are disjoint may run as parallel writers in the same tree; any unit +that shares a file with another, or declares a dependency, stays serial. Never +parallelize writers on an unproven partition — two agents on one file overwrite each +other, which costs a whole run rather than saving one. + +Verification of a unit may overlap with implementation of the next: it runs against +that unit's own frozen candidate, which the receipt machinery already guarantees. + +### L6. Agent teams stay per-session + +Never add `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` to `settings.json`. Enabling it makes +every subagent Claude names launch as a full teammate at 3–5× the tokens, including in +delegation never framed as team work. Enable it on the command line for a session that +specifically wants debate — a bug with an unclear root cause, an architecture review. +Default delegation stays on subagents. + +### L7. Classify every CRITICAL by origin + +When `sdd-verify` raises a CRITICAL, state its origin: `coverage` (the code is correct +but no test that ran covers the scenario), `spec_ambiguity` (the requirement was +under-specified), or `implementation` (the code is actually wrong). Do not default to +`implementation`. That classification is the measurement the rules above were tuned on, +and it is what keeps the next tuning honest instead of guessed. diff --git a/claude/gentle-ai-overrides/reconcile.sh b/claude/gentle-ai-overrides/reconcile.sh new file mode 100755 index 0000000..e33c22c --- /dev/null +++ b/claude/gentle-ai-overrides/reconcile.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash + +# Reconciles the local SDD overrides against whatever gentle-ai last installed. +# +# WHY THIS EXISTS +# `gentle-ai install|sync|upgrade` rewrites everything it ships. Two different +# durability rules apply, and the split is why this script has two jobs: +# +# * ~/.claude/CLAUDE.md — gentle-ai only replaces the regions between its own +# `` / `` markers +# (internal/components/filemerge/section.go :: InjectMarkdownSection rebuilds +# the file as before + block + after). Anything outside every marker survives, +# so the overrides block is appended at EOF under its own sentinels. +# +# * ~/.claude/agents/*.md — every file that ships in gentle-ai's embed is +# overwritten wholesale on each install (WriteFileAtomic per file, no merge). +# The reviewer agents therefore need `memory: project` re-applied every time. +# +# MODES +# reconcile.sh apply, printing what changed +# reconcile.sh --quiet apply, printing only on change or error +# reconcile.sh --check report drift and exit 1; change nothing + +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +BLOCK_SRC="$SCRIPT_DIR/CLAUDE.local-overrides.md" + +CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}" +CLAUDE_MD="$CLAUDE_DIR/CLAUDE.md" +AGENTS_DIR="$CLAUDE_DIR/agents" + +OPEN='' +CLOSE='' + +# Reviewer agents that benefit from cross-session project memory. Judges stay +# blind to *each other* — separate memory dirs — never to the codebase history. +REVIEWERS=( + jd-judge-a + jd-judge-b + review-risk + review-readability + review-reliability + review-resilience +) + +MODE=apply +case "${1:-}" in + --check) MODE=check ;; + --quiet) MODE=quiet ;; + '') ;; + *) printf 'usage: reconcile.sh [--check|--quiet]\n' >&2; exit 2 ;; +esac + +changed=0 +drift=() + +say() { [ "$MODE" = quiet ] || printf '%s\n' "$1"; } +note() { printf '%s\n' "$1"; } + +[ -f "$BLOCK_SRC" ] || { note "reconcile: missing $BLOCK_SRC"; exit 1; } + +# -------------------------------------------------------------------------- +# 1. CLAUDE.md overrides block +# -------------------------------------------------------------------------- + +if [ ! -f "$CLAUDE_MD" ]; then + say "reconcile: no $CLAUDE_MD yet — skipping the overrides block" +else + desired="$OPEN +$(cat "$BLOCK_SRC") +$CLOSE" + + current="" + if rg -qNF "$OPEN" "$CLAUDE_MD"; then + current="$(awk -v o="$OPEN" -v c="$CLOSE" ' + $0 == o { inside = 1 } + inside { print } + $0 == c { inside = 0 } + ' "$CLAUDE_MD")" + fi + + if [ "$current" = "$desired" ]; then + say "ok CLAUDE.md overrides block up to date" + elif [ "$MODE" = check ]; then + drift+=("CLAUDE.md overrides block ${current:+stale}${current:-absent}") + else + # Refuse to splice into a file whose gentle-ai markers are unbalanced — + # that means a sync was interrupted, and appending would land inside a + # half-written region. + opens=$(rg -cN '^$' "$CLAUDE_MD" || true) + closes=$(rg -cN '^$' "$CLAUDE_MD" || true) + if [ "${opens:-0}" != "${closes:-0}" ]; then + note "reconcile: ERROR — $CLAUDE_MD has ${opens:-0} gentle-ai open markers and ${closes:-0} close markers." + note " Refusing to edit a file mid-sync. Re-run 'gentle-ai sync', then this script." + exit 1 + fi + + tmp="$CLAUDE_MD.reconcile.tmp" + if [ -n "$current" ]; then + # Replace in place, keeping the block wherever it already sits. + # + # Split into head/tail and reassemble rather than substituting inside awk: + # BSD awk rejects a multi-line string passed through -v ("newline in + # string"), so the block can never travel as an awk variable. + awk -v o="$OPEN" '$0 == o { exit } { print }' "$CLAUDE_MD" > "$tmp" + printf '%s\n' "$desired" >> "$tmp" + awk -v c="$CLOSE" 'seen { print } $0 == c { seen = 1 }' "$CLAUDE_MD" >> "$tmp" + else + # First install: append at EOF, which is outside every gentle-ai region. + { cat "$CLAUDE_MD"; printf '\n'; printf '%s\n' "$desired"; } > "$tmp" + fi + + # A splice that lost the gentle-ai markers means the awk went wrong. + newopens=$(rg -cN '^$' "$tmp" || true) + if [ "${newopens:-0}" != "${opens:-0}" ]; then + rm -f "$tmp" + note "reconcile: ERROR — splice would have dropped gentle-ai markers. Left $CLAUDE_MD untouched." + exit 1 + fi + + command mv -f "$tmp" "$CLAUDE_MD" + say "patched CLAUDE.md overrides block" + changed=1 + fi +fi + +# -------------------------------------------------------------------------- +# 2. `memory: project` on the reviewer agents +# -------------------------------------------------------------------------- + +for name in "${REVIEWERS[@]}"; do + file="$AGENTS_DIR/$name.md" + + if [ ! -f "$file" ]; then + say "skipped $name (not installed)" + continue + fi + + if rg -qN '^memory:' "$file"; then + say "ok $name (memory already set)" + continue + fi + + if [ "$MODE" = check ]; then + drift+=("$name is missing 'memory: project'") + continue + fi + + # Insert before the closing `---` of the YAML frontmatter, i.e. the second + # line that is exactly `---`. + awk ' + BEGIN { fence = 0; done = 0 } + /^---[[:space:]]*$/ { + fence++ + if (fence == 2 && !done) { print "memory: project"; done = 1 } + print; next + } + { print } + ' "$file" > "$file.tmp" + + # Refuse a result that lost the frontmatter or failed to grow by exactly the + # inserted line. + if ! head -1 "$file.tmp" | rg -qN '^---[[:space:]]*$' \ + || ! rg -qN '^memory: project$' "$file.tmp" \ + || [ "$(wc -l < "$file.tmp")" -ne "$(( $(wc -l < "$file") + 1 ))" ]; then + rm -f "$file.tmp" + note "reconcile: WARNING — patch for $name produced an unexpected result; left untouched" + continue + fi + + command mv -f "$file.tmp" "$file" + say "patched $name (memory: project)" + changed=1 +done + +# -------------------------------------------------------------------------- +# Result +# -------------------------------------------------------------------------- + +if [ "$MODE" = check ]; then + if [ ${#drift[@]} -eq 0 ]; then + printf 'reconcile: no drift\n' + exit 0 + fi + printf 'reconcile: %d item(s) drifted\n' "${#drift[@]}" + printf ' - %s\n' "${drift[@]}" + printf "run 'dotfiles/claude/gentle-ai-overrides/reconcile.sh' to restore\n" + exit 1 +fi + +if [ "$changed" = 1 ]; then + note "reconcile: local SDD overrides restored" +else + say "reconcile: nothing to do" +fi diff --git a/claude/settings.fragment.json b/claude/settings.fragment.json index 54023e6..32003ea 100644 --- a/claude/settings.fragment.json +++ b/claude/settings.fragment.json @@ -39,5 +39,6 @@ "type": "command", "command": "~/.claude/statusline.sh", "refreshInterval": 30 - } + }, + "subagentPromptCacheTtl": "1h" } diff --git a/claude/test-overrides.sh b/claude/test-overrides.sh new file mode 100755 index 0000000..432bb2d --- /dev/null +++ b/claude/test-overrides.sh @@ -0,0 +1,193 @@ +#!/bin/bash +# The assertion helpers are invoked indirectly through "${@:3}" in want(), so +# the linter cannot see the call sites. SC2317 and SC2329 are the same +# "unreachable function" finding, renumbered between versions; pre-commit pins +# an older one than Homebrew ships. +# shellcheck disable=SC2317,SC2329 + +# Golden-input tests for gentle-ai-overrides/reconcile.sh. +# +# The reconciler edits ~/.claude/CLAUDE.md, so every case runs against a +# throwaway CLAUDE_DIR instead. That is what the CLAUDE_DIR override in +# reconcile.sh exists for — CI has no ~/.claude, and a test that mutated the +# real one would be worse than no test. + +HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +R="$HERE/gentle-ai-overrides/reconcile.sh" +BLOCK="$HERE/gentle-ai-overrides/CLAUDE.local-overrides.md" +SENTINEL='' +fail=0 + +# Assertions take the command itself rather than a bare $?, so the exit status +# they read is always the one they asked for. +want() { # want