From 5d4c8d8219d72cc62932f84f884c935f86bec73e Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 25 Jul 2026 22:54:23 -0400 Subject: [PATCH] feat(site): enforce VOICE.md's punctuation rule, and make it true site/VOICE.md has said this since 2026-07-02: Em-dashes are not used as sentence separators in site prose. Restructure with a period, a comma, a colon, or parentheses instead. Nothing enforced it. `_sloplib.in_antislop_doc_scope` governed repo-root docs and `docs/**` and had never covered `site/`, so the rule was prose asking politely - and 16 of the 36 authored pages violated it for 24 days while reviewers cited it at contributors. A rule with no gate is worse than no rule. It is not merely unenforced: people learn that the style docs are advisory, which is the same corrosion as a teardown report that over-counts its failures (#433) or a suite that is red once in four (#462). So this ships the gate first and the prose second. THE GATE. `.github/scripts/check_site_voice.py`, wired into docs.yml's `site-check`, which `deploy` needs - so a violation blocks the publish. Its file set comes from `git ls-files`, so a generated page cannot drift into scope by landing in a tracked directory: if it is not committed, it is not audited. It does not re-implement the exclusions; it asks the shared scope predicate, so there is one definition of scope rather than two that can disagree. SCOPE IS AUTHORED PROSE, and each exclusion is load-bearing rather than convenient: `content/docs/reference/**` is generated on every build (91 of the 128 pages there), `changelog.md` is a verbatim pass-through of the repo CHANGELOG under a different register, and `site/src/curated/**` mirrors plugins/ca bodies that are already excluded via `plugins/`. A finding nobody wrote and nobody can fix in place is noise, and noise is how a gate gets ignored. TWO DETECTOR CORRECTIONS, both found by writing the gate rather than by reading: * The detector flagged DEFINITION-LIST dashes (`- **term** - meaning`). The rule is about SENTENCE separators, and VOICE.md's own Terminology anchors section is written in exactly that form - enforcing it as written would have made the gate contradict the guide it enforces. Now exempt, anchored to line-start and a bolded lead-in. * That exemption's FIRST cut was a false-negative generator, caught by an adversarial reader before it shipped. Blanking the whole `- **term**` lead-in left the next dash with no word character on its left once inline code was stripped, so on - **The gate-enforcement hooks** - `a.py`, `b.py` - make zero calls. a real separator scored 1 before and 0 after. It loosened the shared detector for docs/** and repo-root too, not only for site prose. Only the definition DASH is dropped now; the term is kept. The test that was supposed to cover this passed only because its fixture had no inline code, so the real line is now a fixture. Also: the scope predicate keyed on `.md` alone, so the two authored `.mdx` pages were invisible to a rule about writing. THE PROSE. 118 offending lines across 15 pages, restructured by what each dash was doing - parentheses or a comma pair for an aside, a colon for a consequence or a list lead-in, a period where two independent statements had been welded. Table cells are in scope (`| No - guarded |` became `| No: guarded |`): a reader does not care that the dash sits in a cell. Verified: every code span, link target, version, flag, path, gate ID and ADR reference is byte-identical across all 15 files; no generated page, no curated mirror, and nothing outside site/src/content/docs was touched; zero CRLF drift. Two rewrites were revised after review rather than defended: an appositive that wedged 16 words between subject and verb in install.md, and a fifth comma added to a four-item list in enforcement.md - where pi.md already renders the identical sentence with a period, so it now matches. KNOWN GAP, recorded in the script and filed as #484 rather than implied to be handled: the detector is line-based, so a separator dash at a line-wrap boundary is invisible to it. Three such dashes were found BY HAND while fixing the flagged ones. They are fixed, but the gate did not catch them and would not catch a new one. Closing it needs paragraph-level analysis, which is a different change from this gate. Verified: gate exit 0 over 36 authored pages; site suite 438/438; sloplib 33 tests; full ca hook suite 1169; ci-impact 41; docs contract clean; sync-core byte-identical across three plugins. ca-pi advances to 0.1.26 for the shared hook change. Closes #338. --- .github/scripts/check_site_voice.py | 118 ++++++++++++++++++ .github/workflows/docs.yml | 22 ++++ CHANGELOG.md | 19 +++ core/pysrc/_sloplib.py | 49 +++++++- package.json | 2 +- plugins/ca-codex/hooks/_sloplib.py | 49 +++++++- plugins/ca-pi/CHANGELOG.md | 16 +++ plugins/ca-pi/hooks/_sloplib.py | 49 +++++++- plugins/ca-pi/package.json | 2 +- plugins/ca/hooks/_sloplib.py | 49 +++++++- plugins/ca/hooks/tests/test_sloplib.py | 103 +++++++++++++++ .../src/content/docs/codearbiter-directory.md | 112 ++++++++--------- .../docs/concepts/hardening-history.md | 6 +- site/src/content/docs/enforcement.md | 4 +- .../getting-started/claude-code-and-codex.md | 6 +- .../docs/getting-started/compatibility.md | 22 ++-- .../content/docs/getting-started/install.md | 22 ++-- site/src/content/docs/getting-started/pi.md | 10 +- .../docs/getting-started/quickstart.md | 8 +- .../docs/guides/adding-a-dependency.md | 2 +- site/src/content/docs/guides/ca-sandbox.md | 14 +-- .../content/docs/guides/overriding-a-gate.md | 2 +- .../content/docs/guides/troubleshooting.md | 6 +- site/src/content/docs/guides/uninstalling.md | 52 ++++---- site/src/content/docs/hooks.md | 8 +- site/src/content/docs/overview.md | 10 +- 26 files changed, 606 insertions(+), 156 deletions(-) create mode 100644 .github/scripts/check_site_voice.py diff --git a/.github/scripts/check_site_voice.py b/.github/scripts/check_site_voice.py new file mode 100644 index 00000000..fde82e1c --- /dev/null +++ b/.github/scripts/check_site_voice.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Issue #338 - site/VOICE.md's punctuation rule, finally enforced. + +`site/VOICE.md` has said this since 2026-07-02: + + Em-dashes are not used as sentence separators in site prose. Restructure + with a period, a comma, a colon, or parentheses instead. + +Nothing enforced it. `_sloplib.in_antislop_doc_scope` governed repo-root docs +and `docs/**` and had never covered `site/`, so the rule was prose asking +politely - and 16 of the 36 authored pages violated it, for 24 days, while +reviewers cited it at contributors. + +A rule with no gate is worse than no rule. It is not merely unenforced: people +learn that the style docs are advisory, which is the same failure mode as a +teardown report that over-counts its failures. So this is the gate. + +SCOPE IS AUTHORED PROSE, and the exclusions are load-bearing rather than +convenient: + + * `site/src/content/docs/reference/**` - 91 of the 128 pages there are + generated on every build. Flagging them would report findings nobody wrote + and nobody can fix in place. + * `site/src/content/docs/changelog.md` - a verbatim pass-through of the repo + CHANGELOG, which is payload prose under a different register. + * `site/src/curated/**` - mirrors of `plugins/ca` bodies. Framework prose, + already excluded via `plugins/`; mirroring it into the site must not smuggle + it back into scope. + +The file set comes from `git ls-files`, so a generated page cannot drift into +scope by being written into a tracked directory: if it is not committed, it is +not audited. + +KNOWN GAP, stated rather than discovered later. The detector is line-based: it +requires word characters on both sides of the dash ON THE SAME LINE. A separator +dash that lands at a line-wrap boundary - + ...it holds in one of three states — + listed below. +- is therefore invisible to it, and three such dashes were found by hand in +`codearbiter-directory.md` while fixing the flagged ones. They were fixed too, +but the gate did not catch them and would not catch a new one. Closing that +needs paragraph-level analysis rather than a per-line scan, which is a larger +change than this gate; it is filed separately rather than implied to be handled. + +Usage: + python .github/scripts/check_site_voice.py # audit, exit 1 on findings + python .github/scripts/check_site_voice.py --list # paths only, exit 0 +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "plugins" / "ca" / "hooks")) + +from _sloplib import find_prose_separator_dashes, in_antislop_doc_scope # noqa: E402 + +SITE_PROSE = "site/src/content/docs" + + +def audited_paths() -> list[str]: + """Every git-TRACKED page under the authored site-prose root that the shared + scope predicate governs. One source of truth for scope: this script does not + re-implement the exclusions, it asks `in_antislop_doc_scope`.""" + tracked = subprocess.run( + ["git", "ls-files", SITE_PROSE], + cwd=REPO, capture_output=True, text=True, encoding="utf-8", check=True, + ).stdout.split() + return sorted(p for p in tracked if in_antislop_doc_scope(p)) + + +def audit() -> dict[str, list[dict]]: + out: dict[str, list[dict]] = {} + for rel in audited_paths(): + text = (REPO / rel).read_text(encoding="utf-8", errors="replace") + hits = find_prose_separator_dashes(text) + if hits: + out[rel] = hits + return out + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--list", action="store_true", help="print the audited paths and exit 0") + arguments = parser.parse_args(argv) + + paths = audited_paths() + if arguments.list: + for rel in paths: + print(rel) + return 0 + + if not paths: + print("::error::no authored site prose found to audit - the scope predicate has drifted", + file=sys.stderr) + return 1 + + findings = audit() + total = sum(len(v) for v in findings.values()) + if not findings: + print(f"site voice: {len(paths)} authored page(s) clean of prose separator dashes") + return 0 + + print(f"::error::site/VOICE.md forbids the em-dash as a sentence separator in site prose; " + f"{total} line(s) across {len(findings)} file(s) still use one. Restructure with a " + f"period, a comma, a colon, or parentheses.", file=sys.stderr) + for rel, hits in findings.items(): + for hit in hits: + print(f" {rel}:{hit['line']}: {hit['context'][:160]}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c6b9bad1..a0dc70a3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,6 +23,11 @@ on: - "site/**" - "plugins/ca/**" # the reference is generated from the plugin, so rebuild when it changes - ".github/workflows/docs.yml" # this workflow gates the site graph; it must see its own edits + # Issue #338: the site-voice gate and the detector it calls. Without these + # a PR that only loosened the rule would skip the job that enforces it - + # the same defect class the impact filter already guards for ci.yml. + - ".github/scripts/check_site_voice.py" + - "core/pysrc/_sloplib.py" pull_request: branches: [main] paths: @@ -74,6 +79,23 @@ jobs: run: npm audit --omit=dev --audit-level=high - name: Typecheck run: npm run typecheck + - name: Enforce site/VOICE.md punctuation (issue #338) + # site/VOICE.md has banned the em-dash as a sentence separator in site + # prose since 2026-07-02, and NOTHING enforced it: _sloplib's scope + # covered repo-root docs and docs/**, never site/. 16 of the 36 authored + # pages violated the rule for 24 days while reviewers cited it at + # contributors. A rule with no gate is worse than no rule - people learn + # that the style docs are advisory. + # + # `deploy` needs this job, so a violation blocks the publish. Scope is + # AUTHORED prose only, drawn from `git ls-files` via the shared + # in_antislop_doc_scope: generated reference pages, the changelog + # pass-through, and the curated plugin mirrors are all excluded, because + # a finding nobody wrote and nobody can fix in place is noise. + # + # Runs from the repo root, not site/, since it audits tracked paths. + working-directory: . + run: python .github/scripts/check_site_voice.py - name: Test run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 66848130..464714bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,25 @@ predate the plugin rewrite and are grouped by date. ### Fixed +- site/VOICE.md's punctuation rule is finally enforced. It has banned the + em-dash as a sentence separator in site prose since 2026-07-02, and nothing + checked it: the anti-slop detector's scope covered repo-root docs and + `docs/**` and had never included `site/`. Sixteen of the thirty-six authored + pages violated the rule for twenty-four days while reviewers cited it at + contributors. A rule with no gate is worse than no rule, because people learn + that the style docs are advisory. 118 offending lines across 15 pages are + restructured with a period, a comma, a colon, or parentheses, and + `check_site_voice.py` now blocks the site publish on a new one (issue #338). +- The detector no longer flags a definition-list dash (`- **term** - meaning`). + The rule is about *sentence* separators, and VOICE.md's own Terminology + anchors are written in that exact form, so enforcing it as written would have + made the gate contradict the guide. A real separator later on the same line is + still caught: only the definition dash is dropped, never the term before it. + The first cut of that exemption blanked the whole lead-in and silently + suppressed a genuine finding whenever inline code followed it, which an + adversarial reader caught before it shipped. +- The detector's scope predicate recognises `.mdx` as prose. It keyed on `.md` + alone, so two authored site pages were invisible to a rule about writing. - The shipped `farm.js` bundle is now executed by the test suite, not merely regenerated and byte-compared. `includes/farm.md` tells operators to run `node /tools/farm.js`, but the integration launcher ran `farm.ts` diff --git a/core/pysrc/_sloplib.py b/core/pysrc/_sloplib.py index 36c42377..413fe6e3 100644 --- a/core/pysrc/_sloplib.py +++ b/core/pysrc/_sloplib.py @@ -34,10 +34,28 @@ # A letter or digit (Unicode), used to confirm a dash actually joins two text # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell). _WORD_RE = re.compile(r"[^\W_]", re.UNICODE) +# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence +# separator, and the rule this detector enforces says "sentence separators" - +# site/VOICE.md's own Terminology anchors section is written in exactly this +# form, so flagging it would make the gate contradict the style guide it exists +# to enforce (#338). Anchored to the start of the line and to a bolded lead-in, +# so an ordinary sentence that happens to contain bold text is untouched. +# +# The TERM is captured and kept; only the dash is dropped. Blanking the whole +# lead-in was the first cut and it was a false-negative generator: on +# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls. +# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks, +# and the SECOND dash - a real separator - was left with no word character on +# its left and escaped. That loosened the shared detector for docs/** and +# repo-root too, not only for site prose. +_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]") def _prose_only(line): """Drop the spans §3.A exempts so only candidate prose remains.""" + # Only the definition DASH is dropped; the term is kept (group 1), so a + # later separator on the same line still has its left-hand context. + line = _DEFINITION_RE.sub(r"\1 ", line) line = _INLINE_CODE_RE.sub(" ", line) line = _URL_RE.sub(" ", line) line = _RANGE_RE.sub(" ", line) @@ -78,19 +96,44 @@ def find_prose_separator_dashes(text): return findings +# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence +# separators since 2026-07-02, and nothing enforced it: this predicate covered +# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 +# of its own 36 authored files, is worse than no rule - reviewers cite it and it +# is wrong. +# +# Scope is AUTHORED prose only. Everything under content/docs/reference/ is +# generated on every build (91 of the 128 files there), changelog.md is a +# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors +# plugins/ca bodies - which are framework prose under a different register, and +# already excluded via plugins/. Flagging any of those would report a finding +# nobody wrote and nobody can fix in place. +_SITE_PROSE_ROOT = "site/src/content/docs/" +_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md") + + def in_antislop_doc_scope(rel_path): """True for user-facing Markdown the anti-slop bundle governs: repo-root - community docs and docs/**. Excludes codeArbiter's own framework bodies - (everything under plugins/) and machine-managed .codearbiter/ state.""" + community docs, docs/**, and AUTHORED site prose under + site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies + (everything under plugins/), machine-managed .codearbiter/ state, and every + generated site page.""" if not rel_path: return False p = rel_path.replace("\\", "/") if p.startswith("./"): p = p[2:] - if not p.lower().endswith(".md"): + # `.mdx` counts: the rule is about prose, not about a file extension, and + # two authored site pages are .mdx. + if not p.lower().endswith((".md", ".mdx")): return False if p.startswith("plugins/") or p.startswith(".codearbiter/"): return False + if p.startswith(_SITE_PROSE_ROOT): + rest = p[len(_SITE_PROSE_ROOT):] + return not rest.startswith(_SITE_PROSE_EXCLUDED) + if p.startswith("site/"): + return False if p.startswith("docs/"): return True return "/" not in p diff --git a/package.json b/package.json index d97ca794..ab4e7a49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.25", + "version": "0.1.26", "private": true, "license": "AGPL-3.0-only", "engines": { diff --git a/plugins/ca-codex/hooks/_sloplib.py b/plugins/ca-codex/hooks/_sloplib.py index 36c42377..413fe6e3 100644 --- a/plugins/ca-codex/hooks/_sloplib.py +++ b/plugins/ca-codex/hooks/_sloplib.py @@ -34,10 +34,28 @@ # A letter or digit (Unicode), used to confirm a dash actually joins two text # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell). _WORD_RE = re.compile(r"[^\W_]", re.UNICODE) +# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence +# separator, and the rule this detector enforces says "sentence separators" - +# site/VOICE.md's own Terminology anchors section is written in exactly this +# form, so flagging it would make the gate contradict the style guide it exists +# to enforce (#338). Anchored to the start of the line and to a bolded lead-in, +# so an ordinary sentence that happens to contain bold text is untouched. +# +# The TERM is captured and kept; only the dash is dropped. Blanking the whole +# lead-in was the first cut and it was a false-negative generator: on +# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls. +# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks, +# and the SECOND dash - a real separator - was left with no word character on +# its left and escaped. That loosened the shared detector for docs/** and +# repo-root too, not only for site prose. +_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]") def _prose_only(line): """Drop the spans §3.A exempts so only candidate prose remains.""" + # Only the definition DASH is dropped; the term is kept (group 1), so a + # later separator on the same line still has its left-hand context. + line = _DEFINITION_RE.sub(r"\1 ", line) line = _INLINE_CODE_RE.sub(" ", line) line = _URL_RE.sub(" ", line) line = _RANGE_RE.sub(" ", line) @@ -78,19 +96,44 @@ def find_prose_separator_dashes(text): return findings +# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence +# separators since 2026-07-02, and nothing enforced it: this predicate covered +# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 +# of its own 36 authored files, is worse than no rule - reviewers cite it and it +# is wrong. +# +# Scope is AUTHORED prose only. Everything under content/docs/reference/ is +# generated on every build (91 of the 128 files there), changelog.md is a +# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors +# plugins/ca bodies - which are framework prose under a different register, and +# already excluded via plugins/. Flagging any of those would report a finding +# nobody wrote and nobody can fix in place. +_SITE_PROSE_ROOT = "site/src/content/docs/" +_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md") + + def in_antislop_doc_scope(rel_path): """True for user-facing Markdown the anti-slop bundle governs: repo-root - community docs and docs/**. Excludes codeArbiter's own framework bodies - (everything under plugins/) and machine-managed .codearbiter/ state.""" + community docs, docs/**, and AUTHORED site prose under + site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies + (everything under plugins/), machine-managed .codearbiter/ state, and every + generated site page.""" if not rel_path: return False p = rel_path.replace("\\", "/") if p.startswith("./"): p = p[2:] - if not p.lower().endswith(".md"): + # `.mdx` counts: the rule is about prose, not about a file extension, and + # two authored site pages are .mdx. + if not p.lower().endswith((".md", ".mdx")): return False if p.startswith("plugins/") or p.startswith(".codearbiter/"): return False + if p.startswith(_SITE_PROSE_ROOT): + rest = p[len(_SITE_PROSE_ROOT):] + return not rest.startswith(_SITE_PROSE_EXCLUDED) + if p.startswith("site/"): + return False if p.startswith("docs/"): return True return "/" not in p diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md index 9837f4ab..20c2ab9c 100644 --- a/plugins/ca-pi/CHANGELOG.md +++ b/plugins/ca-pi/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to `ca-pi` are documented in this file. +## [0.1.26] - 2026-07-25 + +### Fixed + +- The anti-slop dash detector no longer flags a definition-list dash + (`- **term** - meaning`). The rule it enforces is about SENTENCE separators, + and the style guides are themselves written in that form, so the detector was + contradicting the guides it exists to serve. A real separator later on the + same line is still reported: only the definition dash is dropped, never the + term before it (issue #338). + +### Changed + +- The detector's document scope now covers authored site prose, and recognises + `.mdx` as prose rather than keying on `.md` alone. + ## [0.1.25] - 2026-07-25 ### Fixed diff --git a/plugins/ca-pi/hooks/_sloplib.py b/plugins/ca-pi/hooks/_sloplib.py index 36c42377..413fe6e3 100644 --- a/plugins/ca-pi/hooks/_sloplib.py +++ b/plugins/ca-pi/hooks/_sloplib.py @@ -34,10 +34,28 @@ # A letter or digit (Unicode), used to confirm a dash actually joins two text # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell). _WORD_RE = re.compile(r"[^\W_]", re.UNICODE) +# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence +# separator, and the rule this detector enforces says "sentence separators" - +# site/VOICE.md's own Terminology anchors section is written in exactly this +# form, so flagging it would make the gate contradict the style guide it exists +# to enforce (#338). Anchored to the start of the line and to a bolded lead-in, +# so an ordinary sentence that happens to contain bold text is untouched. +# +# The TERM is captured and kept; only the dash is dropped. Blanking the whole +# lead-in was the first cut and it was a false-negative generator: on +# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls. +# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks, +# and the SECOND dash - a real separator - was left with no word character on +# its left and escaped. That loosened the shared detector for docs/** and +# repo-root too, not only for site prose. +_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]") def _prose_only(line): """Drop the spans §3.A exempts so only candidate prose remains.""" + # Only the definition DASH is dropped; the term is kept (group 1), so a + # later separator on the same line still has its left-hand context. + line = _DEFINITION_RE.sub(r"\1 ", line) line = _INLINE_CODE_RE.sub(" ", line) line = _URL_RE.sub(" ", line) line = _RANGE_RE.sub(" ", line) @@ -78,19 +96,44 @@ def find_prose_separator_dashes(text): return findings +# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence +# separators since 2026-07-02, and nothing enforced it: this predicate covered +# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 +# of its own 36 authored files, is worse than no rule - reviewers cite it and it +# is wrong. +# +# Scope is AUTHORED prose only. Everything under content/docs/reference/ is +# generated on every build (91 of the 128 files there), changelog.md is a +# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors +# plugins/ca bodies - which are framework prose under a different register, and +# already excluded via plugins/. Flagging any of those would report a finding +# nobody wrote and nobody can fix in place. +_SITE_PROSE_ROOT = "site/src/content/docs/" +_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md") + + def in_antislop_doc_scope(rel_path): """True for user-facing Markdown the anti-slop bundle governs: repo-root - community docs and docs/**. Excludes codeArbiter's own framework bodies - (everything under plugins/) and machine-managed .codearbiter/ state.""" + community docs, docs/**, and AUTHORED site prose under + site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies + (everything under plugins/), machine-managed .codearbiter/ state, and every + generated site page.""" if not rel_path: return False p = rel_path.replace("\\", "/") if p.startswith("./"): p = p[2:] - if not p.lower().endswith(".md"): + # `.mdx` counts: the rule is about prose, not about a file extension, and + # two authored site pages are .mdx. + if not p.lower().endswith((".md", ".mdx")): return False if p.startswith("plugins/") or p.startswith(".codearbiter/"): return False + if p.startswith(_SITE_PROSE_ROOT): + rest = p[len(_SITE_PROSE_ROOT):] + return not rest.startswith(_SITE_PROSE_EXCLUDED) + if p.startswith("site/"): + return False if p.startswith("docs/"): return True return "/" not in p diff --git a/plugins/ca-pi/package.json b/plugins/ca-pi/package.json index 568fbc83..2559fe37 100644 --- a/plugins/ca-pi/package.json +++ b/plugins/ca-pi/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.25", + "version": "0.1.26", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/plugins/ca/hooks/_sloplib.py b/plugins/ca/hooks/_sloplib.py index 36c42377..413fe6e3 100644 --- a/plugins/ca/hooks/_sloplib.py +++ b/plugins/ca/hooks/_sloplib.py @@ -34,10 +34,28 @@ # A letter or digit (Unicode), used to confirm a dash actually joins two text # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell). _WORD_RE = re.compile(r"[^\W_]", re.UNICODE) +# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence +# separator, and the rule this detector enforces says "sentence separators" - +# site/VOICE.md's own Terminology anchors section is written in exactly this +# form, so flagging it would make the gate contradict the style guide it exists +# to enforce (#338). Anchored to the start of the line and to a bolded lead-in, +# so an ordinary sentence that happens to contain bold text is untouched. +# +# The TERM is captured and kept; only the dash is dropped. Blanking the whole +# lead-in was the first cut and it was a false-negative generator: on +# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls. +# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks, +# and the SECOND dash - a real separator - was left with no word character on +# its left and escaped. That loosened the shared detector for docs/** and +# repo-root too, not only for site prose. +_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]") def _prose_only(line): """Drop the spans §3.A exempts so only candidate prose remains.""" + # Only the definition DASH is dropped; the term is kept (group 1), so a + # later separator on the same line still has its left-hand context. + line = _DEFINITION_RE.sub(r"\1 ", line) line = _INLINE_CODE_RE.sub(" ", line) line = _URL_RE.sub(" ", line) line = _RANGE_RE.sub(" ", line) @@ -78,19 +96,44 @@ def find_prose_separator_dashes(text): return findings +# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence +# separators since 2026-07-02, and nothing enforced it: this predicate covered +# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 +# of its own 36 authored files, is worse than no rule - reviewers cite it and it +# is wrong. +# +# Scope is AUTHORED prose only. Everything under content/docs/reference/ is +# generated on every build (91 of the 128 files there), changelog.md is a +# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors +# plugins/ca bodies - which are framework prose under a different register, and +# already excluded via plugins/. Flagging any of those would report a finding +# nobody wrote and nobody can fix in place. +_SITE_PROSE_ROOT = "site/src/content/docs/" +_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md") + + def in_antislop_doc_scope(rel_path): """True for user-facing Markdown the anti-slop bundle governs: repo-root - community docs and docs/**. Excludes codeArbiter's own framework bodies - (everything under plugins/) and machine-managed .codearbiter/ state.""" + community docs, docs/**, and AUTHORED site prose under + site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies + (everything under plugins/), machine-managed .codearbiter/ state, and every + generated site page.""" if not rel_path: return False p = rel_path.replace("\\", "/") if p.startswith("./"): p = p[2:] - if not p.lower().endswith(".md"): + # `.mdx` counts: the rule is about prose, not about a file extension, and + # two authored site pages are .mdx. + if not p.lower().endswith((".md", ".mdx")): return False if p.startswith("plugins/") or p.startswith(".codearbiter/"): return False + if p.startswith(_SITE_PROSE_ROOT): + rest = p[len(_SITE_PROSE_ROOT):] + return not rest.startswith(_SITE_PROSE_EXCLUDED) + if p.startswith("site/"): + return False if p.startswith("docs/"): return True return "/" not in p diff --git a/plugins/ca/hooks/tests/test_sloplib.py b/plugins/ca/hooks/tests/test_sloplib.py index 590595a6..841a4444 100644 --- a/plugins/ca/hooks/tests/test_sloplib.py +++ b/plugins/ca/hooks/tests/test_sloplib.py @@ -119,5 +119,108 @@ def test_empty_or_falsy_path_out_of_scope(self): self.assertFalse(S.in_antislop_doc_scope(None)) + + +class TestSitePoseScope(unittest.TestCase): + """#338 - site/VOICE.md has banned em-dashes as sentence separators since + 2026-07-02, and nothing enforced it: `in_antislop_doc_scope` covered + repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 + of its own 36 authored files, is worse than no rule - reviewers cite it and + it is wrong. + + The scope is AUTHORED site prose only. Everything generated is excluded, by + path, because the generator would otherwise be flagged for output nobody + writes by hand and nobody can fix in place.""" + + def test_authored_site_docs_in_scope(self): + for p in ( + "site/src/content/docs/overview.md", + "site/src/content/docs/guides/uninstalling.md", + "site/src/content/docs/concepts/hardening-history.md", + "site/src/content/docs/getting-started/install.md", + "site/src/content/docs/feature-forge/index.md", + ): + self.assertTrue(S.in_antislop_doc_scope(p), p) + + def test_generated_reference_pages_out_of_scope(self): + # 91 of the 128 files under content/docs are generated into reference/ + # on every build. They are not authored, not committed, and not fixable + # in place. + for p in ( + "site/src/content/docs/reference/commands.md", + "site/src/content/docs/reference/agents/backend-author.md", + ): + self.assertFalse(S.in_antislop_doc_scope(p), p) + + def test_generated_changelog_page_out_of_scope(self): + # Verbatim pass-through of the repo CHANGELOG, which is payload prose + # under a different voice. + self.assertFalse(S.in_antislop_doc_scope("site/src/content/docs/changelog.md")) + + def test_curated_plugin_mirrors_out_of_scope(self): + # site/src/curated/** mirrors plugins/ca bodies. Those are framework + # prose governed by the plugin's own register, already excluded via + # plugins/ - mirroring them into site must not smuggle them back in. + for p in ( + "site/src/curated/agents/backend-author.md", + "site/src/curated/commands/fix.md", + ): + self.assertFalse(S.in_antislop_doc_scope(p), p) + + def test_site_source_that_is_not_docs_prose_out_of_scope(self): + for p in ("site/README.md", "site/VOICE.md", "site/src/components/Thing.md"): + self.assertFalse(S.in_antislop_doc_scope(p), p) + + def test_mdx_authored_pages_are_in_scope_too(self): + # Two authored pages are .mdx. The predicate keyed on ".md" only, so + # they were invisible to a rule that is about prose, not file format. + self.assertTrue(S.in_antislop_doc_scope("site/src/content/docs/index.mdx")) + + + + +class TestDefinitionListDashExempt(unittest.TestCase): + """#338 - the rule is about SENTENCE separators. A definition-list dash is + structural, and site/VOICE.md's own Terminology anchors are written in that + exact form, so flagging it would make the gate contradict the guide.""" + + def test_definition_list_dash_is_not_a_finding(self): + for line in ( + "- **gate** — a phase exit condition (STOP/BLOCK).", + "* **lane** — a sanctioned path through the system.", + "**skill** — an orchestrator routine with phases.", + ): + self.assertEqual(S.find_prose_separator_dashes(line), [], line) + + def test_a_real_separator_after_a_definition_is_still_caught(self): + # Only the definition DASH is dropped, never the term before it. A + # second dash on the same line is still a separator and must not ride in + # behind the exemption. + line = "- **gate** — a phase exit condition — and it blocks the call." + self.assertEqual(len(S.find_prose_separator_dashes(line)), 1) + + def test_definition_exemption_survives_inline_code_after_it(self): + # The regression this exemption first shipped with, and the reason the + # test above was not enough: dropping the whole `- **term**` lead-in left + # the NEXT dash with no word character on its left once + # _INLINE_CODE_RE blanked the backticks, so a real separator vanished. + # Verbatim from site/src/content/docs/getting-started/compatibility.md, + # which the detector scored 1 before the exemption and 0 after. + line = "- **The gate-enforcement hooks** — `pre-bash.py`, `pre-write.py` — make **zero** network calls." + self.assertEqual(len(S.find_prose_separator_dashes(line)), 1) + + def test_definition_term_is_preserved_for_downstream_checks(self): + # Stated as a property, not an implementation detail: whatever the + # exemption does, the term's own words must survive into the analysed + # text, or any later dash on the line loses its left-hand context. + self.assertIn("gate", S._prose_only("- **gate** — a phase exit condition")) + + def test_bold_mid_sentence_is_not_treated_as_a_definition(self): + # The exemption is anchored to the start of the line, so an ordinary + # sentence containing bold text keeps its finding. + line = "The **commit gate** runs nine checks — every one must be green." + self.assertEqual(len(S.find_prose_separator_dashes(line)), 1) + + if __name__ == "__main__": unittest.main() diff --git a/site/src/content/docs/codearbiter-directory.md b/site/src/content/docs/codearbiter-directory.md index b07c4cf8..979a25e9 100644 --- a/site/src/content/docs/codearbiter-directory.md +++ b/site/src/content/docs/codearbiter-directory.md @@ -4,8 +4,8 @@ description: "Every file and directory under .codearbiter/, what writes it, what --- `.codearbiter/` is codeArbiter's project-state store: a root-level directory, outside `.claude/`, -so it survives even if the plugin itself is uninstalled. It holds the project's own record — its -spec, task board, decisions, and audit trail — separate from the plugin code that reads and +so it survives even if the plugin itself is uninstalled. It holds the project's own record (its +spec, task board, decisions, and audit trail), separate from the plugin code that reads and writes it. `/ca:init` scaffolds it; `/ca:create-context` (existing codebase) or `/ca:decompose` (greenfield) populates it. Its presence and content is the plugin's actual contract with your repo: if you want to know what codeArbiter knows about your project, or what it will do next, @@ -22,23 +22,23 @@ those are noted below. | Path | Written by | Read by | Editable by hand? | |---|---|---|---| -| `CONTEXT.md` | `/ca:init`, `/ca:decompose`, `/ca:create-context` | every enforcement hook (`arbiter_active()`), the orchestrator | Yes, but the frontmatter is guarded — see below | -| `open-tasks.md` | `/ca:task` only | `/ca:status`, the statusline, `SessionStart` | No — guarded to `/ca:task` | +| `CONTEXT.md` | `/ca:init`, `/ca:decompose`, `/ca:create-context` | every enforcement hook (`arbiter_active()`), the orchestrator | Yes, but the frontmatter is guarded (see below) | +| `open-tasks.md` | `/ca:task` only | `/ca:status`, the statusline, `SessionStart` | No: guarded to `/ca:task` | | `open-questions.md` | the orchestrator, when a `[CONFIRM-NN]` is raised | `/ca:status`, `SessionStart`, the statusline | Yes | -| `decisions/*.md`, `decision-log.md` | `/ca:adr` only | `/ca:adr-status`, `/ca:reconcile`, `post-write-edit.py` (H-12) | No — guarded to `/ca:adr` | +| `decisions/*.md`, `decision-log.md` | `/ca:adr` only | `/ca:adr-status`, `/ca:reconcile`, `post-write-edit.py` (H-12) | No: guarded to `/ca:adr` | | `specs/*.md` | `brainstorming` skill (via `/ca:feature`, `/ca:sprint`) | `writing-plans`, `/ca:status` | Yes | | `plans/*.md` | `writing-plans` skill | `executing-plans`, `subagent-driven-development`, `/ca:status` | Yes | | `checkpoints/*.md` | `checkpoint-aggregator` agent (`/ca:checkpoint`) | `/ca:audit`, `/ca:status`-adjacent reads | Yes | | `audits/*.md` | `/ca:audit` | humans (report only) | Yes | | `reports//` | `/ca:tribunal` | `/ca:tribunal` on resume, the filing gate | Yes, but not while a run is in flight | -| `sprint-log.md` | `/ca:sprint` | `/ca:status`, `/ca:audit` | No — append-only, guarded (H-05) | -| `overrides.log` | `/ca:override`, `/ca:dev` entry/exit | statusline, `/ca:status`, `/ca:audit`, staleness-warn | No — append-only, guarded (H-05) | -| `triage.log` | `/ca:feature` small-lane triage | `/ca:metrics`, `/ca:audit` | No — append-only, guarded (H-05) | -| `gate-events.log` | every `block()`/`remind()`/`warn()` call in the hooks | (durable sink; no reader ships yet) | No — append-only, guarded (H-05) | -| `.markers/` | `security-pass.py`, `migration-pass.py`, `/ca:dev`, `/ca:adr` | the commit-gate hooks (`pre-bash.py`, `pre-write.py`, `pre-edit.py`) | No — guarded, and hand-writing one is friction and an audit trail, not a proof (see below) | -| `.provenance/*.json` | `context-creation`, `decompose`, `context-check` (re-scout/re-baseline) | `SessionStart` (drift line), `commit-gate` (auto-heal) | Not by hand — regenerate via `/ca:context-check` | +| `sprint-log.md` | `/ca:sprint` | `/ca:status`, `/ca:audit` | No: append-only, guarded (H-05) | +| `overrides.log` | `/ca:override`, `/ca:dev` entry/exit | statusline, `/ca:status`, `/ca:audit`, staleness-warn | No: append-only, guarded (H-05) | +| `triage.log` | `/ca:feature` small-lane triage | `/ca:metrics`, `/ca:audit` | No: append-only, guarded (H-05) | +| `gate-events.log` | every `block()`/`remind()`/`warn()` call in the hooks | (durable sink; no reader ships yet) | No: append-only, guarded (H-05) | +| `.markers/` | `security-pass.py`, `migration-pass.py`, `/ca:dev`, `/ca:adr` | the commit-gate hooks (`pre-bash.py`, `pre-write.py`, `pre-edit.py`) | No: guarded, and hand-writing one is friction and an audit trail, not a proof (see below) | +| `.provenance/*.json` | `context-creation`, `decompose`, `context-check` (re-scout/re-baseline) | `SessionStart` (drift line), `commit-gate` (auto-heal) | Not by hand: regenerate via `/ca:context-check` | | `security-controls.md`, `tech-stack.md`, `coding-standards.md` | `/ca:init` / `/ca:create-context` / `/ca:decompose`, kept current by hand or `/ca:context-check` | every reviewer agent, the crypto/secret gates | Yes | -| `last-checkpoint` | `checkpoint-aggregator` agent | `/ca:status`, the statusline (overrides-since-checkpoint) | Not normally — it's a counter, not a note | +| `last-checkpoint` | `checkpoint-aggregator` agent | `/ca:status`, the statusline (overrides-since-checkpoint) | Not normally: it's a counter, not a note | | `spikes/*.md` | `/ca:spike` | humans, `/ca:feature` (seeds `brainstorming`) | Yes | ## CONTEXT.md @@ -46,7 +46,7 @@ those are noted below. The activation contract. Its leading YAML frontmatter carries two load-bearing keys: - **`arbiter: enabled`** — the single flag every enforcement hook checks via `arbiter_active()` - (`plugins/ca/hooks/_hooklib.py`). It must appear inside a *properly closed* frontmatter block — + (`plugins/ca/hooks/_hooklib.py`). It must appear inside a *properly closed* frontmatter block: `---` opens on line 1, `arbiter: enabled` somewhere inside, `---` closes it. A block that opens but never closes is treated as **malformed**, not disabled, and is surfaced as an error rather than silently ignored. A file with no frontmatter at all is simply dormant. No frontmatter, @@ -75,35 +75,35 @@ frontmatter are both in place, the orchestrator persona loads on every session i **Readers:** every enforcement hook, the `SessionStart` injector, the statusline. **Editable by hand?** The prose body, yes. The frontmatter is guarded: a Write/Edit that would flip `arbiter: enabled` off, or corrupt the block, is blocked (`pre-write.py`/`pre-edit.py`, -issue #159) — the file that turns every gate off can't itself be edited past the gate. -**Delete it:** codeArbiter goes fully dormant in this repo on the next session — no persona, no -enforcement, nothing loads. The rest of `.codearbiter/` is untouched on disk; recreating +issue #159). The file that turns every gate off can't itself be edited past the gate. +**Delete it:** codeArbiter goes fully dormant in this repo on the next session (no persona, no +enforcement, nothing loads). The rest of `.codearbiter/` is untouched on disk; recreating `CONTEXT.md` (or running `/ca:init` again) reactivates it against whatever state remains. ## open-tasks.md -The [board](/glossary/#board): one top-level `- ` bullet per task, in one of three states — +The [board](/glossary/#board): one top-level `- ` bullet per task, in one of three states: queued (`- [ ]`), in-progress (`- [~]`, always carrying a started date), or done (`- [x]`). The only sanctioned writer is `/ca:task` (`add` / `start` / `done`), which calls the pure `_taskboardlib` transforms via `taskwrite.py` so every entry stays schema-conformant and every -transition dated — hand-editing risked malforming the schema, which is why the command exists. +transition dated. Hand-editing risked malforming the schema, which is why the command exists. A task's `[x]` done-flip, `[~]` start-flip, or new `[ ]` entry rides the work commit itself through commit-gate (ADR-0008); there's no separate lagging board-only PR. **Writers:** `/ca:task` only. **Readers:** `/ca:status`, the statusline, `SessionStart` (in-flight -count and staleness). **Editable by hand?** No — guarded to the one writer. +count and staleness). **Editable by hand?** No: guarded to the one writer. **Delete it:** the board is empty going forward; nothing else breaks, but every count that reads it (statusline, `/ca:status`) reports zero until tasks are re-added. ## open-questions.md -The record of unresolved `[CONFIRM-NN]` items — numbered placeholders for a question only the +The record of unresolved `[CONFIRM-NN]` items: numbered placeholders for a question only the user can answer, per the terminology lock in `ORCHESTRATOR.md` §0.1. An open `CONFIRM-NN` blocks stage promotion until it's resolved; it is never guessed at or resolved inside an ADR. The `SessionStart` hook and the statusline both count occurrences here. **Writers:** the orchestrator, when a skill surfaces a genuine unknown. **Readers:** `/ca:status`, -`SessionStart`, the statusline. **Editable by hand?** Yes — resolving a `CONFIRM-NN` is a prose +`SessionStart`, the statusline. **Editable by hand?** Yes: resolving a `CONFIRM-NN` is a prose edit recording the decision and its date. **Delete it:** any `[CONFIRM-NN]` count in the statusline or `/ca:status` reads zero; a future skill that needs to raise one recreates the file. @@ -115,12 +115,12 @@ entry. `/ca:adr` is the only sanctioned author; both the shell flank (redirects, targeting `decisions/`) and the Write/Edit flank are guarded (H-11) so an ADR can't be fabricated, edited after the fact, or slipped in outside the command. Status moves through `proposed -> accepted -> superseded | rejected`; a superseding decision never rewrites the prior -file — it appends a new numbered ADR whose own text names what it supersedes, and a new +file. It appends a new numbered ADR whose own text names what it supersedes, and a new `decision-log.md` entry does the same. **Writers:** `/ca:adr` (via the `decision-lifecycle` skill) only. **Readers:** `/ca:adr-status`, `/ca:reconcile`, and `post-write-edit.py`'s H-12 advisory (a file matching an ADR's `governs:` -glob gets a reminder on every future touch). **Editable by hand?** No — guarded. +glob gets a reminder on every future touch). **Editable by hand?** No: guarded. **Delete it:** the project's decision history is gone; `governs:` reminders for any ADR that used to cover a file stop firing, and `/ca:adr-status` has nothing to report. @@ -131,7 +131,7 @@ approved (`/ca:feature`, `/ca:sprint`). `writing-plans` reads an approved spec t plan, and `/ca:status` lists every slug here alongside its plan to show how far each pipeline got. **Writers:** `brainstorming`. **Readers:** `writing-plans`, `/ca:status`. -**Editable by hand?** Yes — specs are prose documents; edit for clarity, but avoid rewriting +**Editable by hand?** Yes. Specs are prose documents; edit for clarity, but avoid rewriting acceptance criteria the plan and its tests already trace against. **Delete it:** `/ca:status` no longer lists that pipeline; a plan that referenced the missing spec still runs (the plan is self-contained), but nothing can re-derive it from the (now-gone) spec. @@ -153,8 +153,8 @@ resumption point; `/ca:status` can no longer report that pipeline's progress. One dated report per `/ca:checkpoint` sweep (`YYYY-MM-DD.md`), written by the `checkpoint-aggregator` agent from the finding-triage and decision-challenger output. Findings are classified by severity; the ones blocking the current change are called out, the rest recorded -for later. `/ca:tribunal`'s deep-audit output is a distinct artifact (`reports//`, below) -— checkpoints are the lean, routine sweep. +for later. `/ca:tribunal`'s deep-audit output is a distinct artifact (`reports//`, below). +Checkpoints are the lean, routine sweep. **Writers:** `checkpoint-aggregator`. **Readers:** humans (the report is the deliverable); `/ca:audit` pulls still-open findings from the most recent one. **Editable by hand?** Yes, though @@ -165,7 +165,7 @@ recreates the directory and writes fresh. One dated governance packet per `/ca:audit` run (`YYYY-MM-DD.md`), assembling commits, overrides, ADRs, sprint auto-decisions, open questions, and open checkpoint findings for a given range into -one document. `/ca:audit` is read-only against everything it summarizes — it never mutates +one document. `/ca:audit` is read-only against everything it summarizes: it never mutates `overrides.log`, `decisions/`, or any of its other sources. **Writers:** `/ca:audit`. **Readers:** humans. **Editable by hand?** Yes. @@ -179,25 +179,25 @@ presence, since `/ca:audit` re-derives everything from the underlying logs and f reuses the same `run-id` (the date is the run's creation date and never changes on resume). Every lens's findings are written one file per finding, plus append-only triage/run logs, so an interrupted deep audit picks back up from disk instead of restarting. Tribunal writes are confined -to this directory until the filing gate — it never edits, refactors, or commits project code. +to this directory until the filing gate. It never edits, refactors, or commits project code. **Writers:** `/ca:tribunal` only. **Readers:** `/ca:tribunal` itself, on resume, and the filing gate that turns approved findings into GitHub issues. **Editable by hand?** Not while a run is -in flight — you'd desync the resumable state. **Delete it:** an in-flight tribunal run can no +in flight: you'd desync the resumable state. **Delete it:** an in-flight tribunal run can no longer resume and restarts from scratch on next invocation; completed runs' local record is gone -(filed GitHub issues, if any, are unaffected — they live on GitHub, not here). +(filed GitHub issues, if any, are unaffected: they live on GitHub, not here). ## sprint-log.md The append-only ledger of every SMARTS-scored auto-decision an autonomous `/ca:sprint` makes on a non-hard-gate point, each entry carrying a confidence flag (`low` entries are the ones worth -reviewing after the fact). Hard gates — security controls, auth/crypto/secrets, irreversible -operations, an unresolved `[CONFIRM-NN]`, a merge to default — are never auto-decided and never +reviewing after the fact). Hard gates (security controls, auth/crypto/secrets, irreversible +operations, an unresolved `[CONFIRM-NN]`, a merge to default) are never auto-decided and never appear here as a resolved decision; they stop and surface to the user instead. **Writers:** `/ca:sprint`. **Readers:** `/ca:status`, `/ca:audit`, the staleness-warn check (a sprint marked active for over 30 minutes with no matching log activity gets a WARN). -**Editable by hand?** No — one of the four append-only audit logs (H-05): a shell truncation/ +**Editable by hand?** No. One of the four append-only audit logs (H-05): a shell truncation/ rewrite aimed at it is blocked, and a Write/Edit must be a verifiable tail-anchored append, never an overwrite or an empty-`old_string` edit passed off as one. **Delete it:** the sprint decision trail for past runs is gone; a `/ca:sprint` in progress that @@ -213,10 +213,10 @@ rather than recording an empty `BY:` field. The statusline counts entries newer `last-checkpoint` marker as "overrides since last checkpoint." **Writers:** `/ca:override`, `/ca:dev` (entry/exit lines). **Readers:** statusline, `/ca:status`, -`/ca:audit`, the CONFIRM-09 staleness-warn check. **Editable by hand?** No — H-05 guarded, same as +`/ca:audit`, the CONFIRM-09 staleness-warn check. **Editable by hand?** No. H-05 guarded, same as `sprint-log.md`: append-only, tail-anchored edits only, no truncation or rewrite. -**Delete it:** the entire override history is gone — a genuine loss, since this is the one -mechanical record that a bypass happened at all. codeArbiter keeps functioning; the audit trail +**Delete it:** the entire override history is gone (a genuine loss, since this is the one +mechanical record that a bypass happened at all). codeArbiter keeps functioning; the audit trail does not recover. ## triage.log @@ -226,36 +226,36 @@ reasoning behind routing a change to the lighter lane instead of the full spec-t `/ca:metrics` reads it to compute the small-lane rate trend. **Writers:** `/ca:feature` (small-lane triage step). **Readers:** `/ca:metrics`, `/ca:audit`. -**Editable by hand?** No — H-05 guarded, same append-only contract as the other three logs. +**Editable by hand?** No: H-05 guarded, same append-only contract as the other three logs. **Delete it:** the small-lane classification history is gone; `/ca:metrics`' small-lane-rate trend has nothing to compare against until new entries accumulate. ## gate-events.log -The durable, mechanical sink every `block()`, `remind()`, and `warn()` call in the hooks appends -one line to — `[ISO-8601Z] KIND [tag] host= hook=