Merge feature/minor-gate-strengthening into develop

This commit is contained in:
Bastien Chanot 2026-06-29 17:47:51 +02:00
commit 0f0bd7fe63
12 changed files with 435 additions and 12 deletions

View File

@ -50,6 +50,7 @@ rules:
| BDR-037 | 2026-06-27 | v2 capitalize Stop-hook rejected → wire /capitalize+/close to the include | accepted | | 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-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-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. - **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). - **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). - **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.

View File

@ -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). - 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. - 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). - 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).

View File

@ -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-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-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.<name>.ignore=dirty`, never blind reset | migrating/releasing a superproject whose submodule carries intentional local edits | | LRN-070 | 2026-06-29 | clean-tree-gated migration blocked by a dirty submodule → diagnose pointer-vs-content; for a local edit use `submodule.<name>.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.<name>.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. - **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.<name>.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. - **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.<name>.ignore=dirty`, never a blind reset. Cross-ref [[BLK-008]] (gstack -dirty by design). - **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.<name>.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.

View File

@ -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] 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. - [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 — [[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) ## 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. 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] 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 - [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 `<type>/<name>` or finish+delete - [ ] follow-up (optional) — `submodule.gstack.ignore=dirty` into committed `.gitmodules` (share across clones); zenquality `cleanup/post-smtp-fix` rename `<type>/<name>` 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`

View File

@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
### Added ### 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-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 - 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 - `/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` - `/capitalize` — flush uncapitalized context to the memory registries before `/clear` or `/compact`
- `/prune-memory` — curate and compress the `.claude/memory/` registries - `/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. - 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 ### 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, …) - Numerous skill/agent fixes across darwin optimization rounds (geo-analyzer, onboard, init-project, analyzer, plugin-check, prune-memory, …)
## [3.4.0] — 2026-04-15 ## [3.4.0] — 2026-04-15

View File

@ -792,9 +792,26 @@ Categorize:
- **NONE** → exit completely silent. No output (no `PATCHED_FILES` → the doc-commit step - **NONE** → exit completely silent. No output (no `PATCHED_FILES` → the doc-commit step
sees an empty list and no-ops). sees an empty list and no-ops).
- **MINOR** → patch silently. One-line confirmation per file: - **MINOR** → patch, then VERIFY SHAPE with the deterministic oracle BEFORE the
`doc-sync: patched <file> (<what changed>)` silent auto-commit. The LLM made the MINOR call; the oracle re-checks that the
- **SIGNIFICANT** → surface to user before patching: patch's SHAPE actually holds, catching a SIGNIFICANT mislabeled MINOR (RISK-1):
```
bash "$HOME/.claude/lib/doc-shape.sh" check <every patched path> # all paths, ONE call
```
- **exit 0** (within the MINOR envelope) → genuine MINOR: keep the silent patch.
One-line confirmation per file: `doc-sync: patched <file> (<what changed>)`.
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 -- <each patched path>`); 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: DOC SYNC — drift detected after this session:
<list of significant items with proposed fixes> <list of significant items with proposed fixes>

View File

@ -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. | | 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. | | 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 (<offender, from stderr>); 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. | | 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 (<offender, from stderr>); 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 (<helper stderr>); 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. | | 2 | empty | usage error (no message / bad invocation) | internal bug in this include — fix the call, don't paper over it. |
`<doc_hash>` is the DOC commit (the one that adds the patched docs). Docs carry NO `<doc_hash>` 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 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 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 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 = files + summarize, and the PR diff re-shows it. UPSTREAM of this snippet, doc-syncer now
separate doc-syncer chantier. 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 - **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 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 them would re-create the over-reach we ban. ship-feature ends fully fixed; init-project's

View File

@ -16,7 +16,8 @@
# doc-commit.sh pending <file>... # exit 0 if any passed file has changes, 1 if clean # doc-commit.sh pending <file>... # exit 0 if any passed file has changes, 1 if clean
# doc-commit.sh commit "<message>" <file>... # surgical commit # doc-commit.sh commit "<message>" <file>... # 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 # 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 # 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). # 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), # 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() { commit_docs() {
local msg="${1:?commit message required}" local msg="${1:?commit message required}"
shift shift
@ -118,7 +120,22 @@ commit_docs() {
echo "doc-commit: only ignored/no-op changes — no-op" >&2 echo "doc-commit: only ignored/no-op changes — no-op" >&2
return 0 return 0
fi 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 printf 'doc-commit: committed %d file(s): %s\n' "${#changed[@]}" "${changed[*]}" >&2
git rev-parse --short HEAD git rev-parse --short HEAD
} }

127
lib/doc-shape.sh Executable file
View File

@ -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} <text>) → 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 <path>...
# 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 <path>..." >&2
return 2
}
doc_shape_ok "$@"
;;
*)
echo "usage: doc-shape.sh check <path>..." >&2
return 2
;;
esac
}
# Run main only when executed, not when sourced.
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
main "$@"
fi

View File

@ -59,8 +59,45 @@ echo "rc=$?"
- The include treats rc 4 as an upstream BDR-022 anomaly to investigate — not a - The include treats rc 4 as an upstream BDR-022 anomaly to investigate — not a
silent skip. The refusal IS the alarm. 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 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 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 fail-closed and loud. If Scenario C holds, a rejected commit fails LOUD instead of
step (inline-branch commit, no FINISH), and what ship-feature / init-project do at masking as success. If Scenario D holds, a shape-suspect MINOR is escalated to the
their DOC SYNC step BEFORE FINISH (so the doc commit reaches the merge/PR). 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).

View File

@ -13,6 +13,8 @@
# T5 idempotent — empty list / clean tree → no-op exit 0 # T5 idempotent — empty list / clean tree → no-op exit 0
# T6 unsafe git state (detached HEAD) → exit 3, no commit # 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) # 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. # No -e: run every test and report, even after a failure.
set -uo pipefail 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 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" 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" rm -f "$ERRFILE"
echo "" echo ""
printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL" printf 'RESULT: %d passed, %d failed\n' "$PASS" "$FAIL"

166
lib/tests/run-doc-shape.sh Normal file
View File

@ -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] <repo> <args...> → 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 ]