From 09f5b28d993228d9ca948618061fa4d4eb20bd46 Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Mon, 29 Jun 2026 17:41:24 +0200 Subject: [PATCH 1/2] feat(doc-sync): MINOR-shape oracle + fail-loud commit (strengthen MINOR gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) lib/doc-shape.sh — deterministic shape oracle re-checks each LLM-classified MINOR patch before the silent auto-commit; a shape-suspect patch (added heading / oversize / new file / non-doc) escalates to the EXISTING SIGNIFICANT gate. Genuine MINOR still auto-commits (zero friction, BDR-036 preserved). A structural floor under the LLM call, not a semantic SIGNIFICANT-detector (BDR-040). (2) lib/doc-commit.sh — guard the commit itself: a rejected git commit (pre-commit hook / protected branch / signing) now fails loud with exit 5 + empty stdout, instead of a false "committed" + the previous HEAD's hash + exit 0 that left docs silently uncommitted (LRN-071, 3rd occurrence of the swallowed-commit pattern after LRN-066 and LRN-068/BLK-012). Wired doc-syncer STEP A4 (whole-set escalation: no=revert all, select=keep subset) + doc-commit.md (rc-5 consumer row + ACKNOWLEDGMENTS coherence). TDD RED->GREEN: run-doc-commit.sh 32/32, run-doc-shape.sh 19/19, behavioral C/D. shellcheck clean. Branch-guard (3) deferred. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01C6bUdvHnajCNzgVQefZowj --- CHANGELOG.md | 2 + agents/doc-syncer.md | 23 ++++- lib/doc-commit.md | 9 +- lib/doc-commit.sh | 23 ++++- lib/doc-shape.sh | 127 ++++++++++++++++++++++++ lib/tests/run-doc-behavioral.md | 43 ++++++++- lib/tests/run-doc-commit.sh | 16 +++ lib/tests/run-doc-shape.sh | 166 ++++++++++++++++++++++++++++++++ 8 files changed, 398 insertions(+), 11 deletions(-) create mode 100755 lib/doc-shape.sh create mode 100644 lib/tests/run-doc-shape.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6b706..36067f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ### Added - Coupled-capitalize: dev flows (feat / hotfix / bugfix / commit-change, ship-feature, init-project) auto-commit their memory in the same breath, via shared `lib/capitalize-commit.md` + `lib/memory-commit.sh` (surgical — `.claude/memory` + `.claude/tasks` only, never `git add -A`) - Coupled doc-sync: dev flows (feat / bugfix / hotfix, ship-feature, init-project) auto-commit the public docs `doc-syncer` patches, via shared `lib/doc-commit.md` + `lib/doc-commit.sh` (surgical — only the patched files, never `git add -A`, never `.claude/` / `CLAUDE.md`; refuses an out-of-scope path loudly with exit 4). `doc-syncer` surfaces `PATCHED_FILES` (one path per line) as the handoff +- `lib/doc-shape.sh` — deterministic MINOR-shape oracle for `doc-syncer` AUTO MODE: re-checks each LLM-classified MINOR patch (added-heading / size / new-file / non-doc envelope, thresholds env-overridable) and escalates a shape-suspect patch to the existing SIGNIFICANT gate instead of silently auto-committing it. A structural floor under the LLM's classification, not a blocking gate (genuine MINOR still auto-commits, zero friction); catches structural/size significance, not semantic - `/audit-delta` — recurring multi-axis audit (norms / bugs / dead code / security) scoped to changes since last run, with per-axis SHA markers - `/capitalize` — flush uncapitalized context to the memory registries before `/clear` or `/compact` - `/prune-memory` — curate and compress the `.claude/memory/` registries @@ -41,6 +42,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). - Caveman plugin always-on integration purged — plugin disabled + uninstalled; SessionStart/UserPromptSubmit hooks, standalone hook files, `install-plugins.sh` STEP 5.5, `update-all.sh` refresh step, `plugins.lock.json` entry, `doctor.sh` checks, and docs removed. On a subscription plan its ~75% output-token compression has no cost benefit, and the always-on hooks added friction on validation gates + client deliverables. The unrelated memory-registry terse-format convention is kept. ### Fixed +- `lib/doc-commit.sh` no longer masks a rejected `git commit` as success: a pre-commit hook / protected branch / signing failure now fails loud with exit 5 and empty stdout (was: false "committed" + the previous HEAD's hash + exit 0, leaving docs silently uncommitted on a dirty tree) - Numerous skill/agent fixes across darwin optimization rounds (geo-analyzer, onboard, init-project, analyzer, plugin-check, prune-memory, …) ## [3.4.0] — 2026-04-15 diff --git a/agents/doc-syncer.md b/agents/doc-syncer.md index b443d64..69c5bbe 100644 --- a/agents/doc-syncer.md +++ b/agents/doc-syncer.md @@ -792,9 +792,26 @@ Categorize: - **NONE** → exit completely silent. No output (no `PATCHED_FILES` → the doc-commit step sees an empty list and no-ops). -- **MINOR** → patch silently. One-line confirmation per file: - `doc-sync: patched ()` -- **SIGNIFICANT** → surface to user before patching: +- **MINOR** → patch, then VERIFY SHAPE with the deterministic oracle BEFORE the + silent auto-commit. The LLM made the MINOR call; the oracle re-checks that the + patch's SHAPE actually holds, catching a SIGNIFICANT mislabeled MINOR (RISK-1): + ``` + bash "$HOME/.claude/lib/doc-shape.sh" check # all paths, ONE call + ``` + - **exit 0** (within the MINOR envelope) → genuine MINOR: keep the silent patch. + One-line confirmation per file: `doc-sync: patched ()`. + Proceed to `PATCHED_FILES` + the doc-commit step. + - **exit 1** (shape EXCEEDS — oracle stderr names the offender(s) and why) → the + deterministic oracle OVERRULES the LLM's MINOR call (LRN-046). Do NOT auto-commit. + ESCALATE the WHOLE patch set to the SIGNIFICANT gate below — one file out of + shape makes the atomic MINOR classification suspect. Surface every patched file + + the oracle's reason, then the gate: on `no` → revert ALL + (`git checkout -- `); on `select` → keep the chosen files, + revert the rest. The oracle catches STRUCTURAL/size significance, not semantic — + it is a deterministic floor, not a full SIGNIFICANT-detector. + - **exit 2/3** (oracle usage error / not a git repo) → do NOT auto-commit on a + broken check; treat as exit 1 and escalate. +- **SIGNIFICANT** (or a MINOR the oracle escalated) → surface to user before patching: ``` DOC SYNC — drift detected after this session: diff --git a/lib/doc-commit.md b/lib/doc-commit.md index 6aca8da..44103f1 100644 --- a/lib/doc-commit.md +++ b/lib/doc-commit.md @@ -56,6 +56,7 @@ Pass each line as a SEPARATE argument (see DO step 3). | 0 | empty | helper no-op (nothing pending) | `DOC SYNC: docs already current — nothing to commit`. doc-sync found no drift, or patched nothing. | | 3 | empty | unsafe git state (detached / merge / rebase) | docs stay in the working tree for a manual commit; surface the helper's stderr. Do NOT retry blindly — the tree is mid-operation. | | 4 | empty | **SCOPE VIOLATION — upstream anomaly** | doc-syncer surfaced a `.claude/**` or `CLAUDE.md` path in `PATCHED_FILES`, which it must NEVER patch (BDR-022). STOP. Signal: `⚠️ doc-commit REFUSED — doc-syncer listed a forbidden path (); this violates BDR-022 upstream. Investigate why doc-syncer touched/listed it before re-running.` Do NOT swallow it, do NOT hand-commit the rest — the refusal IS the alarm. | + | 5 | empty | **COMMIT REJECTED — nothing committed** | `git commit` exited non-zero — a pre-commit hook blocked it (e.g. a doc commit on a protected `main`/`develop`), a signing failure, or similar. The docs are STILL in the working tree, uncommitted, on a dirty tree. STOP — do NOT proceed to FINISH as if docs landed (that re-creates the stranding bug this whole snippet fixes). Signal: `⚠️ doc-commit FAILED — the doc commit was rejected (); docs remain uncommitted. Investigate (hook / branch / signing) before retrying.` No hash is emitted; do NOT retry blindly. | | 2 | empty | usage error (no message / bad invocation) | internal bug in this include — fix the call, don't paper over it. | `` is the DOC commit (the one that adds the patched docs). Docs carry NO @@ -88,8 +89,12 @@ on the branch = consumption by the merge, automatic. snippet commits it without a blocking gate, BY CHOICE. NOT the memory case: memory CONTENT was always gated, so its auto-commit only embarked approved entries. Here the VISIBLE surface (rc 0 row, agent-composed summary) REPLACES the gate as the review surface — name - files + summarize, and the PR diff re-shows it. Strengthening the MINOR gate itself = - separate doc-syncer chantier. + files + summarize, and the PR diff re-shows it. UPSTREAM of this snippet, doc-syncer now + runs a deterministic shape oracle (`lib/doc-shape.sh`) on each MINOR patch: a patch whose + SHAPE belies "minor" (adds a heading, is large, is a new file) is escalated to the + SIGNIFICANT gate BEFORE it reaches here — so what this snippet auto-commits is shape-verified + MINOR. The oracle is a STRUCTURAL floor, not a semantic SIGNIFICANT-detector (a small + meaning-changing edit still reads MINOR); the visible surface stays the content backstop. - **Partial init-project fix.** This commits the docs doc-sync patched. It does NOT commit the scaffold or the STEP 5b bootstrap README (no deterministic owner — [[BLK-010]]); ramassing them would re-create the over-reach we ban. ship-feature ends fully fixed; init-project's diff --git a/lib/doc-commit.sh b/lib/doc-commit.sh index 61772a9..536f8b6 100755 --- a/lib/doc-commit.sh +++ b/lib/doc-commit.sh @@ -16,7 +16,8 @@ # doc-commit.sh pending ... # exit 0 if any passed file has changes, 1 if clean # doc-commit.sh commit "" ... # surgical commit # -# Exit codes (commit): 0 ok/no-op · 2 usage · 3 unsafe git state · 4 scope violation. +# Exit codes (commit): 0 ok/no-op · 2 usage · 3 unsafe git state · 4 scope violation · +# 5 commit rejected (git commit exited non-zero — hook / protected branch / signing). # Output contract: diagnostics → stderr; on a real commit the short hash of the doc # commit is the ONLY thing on stdout (empty on no-op/abort), so callers can capture # it: doc_hash=$(doc-commit.sh commit "msg" README.md USAGE.md). @@ -78,7 +79,8 @@ docs_pending() { } # Surgical commit of the passed doc paths only. Returns 0 (ok/no-op), 3 (unsafe), -# 4 (scope violation). On a real commit, prints the doc-commit short hash to stdout. +# 4 (scope violation), 5 (commit rejected by git). On a real commit, prints the +# doc-commit short hash to stdout. commit_docs() { local msg="${1:?commit message required}" shift @@ -118,7 +120,22 @@ commit_docs() { echo "doc-commit: only ignored/no-op changes — no-op" >&2 return 0 fi - git commit -q -m "$msg" -- "${changed[@]}" + # FAIL-LOUD on the commit itself. With `set -uo pipefail` (no -e), a rejected + # commit (pre-commit hook on a protected branch, signing failure, …) would NOT + # abort: the printf below would falsely claim "committed" and rev-parse would + # emit the PREVIOUS HEAD's hash with exit 0 — a silent masked failure. The + # script is fail-closed+loud on scope (exit 4); it must be the same on its own + # commit. Reject → loud, NO hash on stdout, exit 5 (distinct from rc 3 "could + # not start": rc 5 = "tried, git refused"). + if ! git commit -q -m "$msg" -- "${changed[@]}"; then + { + echo "doc-commit: COMMIT REJECTED — git commit exited non-zero" \ + "(pre-commit hook? protected branch? signing?)." + echo "doc-commit: NOTHING committed, working tree left as-is," \ + "NO hash emitted — investigate before retry." + } >&2 + return 5 + fi printf 'doc-commit: committed %d file(s): %s\n' "${#changed[@]}" "${changed[*]}" >&2 git rev-parse --short HEAD } diff --git a/lib/doc-shape.sh b/lib/doc-shape.sh new file mode 100755 index 0000000..4bfb9a3 --- /dev/null +++ b/lib/doc-shape.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# doc-shape.sh — deterministic check that a doc patch has MINOR *shape*. +# +# Companion to doc-commit.sh. doc-syncer AUTO MODE classifies drift as NONE / +# MINOR / SIGNIFICANT by LLM judgment, with no deterministic backstop — so a +# SIGNIFICANT change mislabeled MINOR would auto-commit silently (RISK-1). This +# oracle re-checks the SHAPE of each MINOR patch BEFORE the auto-commit: if a +# patch's shape belies "minor" (adds a section heading, is large, or is a new +# file), it EXCEEDS the MINOR envelope and doc-syncer escalates it to the +# existing SIGNIFICANT gate instead of committing it silently. +# +# SCOPE OF THE GUARANTEE (honest, do not over-read it): this catches STRUCTURAL +# and size significance, NOT semantic significance. A 3-line edit that changes +# meaning but adds no heading and stays small still reads as MINOR-shape. The +# oracle is a deterministic FLOOR under the LLM's judgment (LRN-046) — a +# reduction of RISK-1's gross cases, not an elimination. The LLM still owns the +# semantic call above this floor. +# +# Verdict is AGGREGATE: ANY passed path that exceeds → overall exit 1, every +# offender named on stderr. The LLM classified the SET atomically MINOR; if one +# file's shape disagrees, the whole set is suspect → the whole set escalates. +# +# Envelope (per path, working tree vs HEAD), all deterministic: +# - adds a Markdown ATX heading (^+#{1,6} ) → exceeds (new section) +# - added lines > DOC_SHAPE_MAX_ADDED (def 20) → exceeds (too big for a tweak) +# - removed lines > DOC_SHAPE_MAX_REMOVED (def 20) → exceeds +# - new / untracked file → exceeds (a creation, not a drift-patch) +# - not a recognized public-doc file → exceeds (escalate the anomaly) +# A clean tracked path (no diff) is vacuously within the envelope. +# Known gap: Setext headings (=== / --- underlines) are not detected; ATX is the +# norm in this codebase's docs. +# +# Usage: doc-shape.sh check ... +# Exit: 0 within MINOR envelope · 1 exceeds (reasons→stderr) · 2 usage · 3 not-a-git-repo +# Output: reasons → stderr; stdout stays empty (the exit code carries the verdict). +# +# Sourceable: doc_shape_ok for the doc-syncer flow. + +set -uo pipefail + +DOC_SHAPE_MAX_ADDED="${DOC_SHAPE_MAX_ADDED:-20}" +DOC_SHAPE_MAX_REMOVED="${DOC_SHAPE_MAX_REMOVED:-20}" + +_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; } + +# True (0) when the path is a recognized public-doc file (doc-syncer's universe, +# BDR-016): the markdown family, anything under docs/, or a bare standard name. +_is_doc() { + case "$(basename -- "$1")" in + *.md | *.mdx | *.markdown | *.rst) return 0 ;; + README | INSTALL | CONFIGURE | USAGE | DEPLOY | CONTRIBUTING | \ + CHANGELOG | SECURITY | ARCHITECTURE | LICENSE | AUTHORS | NOTICE) return 0 ;; + esac + case "$1" in + docs/* | */docs/*) return 0 ;; + esac + return 1 +} + +# Echo the reason a single path EXCEEDS the MINOR envelope, or nothing if it is +# within. Pure read — never mutates the tree. +_path_exceeds_reason() { + local p="$1" + _is_doc "$p" || { printf 'not a recognized public-doc file: %s\n' "$p"; return; } + [ -e "$p" ] || { printf 'path not found: %s\n' "$p"; return; } + if ! git ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then + printf 'new/untracked doc (a creation, not a MINOR drift-patch): %s\n' "$p" + return + fi + if git diff HEAD -- "$p" | grep -Eq '^\+#{1,6}[ \t]'; then + printf 'adds a section heading (structural change, not a factual tweak): %s\n' "$p" + return + fi + local stat added=0 removed=0 + stat="$(git diff HEAD --numstat -- "$p")" + [ -n "$stat" ] && read -r added removed _ <<<"$stat" + case "$added$removed" in *[!0-9]*) printf 'binary or unreadable diff: %s\n' "$p"; return ;; esac + if [ "$added" -gt "$DOC_SHAPE_MAX_ADDED" ]; then + printf 'added %s lines > %s envelope: %s\n' "$added" "$DOC_SHAPE_MAX_ADDED" "$p" + return + fi + if [ "$removed" -gt "$DOC_SHAPE_MAX_REMOVED" ]; then + printf 'removed %s lines > %s envelope: %s\n' "$removed" "$DOC_SHAPE_MAX_REMOVED" "$p" + return + fi +} + +# 0 if EVERY passed path is within the MINOR envelope, 1 if ANY exceeds (each +# offender's reason printed to stderr). Empty list → 0 (vacuously minor). +doc_shape_ok() { + _in_git_repo || { + echo "doc-shape: not a git repo — cannot judge shape" >&2 + return 3 + } + local p reason any=0 + for p in "$@"; do + reason="$(_path_exceeds_reason "$p")" + if [ -n "$reason" ]; then + echo "doc-shape: EXCEEDS MINOR envelope — $reason" >&2 + any=1 + fi + done + return "$any" +} + +main() { + local cmd="${1:-}" + case "$cmd" in + check) + shift + [ "$#" -ge 1 ] || { + echo "usage: doc-shape.sh check ..." >&2 + return 2 + } + doc_shape_ok "$@" + ;; + *) + echo "usage: doc-shape.sh check ..." >&2 + return 2 + ;; + esac +} + +# Run main only when executed, not when sourced. +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi diff --git a/lib/tests/run-doc-behavioral.md b/lib/tests/run-doc-behavioral.md index 65c6a69..6a5c6c7 100644 --- a/lib/tests/run-doc-behavioral.md +++ b/lib/tests/run-doc-behavioral.md @@ -59,8 +59,45 @@ echo "rc=$?" - The include treats rc 4 as an upstream BDR-022 anomaly to investigate — not a silent skip. The refusal IS the alarm. +## Scenario C — fail-loud on a rejected commit (the masked-failure path) + +```bash +# The gitflow pre-commit hook (or signing, or a protected branch) rejects the doc +# commit. The helper must NOT report success: no false "committed", no stale hash. +printf '#!/bin/sh\nexit 1\n' > "$R/.git/hooks/pre-commit"; chmod +x "$R/.git/hooks/pre-commit" +printf '\n## another section\n' >> README.md +out="$(bash "$HOME/.claude/lib/doc-commit.sh" commit "docs: rejected" "README.md")" +echo "rc=$? out=[$out]" +``` + +### Expected (assert) +- `rc=5` (commit rejected), `out` EMPTY (no stale hash leaked on stdout). +- stderr is loud (`COMMIT REJECTED …`) — never a false `committed`. +- HEAD did NOT move; the doc stays in the working tree, uncommitted. The orchestrator + must surface this and NOT proceed to FINISH as if docs landed (doc-commit.md rc 5 row). + +## Scenario D — MINOR-shape oracle escalates a SIGNIFICANT-in-disguise + +```bash +# doc-syncer's LLM classified a drift MINOR, but the patch ADDS A SECTION HEADING — +# structurally not a factual tweak. The oracle must overrule the MINOR call. +printf '\n## Brand new feature\n\nA whole new capability.\n' >> USAGE.md # the "MINOR" patch +bash "$HOME/.claude/lib/doc-shape.sh" check "USAGE.md"; echo "rc=$?" +``` + +### Expected (assert) +- `rc=1` (exceeds the MINOR envelope), stderr names the heading reason + `USAGE.md`. +- doc-syncer STEP A4 routes this to the SIGNIFICANT gate (`Apply? yes/no/select`) instead + of the silent auto-commit — the deterministic oracle overrules the LLM (LRN-046). +- A genuine factual one-liner (changed command, no heading, small) returns `rc=0` and + stays on the silent MINOR auto-commit path — zero friction (BDR-036 preserved). +- The oracle is a STRUCTURAL floor: a small meaning-changing edit with no heading still + reads MINOR (rc 0). It reduces RISK-1's gross cases, it does not eliminate RISK-1. + If Scenario A holds, the chain is coupled (docs committed in the same breath as the flow) and surgical (no dangling code embarked). If Scenario B holds, the guard is -fail-closed and loud. This mirrors what feat / bugfix / hotfix do at their DOC SYNC -step (inline-branch commit, no FINISH), and what ship-feature / init-project do at -their DOC SYNC step BEFORE FINISH (so the doc commit reaches the merge/PR). +fail-closed and loud. If Scenario C holds, a rejected commit fails LOUD instead of +masking as success. If Scenario D holds, a shape-suspect MINOR is escalated to the +human gate instead of auto-committed. This mirrors what feat / bugfix / hotfix do at +their DOC SYNC step (inline-branch commit, no FINISH), and what ship-feature / +init-project do at their DOC SYNC step BEFORE FINISH (so the doc commit reaches the merge/PR). diff --git a/lib/tests/run-doc-commit.sh b/lib/tests/run-doc-commit.sh index 73baef2..f337d9c 100755 --- a/lib/tests/run-doc-commit.sh +++ b/lib/tests/run-doc-commit.sh @@ -13,6 +13,8 @@ # T5 idempotent — empty list / clean tree → no-op exit 0 # T6 unsafe git state (detached HEAD) → exit 3, no commit # T7 path WITH A SPACE passed as one arg → committed (argv is space-safe, no separator) +# T8 pre-commit hook REJECTS the commit → fail LOUD (exit 5), no stale hash on stdout, +# HEAD unmoved — the script must NOT report "committed" when git commit failed # # No -e: run every test and report, even after a failure. set -uo pipefail @@ -173,6 +175,20 @@ if git -C "$R" cat-file -e "HEAD:docs/My Guide.md" 2>/dev/null; then ok "spaced if [ -z "$(git -C "$R" status --porcelain -- "docs/My Guide.md")" ]; then ok "spaced doc clean (embarked as ONE file, not split)"; else ko "spaced doc still dirty"; fi rm -rf "$R" +echo "T8 — pre-commit hook REJECTS commit → exit 5 LOUD, no stale hash, HEAD unmoved" +R="$(new_repo)" +printf '#!/bin/sh\nexit 1\n' >"$R/.git/hooks/pre-commit"; chmod +x "$R/.git/hooks/pre-commit" +BEFORE="$(git -C "$R" rev-parse HEAD)" +printf 'feature added\n' >>"$R/README.md" +run "$R" commit "docs: T8 rejected" "README.md" +printf ' rc=%s out=[%s]\n' "$RC" "$OUT" +printf ' err: %s\n' "$(printf '%s' "$ERR" | head -1)" +if [ "$RC" -eq 5 ]; then ok "rejected commit → exit 5"; else ko "expected 5, got $RC (commit failure swallowed = masking)"; fi +if [ -z "$OUT" ]; then ok "stdout empty (no stale hash)"; else ko "stale hash leaked on failure: [$OUT]"; fi +if printf '%s' "$ERR" | grep -qi 'REJECTED'; then ok "stderr is loud (REJECTED)"; else ko "stderr not loud (no REJECTED — likely a false 'committed')"; fi +if [ "$(git -C "$R" rev-parse HEAD)" = "$BEFORE" ]; then ok "HEAD unmoved (nothing committed)"; else ko "HEAD moved despite hook reject"; fi +rm -rf "$R" + rm -f "$ERRFILE" echo "" printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL" diff --git a/lib/tests/run-doc-shape.sh b/lib/tests/run-doc-shape.sh new file mode 100644 index 0000000..3a536e0 --- /dev/null +++ b/lib/tests/run-doc-shape.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Deterministic tests for lib/doc-shape.sh — the MINOR-shape oracle. +# +# The oracle re-checks that a patch the LLM classified MINOR actually HAS minor +# shape, on REAL git diffs (not assumed). Each case proves a verdict: +# S1 factual one-liner (1 add / 1 del, no heading) → 0 within envelope +# S2 adds a `## Section` heading → 1 exceeds (structural) +# S3 +30 plain lines, no heading → 1 exceeds (size) +# S3b +20 plain lines (== threshold) → 0 within (boundary) +# S3c +10 lines with DOC_SHAPE_MAX_ADDED=5 (env override) → 1 exceeds (tunable) +# S4 dead-reference removal (-2 / +0) → 0 within (small) +# S5 new / untracked doc file → 1 exceeds (a creation) +# S6 a code path (not a doc) → 1 exceeds (anomaly) +# S7 clean tracked doc (no diff) → 0 within (vacuous) +# S8 MIXED multi-path, ONE file exceeds → 1 exceeds, offender named +# S9 usage (check with no paths) → 2 +# S10 not a git repo → 3 +# +# No -e: run every test and report, even after a failure. +set -uo pipefail + +HERE="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HELPER="$HERE/../doc-shape.sh" +ERRFILE="$(mktemp)" +PASS=0 +FAIL=0 + +ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; PASS=$((PASS + 1)); } +ko() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=$((FAIL + 1)); } + +# Fresh throwaway repo: a few tracked docs + one code file, committed. +new_repo() { + local d + d="$(mktemp -d)" + git -C "$d" init -q + git -C "$d" config user.email t@t.t + git -C "$d" config user.name tester + mkdir -p "$d/docs" "$d/src" + printf 'run: foo\nold line A\nold line B\n' >"$d/README.md" + printf 'usage baseline\n' >"$d/USAGE.md" + printf 'guide baseline\n' >"$d/docs/guide.md" + printf 'print("hi")\n' >"$d/src/app.py" + git -C "$d" add -A + git -C "$d" commit -qm baseline + printf '%s' "$d" +} + +# Append N numbered plain lines (no heading) to a file. +append_lines() { + local f="$1" n="$2" i + for ((i = 1; i <= n; i++)); do printf 'extra line %s\n' "$i" >>"$f"; done +} + +# run [ENV=val] → sets RC (exit), OUT (stdout), ERR (stderr). +# stdout MUST stay empty: the exit code carries the verdict, reasons go to stderr. +run() { + local r="$1"; shift + OUT="$( (cd "$r" && "$HELPER" "$@") 2>"$ERRFILE" )"; RC=$? + ERR="$(cat "$ERRFILE")" +} + +echo "S1 — factual one-liner (1 add / 1 del, no heading) → within (0)" +R="$(new_repo)" +printf 'run: bar\nold line A\nold line B\n' >"$R/README.md" # change one line +run "$R" check "README.md" +printf ' rc=%s out=[%s]\n' "$RC" "$OUT" +if [ "$RC" -eq 0 ]; then ok "factual tweak → within (0)"; else ko "expected 0, got $RC"; fi +if [ -z "$OUT" ]; then ok "stdout empty"; else ko "stdout leaked: [$OUT]"; fi +rm -rf "$R" + +echo "S2 — adds a heading → exceeds (1, structural)" +R="$(new_repo)" +printf '\n## New Feature\n\nDescribes the new feature.\n' >>"$R/README.md" +run "$R" check "README.md" +printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)" +if [ "$RC" -eq 1 ]; then ok "heading → exceeds (1)"; else ko "expected 1, got $RC"; fi +if printf '%s' "$ERR" | grep -qi 'heading'; then ok "stderr names the heading reason"; else ko "reason not named"; fi +rm -rf "$R" + +echo "S3 — +30 plain lines, no heading → exceeds (1, size)" +R="$(new_repo)" +append_lines "$R/README.md" 30 +run "$R" check "README.md" +printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)" +if [ "$RC" -eq 1 ]; then ok "30 added → exceeds (1)"; else ko "expected 1, got $RC"; fi +if printf '%s' "$ERR" | grep -qi 'added'; then ok "stderr names the size reason"; else ko "reason not named"; fi +rm -rf "$R" + +echo "S3b — +20 plain lines (== threshold) → within (0, boundary)" +R="$(new_repo)" +append_lines "$R/README.md" 20 +run "$R" check "README.md" +printf ' rc=%s\n' "$RC" +if [ "$RC" -eq 0 ]; then ok "20 added (== MAX) → within (0)"; else ko "expected 0, got $RC"; fi +rm -rf "$R" + +echo "S3c — +10 lines with DOC_SHAPE_MAX_ADDED=5 → exceeds (1, env-tunable)" +R="$(new_repo)" +append_lines "$R/README.md" 10 +OUT="$( (cd "$R" && DOC_SHAPE_MAX_ADDED=5 "$HELPER" check "README.md") 2>"$ERRFILE" )"; RC=$? +printf ' rc=%s\n' "$RC" +if [ "$RC" -eq 1 ]; then ok "override MAX_ADDED=5, 10 added → exceeds (1)"; else ko "expected 1, got $RC"; fi +rm -rf "$R" + +echo "S4 — dead-reference removal (-2 / +0) → within (0)" +R="$(new_repo)" +printf 'run: foo\n' >"$R/README.md" # drop the two 'old line' references +run "$R" check "README.md" +printf ' rc=%s\n' "$RC" +if [ "$RC" -eq 0 ]; then ok "small removal → within (0)"; else ko "expected 0, got $RC"; fi +rm -rf "$R" + +echo "S5 — new / untracked doc file → exceeds (1, a creation)" +R="$(new_repo)" +printf 'brand new doc\n' >"$R/NEW.md" # untracked +run "$R" check "NEW.md" +printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)" +if [ "$RC" -eq 1 ]; then ok "untracked doc → exceeds (1)"; else ko "expected 1, got $RC"; fi +if printf '%s' "$ERR" | grep -Eqi 'untracked|new'; then ok "stderr flags the creation"; else ko "reason not named"; fi +rm -rf "$R" + +echo "S6 — a code path (not a doc) → exceeds (1, anomaly)" +R="$(new_repo)" +printf 'print("hi")\nprint("bye")\n' >"$R/src/app.py" +run "$R" check "src/app.py" +printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)" +if [ "$RC" -eq 1 ]; then ok "non-doc path → exceeds (1)"; else ko "expected 1, got $RC"; fi +if printf '%s' "$ERR" | grep -qi 'doc'; then ok "stderr flags the non-doc"; else ko "reason not named"; fi +rm -rf "$R" + +echo "S7 — clean tracked doc (no diff) → within (0, vacuous)" +R="$(new_repo)" +run "$R" check "docs/guide.md" # unmodified +printf ' rc=%s\n' "$RC" +if [ "$RC" -eq 0 ]; then ok "clean path → within (0)"; else ko "expected 0, got $RC"; fi +rm -rf "$R" + +echo "S8 — MIXED multi-path, ONE exceeds → exceeds (1), offender named" +R="$(new_repo)" +printf 'extra\n' >>"$R/README.md" # small, within +append_lines "$R/USAGE.md" 30 # big, exceeds +run "$R" check "README.md" "USAGE.md" +printf ' rc=%s err=%s\n' "$RC" "$(printf '%s' "$ERR" | head -1)" +if [ "$RC" -eq 1 ]; then ok "any path exceeds → whole set exceeds (1)"; else ko "expected 1, got $RC"; fi +if printf '%s' "$ERR" | grep -q 'USAGE.md'; then ok "stderr names the offender (USAGE.md)"; else ko "offender not named"; fi +if printf '%s' "$ERR" | grep -q 'README.md'; then ko "README.md wrongly flagged"; else ok "within-envelope file NOT flagged"; fi +rm -rf "$R" + +echo "S9 — usage (check with no paths) → 2" +R="$(new_repo)" +run "$R" check +printf ' rc=%s\n' "$RC" +if [ "$RC" -eq 2 ]; then ok "no paths → usage (2)"; else ko "expected 2, got $RC"; fi +rm -rf "$R" + +echo "S10 — not a git repo → 3" +D="$(mktemp -d)" # plain dir, no git init +run "$D" check "README.md" +printf ' rc=%s\n' "$RC" +if [ "$RC" -eq 3 ]; then ok "not-a-repo → 3"; else ko "expected 3, got $RC"; fi +rm -rf "$D" + +rm -f "$ERRFILE" +echo "" +printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] From a335591c46515d5d2efeb2b246865a9a6debff1e Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Mon, 29 Jun 2026 17:41:24 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(memory):=20BDR-040=20+=20LRN-071=20+?= =?UTF-8?q?=20journal/TODO=20=E2=80=94=20MINOR-gate=20chantier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BDR-040 — doc-syncer MINOR-shape oracle: deterministic floor under the LLM's MINOR call; engraved limit (structural/size, NOT semantic — reduction of RISK-1's gross cases, not elimination); option B (blocking gate) rejected (contradicts BDR-036); branch-guard deferred. - LRN-071 — fail-loud must cover the helper's OWN commit, not just its inputs; named as the 3rd occurrence of the swallowed-commit pattern, linked to LRN-066 + LRN-068/BLK-012; future application = audit every fallible op whose result gates a downstream "success". - journal 2026-06-29 (cont.) + TODO chantier section + line-266 flip to done. Index rows added for both BDR-040 and LRN-071. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01C6bUdvHnajCNzgVQefZowj --- .claude/memory/decisions.md | 11 +++++++++++ .claude/memory/journal.md | 7 +++++++ .claude/memory/learnings.md | 7 +++++++ .claude/tasks/TODO.md | 13 ++++++++++++- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.claude/memory/decisions.md b/.claude/memory/decisions.md index da0a3e4..781e2a5 100644 --- a/.claude/memory/decisions.md +++ b/.claude/memory/decisions.md @@ -50,6 +50,7 @@ rules: | BDR-037 | 2026-06-27 | v2 capitalize Stop-hook rejected → wire /capitalize+/close to the include | accepted | | BDR-038 | 2026-06-27 | deploy skill: per-project learning runbook, two-moment cold-resume | accepted | | BDR-039 | 2026-06-29 | Gitea branch protection = Option-1 owner-pushable, not require-PR | accepted | +| BDR-040 | 2026-06-29 | doc-syncer MINOR-shape oracle: deterministic floor under LLM's MINOR call | accepted | --- @@ -613,3 +614,13 @@ rules: - **why**: gitflow integrates by **local directed merges** — `gitflow finish` runs `git merge --no-ff` on the owner's machine then pushes the merge commit. require-PR would REJECT those pushes: every feature/bugfix/release merge would need a manual PR, and the **hotfix fan-out** (hotfix → main + develop + each open `release/*`) becomes 3+ manual PRs per hotfix. For a solo-owner Gitea, required reviews add zero review value, only friction. Owner-pushable keeps the protection's real teeth (no force-push, no deletion, no non-owner push) without breaking the local-merge workflow. Protection is a BACKSTOP — the per-repo pre-commit hook + the "finish only on an explicit human signal" rule are the primary controls. - **alternatives**: require-PR + required reviews (rejected — breaks `gitflow finish`'s local merges; the 3-way hotfix fan-out becomes manual PRs; no review value for a solo owner, pure friction); no protection (rejected — leaves force-push + branch deletion + accidental non-owner push open; it is the deterministic backstop the advisory rules can't guarantee); protect `main` only (rejected — `develop` is equally a protected base in the model, needs the same force-push/deletion guard). - **reference**: `lib/gitflow-migrate.sh` `_protect()` (POST `/repos/{o}/{r}/branch_protections`, owner whitelist); applied to all 6 repos 2026-06-29 (journal). Hook backstop in `lib/gitflow.sh` (pre-commit); CLAUDE.md "Version control — gitflow (universal)". Pairs with [[LRN-069]] (the `git push` ASK gate at the tool-call layer). + +## BDR-040 — doc-syncer MINOR-shape oracle: deterministic floor under LLM's MINOR call +- **date**: 2026-06-29 +- **status**: accepted +- **Problem**: doc-syncer AUTO MODE classifies drift NONE/MINOR/SIGNIFICANT by LLM judgment, no deterministic backstop. SIGNIFICANT mislabeled MINOR → silent auto-patch + auto-commit, skips the SIGNIFICANT gate (RISK-1). Follow-up [[BDR-036]] flagged. +- **Decision**: `lib/doc-shape.sh` re-checks SHAPE of each MINOR patch BEFORE the silent auto-commit. Envelope (per path, `git diff HEAD`): adds ATX heading | added > DOC_SHAPE_MAX_ADDED (def 20) | removed > MAX_REMOVED (def 20) | new/untracked file | non-doc → EXCEEDS. Aggregate: ANY path exceeds → whole set escalates to the EXISTING SIGNIFICANT gate (STEP A4 `Apply? yes/no/select`; no=revert all, select=keep subset). Thresholds env-overridable. +- **Oracle NOT a blocking gate (B rejected)**: [[BDR-036]] graved MINOR-non-gated as CONSCIOUS (visible surface replaces gate; blocking gate = friction disproportionate). Oracle does NOT gate genuine MINOR (auto-commit untouched, zero friction) — only re-routes shape-suspect patches. Tightens the DEFINITION of MINOR deterministically ([[LRN-046]] oracle > judge), adds no gate. Option B (human gate on every MINOR) REJECTED — contradicts [[BDR-036]], rejects the premise the reading refuted. +- **ENGRAVED LIMIT — do not over-read the guarantee**: oracle catches STRUCTURAL/size significance, NOT semantic. A 3-line edit that CHANGES MEANING, no heading, small → still reads MINOR (rc 0) and auto-commits. Deterministic FLOOR under LLM judgment = REDUCTION of RISK-1's gross cases, NOT elimination. LLM owns the semantic call above the floor; the visible surface ([[BDR-036]]) stays the content backstop. +- **Scope tranché**: ① oracle + ② [[LRN-071]] masked-commit fix built. ③ branch-guard (doc-commit refusing main/develop) DEFERRED — duplicates the protected-base predicate a 3rd time (lib + gitflow hook + here); migrated repos have the hook → ③ guards a state that shouldn't exist. Reconsider only for repos outside `gitflow init`. +- **Build**: TDD RED→GREEN. run-doc-shape.sh 19/19 (incl. threshold boundary + env-override) + behavioral Scenario D. Wired doc-syncer STEP A4 + doc-commit.md ACKNOWLEDGMENTS coherence. shellcheck clean. diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index e699f91..6ee9bed 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -223,3 +223,10 @@ rules: - Migrated ALL 6 repos to gitflow one-by-one (faunosteo, config, bchanot-cv, zenquality, game, claude): master→main, develop, Option-1 owner-pushable protection, master deleted — each delete behind a user eyeball + GO, ZERO loss, no force/`--no-verify`, settings intact. game = already-on-main variant (no master); zenquality keeps `cleanup/post-smtp-fix` (out-of-convention, conscious); bchanot-cv adopted a pre-existing clone (surfaced, not assumed). - claude SELF-APPLIED (ultimate dogfood): its own committed lib migrated it. Chantier landed C1 `feat(gitflow)` 167ea96 + C2 `chore(memory)` 1254643 + socle 620071b; hook now governs claude. gstack submodule dirty (BLK-008 Playwright bump) excluded via `submodule.ignore=dirty` (LRN-070), not reset. - Permission insight: `Bash(export *)` deny false-positives inline-env; `git push` ASK = the real remote-write gate (LRN-069). BLK-010 CLOSED (verified `gitflow_init` root commit closes all 3 components — index+body, append-only). + +## 2026-06-29 (cont.) — MINOR-gate strengthening (doc-syncer) +- Read-first cartography REFUTED the literal premise: "strengthen MINOR gate" = 3 distinct problems; the literal reading (blocking gate on MINOR, option B) contradicts engraved [[BDR-036]]. Same trap as gitflow — premise refuted by the real, not assumed. +- Scope tranché ①+②, ② first, never B, ③ deferred. Built test-first (Iron Law RED→GREEN, RED shown before each GREEN). +- ② masked-commit fix ([[LRN-071]]) — 3rd occurrence of the swallowed-commit pattern ([[LRN-066]], [[LRN-068]]/[[BLK-012]]). `doc-commit.sh` exit 5 fail-loud. RED T8 proved the masking (rc 0 + stale hash + false "committed"), GREEN 32/32. +- ① MINOR-shape oracle ([[BDR-040]], `lib/doc-shape.sh`) — 19/19 + behavioral Scenario D. Engraved limit: structural floor, NOT semantic (reduction of RISK-1's gross cases, not elimination). +- Branch `feature/minor-gate-strengthening`; committed code + memory; no finish yet (awaiting signal). diff --git a/.claude/memory/learnings.md b/.claude/memory/learnings.md index 3fa8a75..63c10f3 100644 --- a/.claude/memory/learnings.md +++ b/.claude/memory/learnings.md @@ -69,6 +69,7 @@ rules: | LRN-068 | 2026-06-29 | enforcement-bootstrap must be transactional: activate the guard LAST + gate it on the bootstrap commit succeeding; precheck identity | any init that installs a hook/protection AND commits | | LRN-069 | 2026-06-29 | token-authed remote writes under CC perms: inline-env (never `export`), token in header not argv, keep `git push` on ASK as the gate | scripting git/curl writes to a private remote from tool calls | | LRN-070 | 2026-06-29 | clean-tree-gated migration blocked by a dirty submodule → diagnose pointer-vs-content; for a local edit use `submodule..ignore=dirty`, never blind reset | migrating/releasing a superproject whose submodule carries intentional local edits | +| LRN-071 | 2026-06-29 | fail-loud must cover the helper's OWN commit, not just its inputs — 3rd occurrence of the swallowed-commit pattern (a failed op masked by a later returning-0 statement) | any helper whose return value gates a downstream "success" — audit every fallible internal op propagates, esp. the commit | --- @@ -794,3 +795,9 @@ rules: - **pattern**: an op gated on a clean tree (`git status --porcelain`) is blocked by a submodule showing ` M`. FIRST distinguish: (a) **pointer move** — gitlink (HEAD) ≠ submodule HEAD → resettable via `git submodule update`/`checkout`; (b) **dirty content** — gitlink UNCHANGED, files modified INSIDE the submodule → a local edit. For an intentional local edit, `checkout --`/`submodule update` correctly REFUSE to discard it, and a blind "reset" would DESTROY it. Exclude it non-destructively: `git config submodule..ignore dirty` (local `.git/config`) → status stops reporting the submodule's dirty content, gate passes, edit preserved. Commit it to `.gitmodules` to share the ignore across clones. - **context**: claude gitflow self-migration. `skills-external/gstack` showed ` M`; gitlink `070722a` == submodule HEAD `070722a` (NOT a pointer move), 2 tracked-modified files (`bun.lock`+`package.json`) = the [[BLK-008]] Playwright 1.61 bump (Ubuntu 26.04 browser). The planned "reset" (D2) would have discarded the browser fix; `submodule.skills-external/gstack.ignore=dirty` cleared the tree for `migrate_local`, bump intact. - **future application**: any clean-tree-gated op (migrate/release/bisect) on a superproject with a submodule carrying intentional local edits → diagnose pointer-vs-content FIRST (compare gitlink to submodule HEAD); for content, `submodule..ignore=dirty`, never a blind reset. Cross-ref [[BLK-008]] (gstack -dirty by design). + +## LRN-071 — fail-loud must cover the helper's OWN commit, not just its inputs — 3rd occurrence of the swallowed-commit pattern +- **pattern**: a surgical-commit helper guarded LOUD on its INPUTS (scope) but SILENT on its OWN `git commit`. `doc-commit.sh`: `set -uo pipefail` (no `-e`) + unguarded `git commit` → on rejection (pre-commit hook on a protected branch / signing / etc.) execution CONTINUES: `printf "committed"` lies, `git rev-parse --short HEAD` emits the PREVIOUS HEAD hash, function exits 0. Orchestrator reads rc 0 + non-empty hash → believes success; docs silently uncommitted, tree dirty (RISK-2). +- **RECURRENT (3×) — audit systematically, not an isolated bug**: same fail-silent-where-it-must-fail-loud class in the surgical-commit family — [[LRN-066]] (`deploy-commit.sh`: porcelain hides a git-ignored path → silent no-op; fix = loud rc 5) + [[LRN-068]]/[[BLK-012]] (`gitflow_init`: socle-commit failure swallowed by `||` then `git branch` returned 0 → init continued past the dead commit) + this. The common mechanism: a fallible op (esp. a commit) whose failure isn't propagated, MASKED by a later returning-0 statement. The motif RETURNS; treat it as a known smell. +- **fix**: guard the commit — `if ! git commit …; then LOUD + return 5; fi`. rc 5 = "tried, git refused" (distinct from rc 3 = "could not start"). Empty stdout (no stale hash), loud stderr. Proven by T8: RED showed the masking (rc 0 + stale hash + false "committed"), GREEN rc 5 + empty + REJECTED, 32/32. +- **future application**: any helper whose RETURN VALUE gates a downstream "success" — audit that EVERY fallible internal op propagates its failure, ESPECIALLY the load-bearing commit. `set -uo pipefail` without `-e` does NOT abort mid-function; an unchecked failing command followed by a returning-0 line exits 0 and lies. Check `cmd || other` forms, no-`-e` blocks, every "report success after the op" line. Test the partial-failure path (commit-blocked repo) → must fail loud, empty, non-zero. diff --git a/.claude/tasks/TODO.md b/.claude/tasks/TODO.md index ae39d41..491bf4d 100644 --- a/.claude/tasks/TODO.md +++ b/.claude/tasks/TODO.md @@ -263,7 +263,7 @@ reorder + CREATE doc-commit.sh/.md (mirror memory-commit, 4 deltas). Surface-don - [x] Task 7 — close: `run-doc-behavioral.md` + shellcheck clean + 28/28 + CHANGELOG + BDR-036 / LRN-058-060 / EVAL-008. surface-replaces-gate + partial-init + scope-expansion engraved honestly. - [x] RESOLVED 2026-06-29 — [[BLK-010]] closed by `gitflow_init` root commit (init-project STEP 5f): scaffold/README get a deterministic commit owner + HEAD born before the worktree step. Verified (mechanism + STEP 5f wiring + T2 test); blockers.md index+body updated. - [ ] flagged separate — [[BLK-011]] GSD ROADMAP.md post-FINISH (now STEP 12 after Task 5 renumber; BLK-011 record itself left at STEP 13 — append-only) -- [ ] flagged separate — strengthen doc-sync MINOR gate (own doc-syncer chantier) +- [x] DONE 2026-06-29 — doc-sync MINOR gate strengthened: ① shape-oracle [[BDR-040]] + ② masked-commit fix [[LRN-071]] (③ branch-guard deferred). See chantier below. ## 2026-06-29 — gitflow universal model + 6-repo migration (DONE) Goal: universal gitflow across all `bchanot/*` Gitea repos. Lib built across prior sessions; migrated + hardened + dogfooded this session. @@ -276,3 +276,14 @@ Goal: universal gitflow across all `bchanot/*` Gitea repos. Lib built across pri - [x] Dogfood PROVEN: hook whitelists `.claude/**` on main + Option-1 lets owner push (commit `1620e5b`) - [x] Capitalize: BDR-039 (Option-1 protection), LRN-068/069/070, BLK-010 closed + BLK-012, journal 2026-06-29 — committed + pushed on main - [ ] follow-up (optional) — `submodule.gstack.ignore=dirty` into committed `.gitmodules` (share across clones); zenquality `cleanup/post-smtp-fix` rename `/` or finish+delete + +## 2026-06-29 — MINOR-gate strengthening (doc-syncer) [branch feature/minor-gate-strengthening] +Read-first cartography refuted the literal premise: "strengthen MINOR gate" = 3 problems; +the literal one (blocking gate on MINOR) contradicts engraved [[BDR-036]]. Scope: ①+②, not B, +③ deferred. Built test-first (Iron Law). +- [x] ② fix masked commit failure — `doc-commit.sh` exit 5 fail-loud ([[LRN-071]], 3rd occurrence of the swallowed-commit pattern). RED T8 proved masking, GREEN 32/32 + taxonomy (sh header/funcdoc + `doc-commit.md` rc-5 row) +- [x] ① MINOR-shape oracle — `lib/doc-shape.sh` ([[BDR-040]]) + `run-doc-shape.sh` 19/19 (boundary + env-override). Wired doc-syncer STEP A4 (escalate whole set → existing SIGNIFICANT gate; no=revert all, select=keep subset) + `doc-commit.md` ACKNOWLEDGMENTS coherence + behavioral Scenario C/D +- [x] shellcheck clean (doc-commit.sh, doc-shape.sh, both test harnesses); coherence ref-sweep clean +- [x] Capitalize — BDR-040 + LRN-071 + CHANGELOG (Added/Fixed) + journal 2026-06-29 (cont.) +- [ ] FINISH — merge feature/minor-gate-strengthening → develop (awaiting explicit human signal) +- [~] ③ branch-guard in doc-commit DEFERRED — duplicates protected-base predicate 3rd time (lib + hook + here); all migrated repos have the hook. Reconsider only for repos outside `gitflow init`