From 971d1db51608e84e3e9113ac26cfd1e164afec03 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 10 Aug 2026 14:13:24 +0000 Subject: [PATCH 1/5] gate the two-file agent-notes contract, with the matcher derived by counting this tree The 2026-08-04 split made every anchor between CLAUDE.md and documentation/agent-notes.md load-bearing, and nothing checked them. scripts/check-agent-notes.ts verifies the narrative file is tracked, that no section is emptied down to its heading (a container whose body is its subsections is exempt and counted), and that every pointer at it resolves. It runs from test/scripts/agent-notes.test.ts, so it rides the required ci / verify contexts and prepublishOnly rather than adding a fourth workflow. THE MATCHER WAS DERIVED BY COUNTING BOTH SPELLINGS HERE, NOT PORTED. Porting without re-counting has produced a false green twice: a qualified-only matcher would have covered 3 of ncpdp's 38 pointers, and would have matched nothing at all in terminology (42 bare, zero qualified) while still exiting 0. Measured on this tree: 11 qualified pointers, all in CLAUDE.md, and zero bare pointers. So this gate matches the qualified form only, and a bare-form census refuses the run at exit 2 if that spelling ever appears, because the evidence the scope rests on would have gone stale. No count is written into the gate's prose as a promise; the OK line prints them all on every run. PROVEN NON-VACUOUS AGAINST A CLONE OF THE REAL TREE before it was believed: a misspelled real anchor and an emptied real section each exit 1; a deleted narrative file exits 1 with the pair and every pointer; a bare pointer that RESOLVES exits 2; a neutered matcher and a tree with every pointer rewritten out of range both exit 2 rather than going green. Re-derived for this repository rather than inherited: the exit codes come from scripts/phi-scan.ts, and an unmerged path REFUSES here even though phi-scan leaves that status unenumerated, because its reasoning (git commit refuses an unmerged index) covers a staged route this gate does not have. The NUL skip is a disclosed miss and is required rather than tidy: the tree tracks vendored tarballs and a synthetic DICOM fixture. A draft claimed that skip differs from check-no-emdash.sh's partition; that was false and was corrected by reading that gate's own OK line. Both key on an actual NUL byte. CLAUDE.md sat at its byte budget, so the branch-protection, PHI-residual and em-dash blocks were compressed to their imperatives and the reasoning each one compresses (already present in the narrative file) now carries it. No trap was deleted and no ceiling was raised. Two verify.sh steps are red and BOTH REPRODUCE ON THE BASE 0a1b322: pnpm audit (advisories in @modelcontextprotocol/sdk transitive deps) and the licenses gate (a local pnpm store index error for the vendored fhir tarball). Neither is touched by this slice, which adds no dependency. --- .changeset/lucky-donkeys-smile.md | 10 + CHANGELOG.md | 49 ++ CLAUDE.md | 94 +-- documentation/agent-notes.md | 136 ++++ package.json | 1 + scripts/check-agent-notes.ts | 1167 +++++++++++++++++++++++++++++ test/scripts/agent-notes.test.ts | 771 +++++++++++++++++++ 7 files changed, 2181 insertions(+), 47 deletions(-) create mode 100644 .changeset/lucky-donkeys-smile.md create mode 100644 scripts/check-agent-notes.ts create mode 100644 test/scripts/agent-notes.test.ts diff --git a/.changeset/lucky-donkeys-smile.md b/.changeset/lucky-donkeys-smile.md new file mode 100644 index 0000000..2e2eedf --- /dev/null +++ b/.changeset/lucky-donkeys-smile.md @@ -0,0 +1,10 @@ +--- +"@cosyte/cli": patch +--- + +Gate the two-file agent-guidance contract with `pnpm check:agent-notes`, run from the test suite so +it rides the required CI contexts and `prepublishOnly`. The narrative file must be tracked, no +section may be emptied down to its heading, and every pointer at it must resolve. The matcher was +derived by counting both pointer spellings on this tree rather than ported from a sibling, a bare-form +census refuses if the spelling this scope rests on ever changes, and the gate refuses at exit 2 +rather than reporting green over a corpus it never opened. diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b97aa..22bea1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,55 @@ still do. Each entry was assigned to the release whose tag first contains it, re ## [Unreleased] +### Added + +- **The two-file agent-guidance contract is now gated (`pnpm check:agent-notes`).** `CLAUDE.md` was + split from `documentation/agent-notes.md` on 2026-08-04, which made every anchor between them + load-bearing, and nothing checked them. `scripts/check-agent-notes.ts` now verifies that the + narrative file is tracked, that no section is emptied down to its heading (a container whose body is + its subsections is exempt and counted), and that every pointer at it resolves. It runs from + `test/scripts/agent-notes.test.ts`, so it rides the required `ci / verify` contexts and + `prepublishOnly` rather than adding a fourth workflow. + - **The matcher was derived by counting this tree, not by porting a sibling's.** Two pointer + spellings are live across these repositories, and porting without re-counting has produced a false + green twice: a qualified-only matcher would have covered 3 of `ncpdp`'s 38 pointers, and would + have matched nothing at all in `terminology` (42 bare, zero qualified) while still exiting 0. + **Measured here: every pointer is the qualified `documentation/agent-notes.md#` form and no + bare pointer exists**, so the gate matches that form only. No count is written into the gate's + prose as a promise; the OK line prints all of them on every run, because it measures rather than + remembers. + - **A bare-form census keeps that scope honest.** Matching one spelling is safe only while the other + stays absent, so in the pair every backticked `#` span is enumerated: a digits-only one is + a pull-request reference and is counted and reported, and **any other one refuses the run at exit + 2** with an instruction to re-derive the matcher. Refusal rather than a finding, because the tree + has not necessarily broken but the evidence the scope rested on has. + - **It refuses rather than reporting green over a corpus it never opened.** There is no declared + scan root to be wrong about: the corpus is `git ls-files`, reconciled as **sets of paths**, and + zero pointers, zero tracked files, an unmerged path, a symlink, a non-regular file or two files + carrying the contract basename each refuse at exit 2. This repository has already shipped the + opposite defect once, in `phi-scan`, which printed `OK, no hits` over a root it never walked. + - **Every claim was watched to fail on a clone of the real tree** before the gate was believed: a + misspelled real anchor, an emptied real section, the narrative file deleted, a bare pointer that + resolves, a neutered matcher, and a tree with every pointer rewritten out of range. The last two + are the `terminology` scenario, and both refuse instead of going green. + - **Exit codes and corpus handling were re-derived from this repository, not inherited.** `0`, `1` + for a finding and `2` for a refusal come from `scripts/phi-scan.ts`. An unmerged path **refuses** + here even though `phi-scan` leaves that status unenumerated, because its reasoning (`git commit` + refuses an unmerged index) covers a staged route this gate does not have. + - **The NUL skip is a disclosed miss, not a pass**, and is required rather than tidy: the tree + tracks vendored `@cosyte/*` tarballs and a synthetic DICOM fixture, none readable as markdown or + editable to clear a red. The tell is the skipped count on the OK line. A draft claimed this + partition differs from `check-no-emdash.sh`'s; **that was false and was corrected by reading that + gate's own OK line.** Both key on an actual NUL byte; the wider set is git's own binary + classification, which is why neither gate may be reduced to `grep -I`. + +### Changed + +- **`CLAUDE.md` narrative was relocated into `documentation/agent-notes.md` to make room for the gate's + rules.** The branch-protection, PHI-scanner-residual and em-dash blocks were compressed to their + imperatives; every trap keeps a one-line rule and a pointer, and the reasoning each one compresses + was already in the narrative file. No trap was deleted and no ceiling was raised. + ### Fixed - **`pnpm phi-scan`'s all-mode walk was rooted at `test/__fixtures__` and `src` only, so 89 of this diff --git a/CLAUDE.md b/CLAUDE.md index 912571a..5ae550f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,17 @@ > imperative with a link to the section that proves it. **"I did not read the reason" is not a licence > to discount the rule.** Every one of these lines cost a defect to learn. +> **The pair is gated** (`pnpm check:agent-notes`, enforced by `test/scripts/agent-notes.test.ts`, so +> it rides `ci / verify` and `prepublishOnly`): the narrative file must be tracked, every section must +> have a body (a container's is its subsections), and every pointer at it **in a file it opened** must +> resolve. **A NUL-bearing file is skipped: a disclosed miss, not a pass**; the tell is the skipped +> count. It matches the **QUALIFIED spelling only**; a **bare** backticked anchor in either half +> **REFUSES the run**: the measurement that scoped the matcher has gone stale. **Never port a +> sibling's matcher without re-counting both spellings here.** It asserts **this repo's promise, not a +> universal**, and **refuses (exit 2) rather than reporting green over a corpus it never opened**. +> **Never clear a red by deleting the pointer or the heading.** Why, and every miss: +> [agent-notes § The gate](documentation/agent-notes.md#the-two-file-contract-gate). + ## Project **`@cosyte/cli`**: the **cosyte CLI**: a **`bin` package** (the `cosyte` command), not a parser and @@ -183,15 +194,13 @@ Why: [agent-notes § The pre-commit PHI gate and git mv](documentation/agent-not - **`scripts/` IS SWEPT: name a PHI shape, never SPELL a literal** (a draft banner red the gate on itself). The SSN check reads **no** allow-list, so an `ID` in dashed shape reds `phi-allow-list.txt`: respell `MRN-`. -- **🛑 THE WIDENING BOUGHT THE SSN/EMAIL FLOOR OVER 38 MORE FILES AND NOTHING ELSE** (all hand-read: - enumeration gap, not exposure). **The recogniser was NOT widened, on measurement** - this floor is - anchor-free, so it never had the "file IS the document" defect, and an escape-decoded view finds - nothing new. **A tripwire reds if that changes**; widen **in addition to** the raw pass. -- **Other residuals:** `D` and `U` are unenumerated (`U` costs nothing that can reach a commit: - `git commit` refuses an unmerged index, exit 128); the routes now differ widely: `--staged` is - `test/__fixtures__` + `src/*.ts` only, CI sweeps the rest. -- **Give `test/scripts/phi-scan.test.ts` explicit timeouts.** Each case spawns `tsx` cold: 0.5s idle, - **3.7s under contention**, against a shared 10s default. +- **🛑 THE WIDENING BOUGHT THE SSN/EMAIL FLOOR OVER 38 MORE FILES AND NOTHING ELSE** (an + enumeration gap, not exposure). **The recogniser was NOT widened, on measurement**, and **a tripwire + reds if that changes**: widen **in addition to** the raw pass, never instead of it. +- **Other residuals:** `D` and `U` are unenumerated (`U` reaches no commit: `git commit` refuses an + unmerged index, exit 128); the two routes now differ widely. +- **Give every `test/scripts/*.test.ts` case an explicit timeout.** Each spawns `tsx` cold: **3.7s + under contention**, against a shared 10s default. - **Assert the premise, not only the remedy.** Two vacuity traps already sprang in this suite: a fixture whose `git merge` refused on an identity-less runner so every later assertion held over an empty result, and a loop that asserted only the detection-OFF side. @@ -226,14 +235,13 @@ Why: [agent-notes § The em-dash brand gate](documentation/agent-notes.md#the-em seeding each with a live em dash. A red on those echoes a whole synthetic message into a public CI log: deliberate and acceptable. - **Fix the script's shared known limits in the shared copies, not here.** A divergent copy is worse - than a shared known limit. The script is composed from three siblings' fixes; understand the - composition before editing it. -- **Re-derive every number before writing it down and never quote one from a sibling's copy.** The - first draft of these notes stated a wrong count _inside the sentence arguing for measurement rigor_. + than a shared known limit; the script is composed from three siblings' fixes. +- **Re-derive every number before writing it down and never quote one from a sibling's copy.** A draft + of these notes stated a wrong count _inside the sentence arguing for measurement rigor_. - **What lands on `main` here differs from `mllp`: all three merge methods are enabled**, so the - **branch commit messages** are the one text that lands under every method, the PR title lands under - two of three, and **the PR body lands under none** (it is scanned anyway, as deliberate - over-strictness). **Do not repeat `ncpdp`'s copy** claiming the title and body are what lands. + **branch commit messages** are the one text that lands under every method and **the PR body lands + under none** (scanned anyway, deliberate over-strictness). **Do not repeat `ncpdp`'s copy** claiming + the title and body are what lands. - **Not retroactive:** commit subjects already on `main` may carry `U+2014`. History is not rewritten. ## Tech Stack (the shared `@cosyte/*` standard) @@ -272,41 +280,33 @@ a summary. Full ruleset, the required-context table and the per-check reasoning: [agent-notes § Branch protection](documentation/agent-notes.md#branch-protection-and-the-limits-of-this-claim). -- `main` is protected by the repository ruleset **`ci-required-checks`** (id `19907924`). Before it - existed every check here was advisory, on the branch that publishes. **Seven** required contexts, - each pinned to **`integration_id: 15368`** so a same-named status from another actor cannot satisfy - it. **Do not quote that count without re-deriving it** (`gh api repos/cosyte/cli/rulesets/19907924`): - it was `6` until `ci / prepublish` was added, and it moves when the called workflow does. +- `main` is protected by the repository ruleset **`ci-required-checks`** (id `19907924`); before it + existed every check here was advisory, on the branch that publishes. Every required context is + pinned to **`integration_id: 15368`** so a same-named status from another actor cannot satisfy it. + **Never quote the context count without re-deriving it** + (`gh api repos/cosyte/cli/rulesets/19907924`): it moves when the called workflow does. - **Read a required context off a REAL check run, never off a workflow's `name:` field.** Requiring a - context nothing emits does not fail a PR: it leaves it **pending and unmergeable forever**, with no - error and no warning. -- **▶ A `ci / *` CONTEXT CAN APPEAR HERE WITH NO COMMIT IN THIS REPO, AND IT ARRIVES NOT REQUIRED.** - `ci.yml` calls `cosyte/.github/.github/workflows/ci.yml@main` unpinned, so a job added upstream - starts emitting a context here that the ruleset does not name: **a red X that does not block a - merge**, which is the failure this repo's whole protection claim exists to prevent. `ci / prepublish` - arrived that way on 2026-08-05 and was unrequired until measured and added. **Census `ci / *` against - a real check run whenever `.github` moves**, then require it or write down why not, in `ci.yml`'s - banner. -- **`no-internal-refs` and `no-emdash` are bare JOB IDS**, not ` / `: they are ordinary - jobs in this repo's own workflows. **Renaming the job silently detaches the required check.** Rename - the job and the ruleset together, or neither. -- **A required job gates all of its steps.** Splitting a step out of `ci / verify` into its own job - silently un-requires it. There is a banner on `ci.yml` where someone would trip it. -- **Never add a `paths:` filter to `ci.yml`, `codeql.yml`, `no-internal-refs.yml` or - `no-emdash.yml`.** None carries one, which is what stops a PR skipping a required check. -- **Confirm a ruleset write with the `PUT` itself, never a `GET`.** An Organization-sourced ruleset - returns `200` to a `GET` and `404` to an identical-payload `PUT`. `19907924` is - `source_type: Repository` and is the only ruleset this repo has. + context nothing emits does not fail a PR: it leaves it **pending and unmergeable forever**. +- **▶ A `ci / *` CONTEXT CAN APPEAR HERE WITH NO COMMIT IN THIS REPO, AND IT ARRIVES NOT REQUIRED** + (`ci.yml` calls the reusable workflow unpinned), so an upstream job shows **a red X that does not + block a merge**. **Census `ci / *` against a real check run whenever `.github` moves**, then require + it or write down why not. +- **`no-internal-refs` and `no-emdash` are bare JOB IDS**, not ` / `. **Renaming the job + silently detaches the required check**: rename the job and the ruleset together, or neither. +- **A required job gates all of its steps**, so splitting a step out of `ci / verify` into its own job + silently un-requires it. **Never add a `paths:` filter** to `ci.yml`, `codeql.yml`, + `no-internal-refs.yml` or `no-emdash.yml`: none carries one, which is what stops a PR skipping a + required check. +- **Confirm a ruleset write with the `PUT` itself, never a `GET`**: an Organization-sourced ruleset + answers `200` to a `GET` and `404` to an identical-payload `PUT`. - **Never require `scorecard / analysis`, `fuzz`, or `release / release`**: none runs on - `pull_request`, so each would strand every PR pending forever. **Never require the Advanced Security + `pull_request`, so each strands every PR pending forever. **Never require the Advanced Security `CodeQL` check** (app id `57789`): it reports **alert state**, not whether the analysis ran. - **▶ Scope of the claim: a ruleset makes a red check BLOCK a merge. It does not make the check - correct, and nothing inside this repository can observe its own ruleset.** Delete it and this suite - stays green while this section keeps asserting protection. Verify the only way that works: - `gh api repos/cosyte/cli/rulesets`. -- Recorded **unproven** rather than fine: no fork PR has ever run here, so neither the - first-time-contributor gate nor whether `codeql / analyze` can report on a fork token has been - observed. + correct, and nothing inside this repository can observe its own ruleset.** Verify the only way that + works: `gh api repos/cosyte/cli/rulesets`. Recorded **unproven** rather than fine: no fork PR has + ever run here, so neither the first-time-contributor gate nor whether `codeql / analyze` can report + on a fork token has been observed. ## Engineering Guardrails diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index afb4100..d7764a2 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -21,6 +21,142 @@ narrative; keep the cursor, the rule, and every trap."_ --- +## The two-file contract gate + +The split above made a link load-bearing. A rule in `CLAUDE.md` now reads "never do X. Why:" followed +by an anchor into this file, so if that anchor stops resolving the reader is left with an imperative +and no grounding, which is the "prose no test can check" shape this repository's own text warns +about. Three things could break it silently and none of them had a check: the narrative file stops +existing, a section is emptied down to its heading, or an anchor is edited on one side of the pair +and not the other. `scripts/check-agent-notes.ts` checks those three, on this tree, and nothing else. + +It lives in the **test suite** (`test/scripts/agent-notes.test.ts`) rather than in a fourth workflow. +That is deliberate: it therefore rides the required `ci / verify` contexts and blocks a merge, and it +rides `prepublishOnly`, which in this package is a real pre-publish gate rather than an advisory one. +A gate that catches a defect and then cannot block the merge that reintroduces it is documentation. + +### Why the matcher is the part that never ports + +**Counting first was the whole job, and skipping it has produced a false green twice in this +ecosystem.** Two spellings of a pointer are live across the cosyte repos, and which one dominates is +a property of the tree rather than of the convention: + +- **QUALIFIED**, `agent-notes.md#`, optionally prefixed with a path; and +- **BARE**, an inline code span holding a `#` and an anchor and nothing else. + +A qualified-only matcher dropped into `ncpdp` would have printed `all resolving` while covering 3 of +that tree's 38 pointers, because its dominant spelling is the bare one (35 against 3). In +`terminology` the split measured 42 bare against ZERO qualified, so the same matcher would have found +nothing at all and still exited 0. **A gate that clears a corpus it never opened is the single most +repeated defect in this class.** + +**Measured on this tree before the matcher was written: the qualified form is the only live one.** +Every pointer at this file is written `documentation/agent-notes.md#`, all of them in +`CLAUDE.md`, and there is no bare pointer anywhere. The bare-shaped spans that do exist in the pair +are pull-request references (`#` followed by digits, which is what GitHub renders them as) plus spans +that are not anchor-shaped at all, such as a quoted shebang and a quoted heading. So a bare matcher +here would have a denominator of zero, and the ecosystem rule that **a zero from either form refuses** +would then refuse on a healthy tree forever. That is why this gate matches the qualified form only. + +**No count from that paragraph is load-bearing, deliberately.** The OK line prints every figure on +every run, because it measures rather than remembers, and a figure written into a document goes stale +on the next commit with nobody touching it. + +### The bare census, which keeps the single-form scope a measurement + +Matching one form is safe exactly as long as the other stays absent, and an assumption nothing +re-checks is how a matcher silently stops covering its corpus. So the absence is observed on every +run. In the **pair only**, every backticked span of the shape `#` is enumerated. A span +whose anchor is **all digits** is a pull-request reference, not a pointer: it is counted and reported +on the OK line rather than dropped in silence. **Any other bare span refuses the run at exit 2.** + +That is a refusal rather than a violation on purpose. The tree has not necessarily broken, but the +evidence the matcher's scope was derived from has, so "all resolving" would be a claim about a corpus +the gate no longer covers. **The remedy is to re-derive the matcher, never to delete the span.** + +**The census is scoped to the pair, and that is measured rather than tidy.** Outside the pair the +bare shape is genuinely ambiguous on this tree: `CHANGELOG.md` carries backticked pull-request +references, `tsup.config.ts` and `docs-content/` carry quoted shebangs and shell comments, and +`scripts/phi-allow-list.txt` carries a quoted comment marker. Widening the census to the whole corpus +turns every one of those into a refusal. + +### What was re-derived here rather than inherited + +A sibling's exit-code table does not port, and neither does its corpus handling. Each of these was +decided against this repository: + +- **The exit codes come from `scripts/phi-scan.ts`**, this repository's own scanner: `0` clean, `1` a + finding a human acts on, `2` a refusal or a bad invocation. That split was itself paid for here, + when an unreadable allow-list, override log and scan root each threw past every handler and exited + `1`, which reads as a finding and is worse than a crash. +- **An UNMERGED path refuses.** `phi-scan` leaves the unmerged status unenumerated in its **staged** + route and records why: `git commit` refuses an unmerged index at exit 128, so nothing unmerged can + reach a commit through that gate. **That reasoning does not transfer**, because this gate has no + staged route; it runs from the test suite and from CI over whatever tree it is handed, and a + half-merged one is exactly where a working-tree copy is conflict markers. +- **The NUL skip is required here and is not removable by tidying.** This tree tracks real NUL-bearing + files: the vendored `@cosyte/*` tarballs, which are compressed streams, and a synthetic DICOM + fixture. None can be read as markdown and none can be edited to clear a red, and a gate whose red + has no fix is a gate someone disables. It is a **disclosed miss, not a pass**, and the tell is the + skipped count on the OK line. +- **A correction, kept visible because it was easy to write and was wrong.** A draft of the script's + disclosure claimed `scripts/check-no-emdash.sh` partitions on what git calls binary while this gate + partitions on a NUL byte, so the two sets differ. **That is false**, and reading that gate's own OK + line ("excluded by the NUL rule") is what caught it: **both gates key on an actual NUL byte and + exclude the same set.** The set that genuinely differs is **git's, which is wider**: + `test/__fixtures__/adt-a01.hl7` and `test/__fixtures__/minimal.astm` are `i/-text` to git (an HL7 v2 + or ASTM terminator is a lone `CR`) yet hold no NUL, so **both gates open them**. That gap is why + neither gate may be "simplified" to `grep -I` or to `git ls-files --eol`: either would drop a + readable text file from the sweep with no tell. + +### Existence is not observation, so the OK line reconciles + +The likeliest failure of a gate like this is not a wrong answer, it is a right-looking answer over a +corpus it never opened, and **this repository has already shipped exactly that**: `phi-scan` carried a +declared scan root the walk never observed and printed `OK, no hits` at exit 0 over it. + +**The property that prevents it here is structural rather than arithmetic: there is no declared root +to be wrong about.** The corpus is whatever `git ls-files` returns, every path in it is opened or +refused, and the only silent skip is the NUL one, which is counted and named. The printed arithmetic +is the weaker half and is not the remedy: it reconciles **sets of paths** rather than a pair of +counters, because a counter incremented once per iteration can only sum to the number of iterations, +so comparing that sum to the corpus size is a tautology dressed as a check. + +**Zero qualified pointers refuses**, because in this repository that cannot be a clean tree: +`CLAUDE.md` opens by linking this file and cites it by anchor throughout. + +### The positive controls, because a gate is believed only after it has been watched to fail + +Every one of these was run against a clone of the **real** tree, not a fixture, before the gate was +believed: + +| what was broken | result | +| --------------------------------------------------- | -------------------------------------- | +| one real anchor in `CLAUDE.md` misspelled | exit 1, naming the file and line | +| a real section emptied down to its heading | exit 1, naming the section and its slug | +| this file deleted | exit 1, the pair plus every pointer | +| a genuine bare pointer added (one that RESOLVES) | exit 2, "re-derive the matcher" | +| the matcher neutered so it matches nothing | exit 2, self-test refusal | +| every qualified pointer rewritten so none is matched | exit 2, "ZERO qualified pointers" | + +The last two are the ones that matter most: they are the `terminology` scenario, and a gate that went +green on either of them would be worthless while looking healthy. + +### What no mechanical check here can see + +The heading, anchor and body checks all verify that prose **moved**. **None of them notices a trap +that reached this file with no imperative following it into `CLAUDE.md`**, and the exposed class is +the trap phrased as a deliberate omission ("is deliberately left alone", "is never the default"), +which carries no identifier to grep for. Enumerate those by hand. The same limit applies to claims +about other documents: an anchor resolving proves the target exists, never that it says what the +sentence promises. A section with a body is not a section with the **right** body. + +This file is also outside every `format` glob in `package.json` (which reaches `src/`, `test/`, +`scripts/` and the top level only), so Prettier does not renormalise the list indentation the +pointers depend on. **Do not add `documentation/` to a formatter glob.** + +--- + ## Status The per-incident sections below were the bulk of `CLAUDE.md`'s `## Status` list. Each is reproduced diff --git a/package.json b/package.json index ab7f206..97b4583 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "changeset": "changeset", "typecheck": "tsc --noEmit", "phi-scan": "tsx scripts/phi-scan.ts", + "check:agent-notes": "tsx scripts/check-agent-notes.ts", "check:no-emdash": "bash scripts/check-no-emdash.sh", "check:no-internal-refs": "bash scripts/check-no-internal-refs.sh", "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.ts\" \"test/**/*.ts\" --max-warnings=0 --no-error-on-unmatched-pattern", diff --git a/scripts/check-agent-notes.ts b/scripts/check-agent-notes.ts new file mode 100644 index 0000000..577e8cc --- /dev/null +++ b/scripts/check-agent-notes.ts @@ -0,0 +1,1167 @@ +#!/usr/bin/env tsx +/** + * `@cosyte/cli` two-file-contract gate. + * + * WHAT THIS REPO PROMISES, WHICH IS THE ONLY THING THIS GATE ASSERTS. On 2026-08-04 this + * repo's guidance was split in two: `CLAUDE.md` became a cursor plus rules plus traps, and + * `documentation/agent-notes.md` took the narrative, with `CLAUDE.md` pointing into it by + * anchor. Nothing was deleted; the reasoning moved behind a link. That makes the link + * load-bearing in a way it was not before. A rule in `CLAUDE.md` now reads "never do X. Why:" + * followed by an anchor into the narrative file, and if that anchor does not exist the reader + * gets an imperative with no grounding. Three things can break silently and none had a check: + * + * 1. the narrative file stops existing (a rename, a bad merge, a `git rm`); + * 2. a section is emptied down to its heading, so a pointer resolves to nothing; and + * 3. an anchor is edited on one side of the pair and not the other, so a pointer dangles. + * + * This gate checks those three, on this tree, and nothing else. + * + * --------------------------------------------------------------------------- + * IT IS NAMED FOR WHAT IT CHECKS AND IS DELIBERATELY NOT A UNIVERSAL, AND THAT IS THE MOST + * IMPORTANT LINE IN THIS FILE. + * + * The two-file split was applied across the cosyte tree, so the tempting framing is "every + * repo has a `CLAUDE.md` and a `documentation/agent-notes.md`, and this gate enforces the + * contract". MEASURED 2026-08-06, across the umbrella's own checkout: SEVEN repos have NO + * `documentation/agent-notes.md` at all. So the ecosystem-wide contract is either not + * universal or is violated in seven places, and a gate written as though it were universal + * would be asserting something seven repos disprove. That is an OVERCLAIM, and an + * overclaiming guard is worse than a narrow one: it invites a reader to trust a promise the + * tree does not keep, and the first repo that trips it deletes the gate instead of fixing + * anything. For those repos the honest outcome is a WRITTEN EXEMPTION, not an invented file. + * + * So: this gate asserts `@cosyte/cli`'s contract. This repo HAS an `agent-notes.md`, its + * `CLAUDE.md` points into it by anchor throughout, and its `CLAUDE.md` sits AT its byte + * budget with the narrative already relocated, so here the pointer relationship is real and + * paid for. Whether every OTHER repo owes the same thing is a question for whoever owns the + * convention, and it is not answered by a script inside one package. DO NOT WIDEN THIS FILE + * TO CLAIM IT. + * + * --------------------------------------------------------------------------- + * THE MATCHER IS THE PART THAT DOES NOT PORT, AND COUNTING FIRST WAS THE WHOLE JOB. + * + * Two spellings of a pointer are live across this ecosystem and which one dominates is a + * property of the tree, not of the convention: + * + * * QUALIFIED, `agent-notes.md#`, optionally path-prefixed; and + * * BARE, an inline code span holding a `#` and an anchor and nothing else. + * + * Porting a sibling's matcher without re-counting has produced a false green twice. A + * qualified-only matcher dropped into `ncpdp` would have printed `all resolving` while + * covering 3 of that tree's 38 pointers, because its dominant spelling is the bare one (35 + * against 3). In `terminology` the split measured 42 bare against ZERO qualified, so the same + * matcher would have found NOTHING AT ALL and still exited 0. + * + * MEASURED HERE, ON THIS TREE, BEFORE THIS MATCHER WAS WRITTEN: the qualified form is the + * only live one. Every pointer at the narrative file in this repository is written + * `documentation/agent-notes.md#`, and there is no bare pointer anywhere. The + * bare-shaped spans that DO exist in the pair are pull-request references (`#` followed by + * digits, which is what GitHub renders them as) plus spans that are not anchor-shaped at all, + * such as a quoted shebang and a quoted heading. So a bare MATCHER here would have a + * denominator of zero, and a per-form refusal keyed on it would refuse on a healthy tree + * forever. That is why this file matches the qualified form only. + * + * DO NOT TRUST THE PARAGRAPH ABOVE FOR THE TOTALS. A figure written into a comment goes stale + * on the next commit with nobody touching it, and this ecosystem has burned four separate + * slices on exactly that. THE OK LINE PRINTS EVERY COUNT ON EVERY RUN, because it measures + * rather than remembers. + * + * --------------------------------------------------------------------------- + * THE BARE CENSUS, WHICH IS HOW "THE BARE FORM IS DEAD HERE" STAYS A MEASUREMENT. + * + * Deciding to match one form only is safe exactly as long as the other form stays absent, and + * an assumption nothing re-checks is how a matcher silently stops covering its corpus. So the + * absence is not assumed, it is OBSERVED on every run: + * + * * In the PAIR ONLY (`CLAUDE.md` and the narrative file), every backticked span of the + * shape `#` is enumerated. + * * A span whose anchor is ALL DIGITS is a pull-request or issue reference, not a pointer. + * It is COUNTED AND REPORTED on the OK line rather than dropped in silence, because a + * matcher that quietly declines to look at some of its own hits is the defect this whole + * section exists to prevent. The disclosed cost is that a heading whose text is only + * digits would be unreachable through the bare form; the qualified form still reaches it, + * and no such heading exists here. + * * ANY OTHER bare span is a SUSPECTED BARE POINTER and REFUSES THE RUN (exit 2). It is a + * refusal and not a violation on purpose: the tree has not necessarily broken, but the + * evidence this matcher's scope was derived from has, so "all resolving" would be a claim + * about a corpus this gate no longer covers. The fix is to re-derive the matcher against + * the new spelling, exactly as this one was derived, NOT to delete the span. + * + * THE CENSUS IS SCOPED TO THE PAIR, AND THAT IS MEASURED RATHER THAN TIDY. Outside the pair + * the bare shape is genuinely ambiguous rather than merely noisy on THIS tree: `CHANGELOG.md` + * carries backticked pull-request references, `tsup.config.ts` and `docs-content/` carry + * quoted shebangs and shell comments, and `scripts/phi-allow-list.txt` carries a quoted + * comment marker. Widening the census to the whole corpus turns those into refusals. + * + * --------------------------------------------------------------------------- + * EXISTENCE IS NOT OBSERVATION, WHICH IS WHY THE OK LINE RECONCILES. + * + * The failure this gate is most likely to have is not a wrong answer, it is a right-looking + * answer over a corpus it never opened. That is not hypothetical in this repository: its own + * PHI scanner shipped a declared scan root the walk never observed and printed `OK, no hits` + * at exit 0 over it, which is why `scripts/phi-scan.ts` now reconciles per root against + * `git ls-files` and refuses at exit 2. A DENOMINATOR DOES NOT DETECT THAT, because a count + * counts the roots that DID exist. + * + * BE PRECISE ABOUT WHICH PART DOES THE WORK. The property that prevents the defect here is + * STRUCTURAL, not arithmetic: THERE IS NO DECLARED ROOT TO BE WRONG ABOUT. The corpus is + * whatever `git ls-files` returns, every path in it is opened or refused, and the only silent + * skip is a NUL-bearing file, which is counted and named on the OK line. + * + * THE PRINTED ARITHMETIC IS A WEAKER THING AND IS NOT THE REMEDY. It is reconciled over SETS + * of paths rather than a pair of counters, which buys something a counter cannot: a counter + * incremented once per iteration can only sum to the number of iterations, so comparing that + * sum to the corpus size is a tautology. Sets catch a path enumerated twice and a path no + * branch reached. Treat the printed sum as something a reader can check by eye, above all the + * skip count, and not as evidence the scan was complete. + * + * Further refusals exist for the same reason, each a case where "no violations" would be a + * lie rather than a result: + * * zero tracked paths (not a repo, or a `--root` pointed at an empty tree); + * * a tracked path that is missing, unreadable, a symlink, or not a regular file; + * * an UNMERGED path, which `git ls-files -s` reports three times and whose working-tree + * copy is conflict markers nobody has decided the contents of; + * * two tracked files carrying the contract basename, which makes every pointer ambiguous; + * * a suspected bare pointer, per the census above; and + * * ZERO QUALIFIED POINTERS FOUND ANYWHERE. In THIS repo that cannot be a clean tree: + * `CLAUDE.md` opens by linking the narrative file and cites it by anchor throughout. Zero + * means the matcher stopped matching, so the pointer half proved nothing. This refusal is + * grounded in what THIS repo contains and is one of the things a port must re-derive. + * + * --------------------------------------------------------------------------- + * EXIT CODES. RE-DERIVED FROM THIS REPOSITORY'S OWN `scripts/phi-scan.ts`, NOT PORTED. That + * scanner's contract is the one a reader of this repo already knows, and it was itself paid + * for: an unreadable allow-list, override log or scan root each threw past every handler and + * exited 1, which read as a finding. + * 0 the contract holds. + * 1 the contract is broken: a missing file, an empty section, or a dangling pointer. + * 2 REFUSAL, or a bad invocation. The gate could not observe what it claims to check, or + * could not honestly report on what it did observe. Never reported as clean. + * + * The split matters: exit 1 is a finding a human acts on, exit 2 is "believe nothing I said". + * Collapsing them turns a broken scanner into a list of false findings, which reads as + * actionable and is worse than a crash. + * + * --------------------------------------------------------------------------- + * DISCLOSED MISSES. Stated here rather than discovered later. + * + * WHICH OF THESE A TEST PINS IS MARKED PER ITEM. A DISCLOSURE THAT NAMES A TEST MUST NAME ONE + * THAT EXISTS. [PINNED] means a case in `test/scripts/agent-notes.test.ts` exercises it IN THE + * DIRECTION IT FAILS. [SCOPE] means it is a boundary of what this gate is for, with nothing to + * execute. + * + * (i) [PINNED] A POINTER SPLIT MID-ANCHOR ACROSS A LINE WRAP is matched only when the head + * fragment runs to the end of the line and the join with the next line resolves. The + * join is attempted ONLY after the line-pass anchor has already failed, so it can turn + * a false red into a pass but never a real red into a pass, EXCEPT where the head + * fragment is ITSELF a valid anchor and the tail is garbage, which passes green. + * Closing that needs a markdown renderer, not a bigger regex. + * (ii) [PINNED] A PERCENT-ENCODED OR HTML-ENTITY ANCHOR IS NOT DECODED. `#a%20b` matches + * only up to the `%`, so it is checked as `#a` and reds. No such pointer exists here. + * (iii) [PINNED] A POINTER AT ANY OTHER FILE'S ANCHOR IS OUT OF SCOPE, including + * `CLAUDE.md#...`. This gate is about the narrative file. A general markdown link + * checker is a different tool with a different failure surface, and writing half of one + * here would be the overclaim this file's second section refuses. + * (iv) [PINNED] A POINTER INSIDE A FENCED CODE BLOCK IS TREATED EXACTLY LIKE PROSE. + * Deliberate: a reader follows it either way. Headings are the opposite, see (vi). + * (v) [PINNED] A NUL-BEARING FILE IS SKIPPED WHOLE, SO ITS POINTERS ARE NEVER READ. This is + * the one that can print `all resolving` over a dangling pointer, and it is a disclosed + * miss rather than a pass. THE TELL IS THE SKIPPED COUNT ON THE OK LINE. + * THE EXCLUSION IS REQUIRED HERE AND IS NOT REMOVABLE BY TIDYING. This tree tracks + * real NUL-bearing files: the vendored `@cosyte/*` tarballs, which are compressed + * streams, and a synthetic DICOM fixture. None can be read as markdown and none can be + * edited to clear a red, and a gate whose red has no fix is a gate someone disables. + * THE PARTITION IS AN ACTUAL NUL BYTE, WHICH IS NARROWER THAN WHAT GIT CALLS BINARY, + * AND GETTING THAT ROUND THE WRONG WAY IS HOW A FILE LEAVES THE SWEEP IN SILENCE. A + * first draft of this note claimed `scripts/check-no-emdash.sh` partitions on git's + * classification while this gate partitions on NUL, so the two sets differ. THAT WAS + * FALSE AND WAS CAUGHT BY READING THAT GATE'S OWN OK LINE, which says "excluded by the + * NUL rule": BOTH gates partition on NUL and both exclude the same set today. The set + * that genuinely differs is GIT'S, which is WIDER: `test/__fixtures__/adt-a01.hl7` and + * `test/__fixtures__/minimal.astm` are `i/-text` to git (an HL7 v2 or ASTM terminator + * is a lone `CR`) yet hold no NUL, so BOTH gates open them. That gap is exactly why + * neither gate may be "simplified" to `grep -I` or to `git ls-files --eol`: either + * would drop a readable text file from the sweep with no tell. Each gate prints its own + * count on its own OK line; neither number is a copy of the other, and this comment + * deliberately quotes neither. + * (vi) [PINNED] AN ATX HEADING INSIDE A FENCED CODE BLOCK IS NOT AN ANCHOR, and the fence + * tracker is why. Without it a `#` comment in a shell sample mints a phantom anchor and + * masks the dangling pointer this gate exists to catch. The tracker handles ``` and ~~~ + * fences of three or more characters. It does NOT track an INDENTED code block as a + * block, but that is not reachable as a phantom anchor: `ATX_RE` bounds indentation at + * three spaces, so a four-space-indented `#` line is not a heading here either, which + * is what CommonMark does anyway. Both halves are asserted. + * (vii) [PINNED] THE SLUGGER IS A TRANSCRIPTION OF github-slugger, NOT THE MODULE, so that + * this gate adds no runtime dependency to a package whose dependency count is capped. + * It was verified against github-slugger@2.0.0 by running the real module over THIS + * tree's twenty real headings plus the shapes below, not by reading a sibling's table. + * `SLUG_CASES` carries the rows that would diverge from the obvious implementation. + * ONE THING THE MODULE CANNOT BE FED DIRECTLY, AND GETTING THIS BACKWARDS IS A FALSE + * RED: github-slugger slugs the RENDERED text of a heading, so a heading carrying a + * markdown link must have the link's URL removed before slugging. Feeding the raw + * source `A [linked](https://example.test/x) heading` to the module yields + * `a-linkedhttpsexampletestx-heading`, while GitHub's real anchor is `a-linked-heading`. + * `stripInline` is that rendering step. No heading here carries a link today, so the + * function is inert on this corpus and is kept as a guard for the next one. + * UNTESTED AND NOT CLAIMED: combining marks, CJK, and connector punctuation other than + * `_`. A heading needing any of those is the signal to measure it, not to assume. + * (viii)[SCOPE] A SECTION WITH A BODY IS NOT A SECTION WITH THE RIGHT BODY. This gate proves + * a pointer lands somewhere non-empty. It cannot prove the prose there grounds the rule + * that cited it. That half stays human, and saying so is the point of writing it down. + * (ix) [SCOPE] IT DOES NOT CHECK ANY BYTE BUDGET. `CLAUDE.md`'s ceiling is enforced by the + * meta-repo's `.claude/hooks/doc-budget.mjs`, which holds the budget table; a script + * inside this package cannot see it and must not keep a second copy of a number. + * (x) [SCOPE] IT DOES NOT NOTICE A TRAP THAT REACHED THE ARCHIVE WITH NO IMPERATIVE + * FOLLOWING IT INTO `CLAUDE.md`. Heading, anchor and body checks all verify prose + * MOVED. The exposed class is a trap phrased as a deliberate omission, which carries no + * identifier to grep for. Enumerate those by hand. + * + * Run it locally with `pnpm check:agent-notes`. `pnpm test` runs it against this tree too + * (`test/scripts/agent-notes.test.ts`), which is what puts it on the meta-repo's + * `scripts/verify.sh cli` ladder without that ladder needing to name it, and what puts it + * inside `prepublishOnly`. + */ + +import { execFileSync } from "node:child_process"; +import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Heading { + /** 1-based line number of the heading text (the underline line, for setext). */ + readonly line: number; + readonly text: string; + readonly slug: string; + /** 1-based line the section body may start on. */ + readonly bodyFrom: number; + /** + * Depth: 1 to 6 for `#` through `######`, and for setext 1 for `===` and 2 for `---`. + * Carried ONLY so `emptySections` can tell a CONTAINER from an emptied leaf; nothing else + * reads it, and it is deliberately not part of the slug, which depends on the text alone. + */ + readonly level: number; +} + +interface Violation { + readonly where: string; + readonly what: string; +} + +/** A refusal: the gate could not observe what it claims to check. Always exit 2. */ +class RefusalError extends Error {} + +/** A bad invocation. Also exit 2: the run proves nothing. */ +class InvocationError extends Error {} + +// --------------------------------------------------------------------------- +// The contract file, named once +// --------------------------------------------------------------------------- + +/** + * The basename this gate is about. Matched on BASENAME rather than on the full path, so a + * pointer qualified with `documentation/`, one prefixed `./`, and a bare one all reach the + * same target. Exactly one tracked file may carry this name: two would make every pointer + * ambiguous, and the gate refuses rather than guessing. + * + * NOTE FOR ANYONE EDITING THE PROSE IN THIS FILE OR IN `test/scripts/agent-notes.test.ts`: + * this gate scans EVERY tracked text file and carves out no exemption for its own source or + * its own tests, so a literally-written pointer here is a pointer into this repo's narrative + * file and is checked as one. That is deliberate. An exemption for the gate's own files is + * precisely where a genuinely broken pointer would hide, and this repository's PHI scanner has + * already paid for a blanket exemption once. Sample pointers are therefore assembled from a + * constant rather than written out, in both files. + */ +const CONTRACT_BASENAME = "agent-notes.md"; + +/** The cursor half of the pair. Its absence is a contract violation, not a refusal. */ +const CURSOR_PATH = "CLAUDE.md"; + +// --------------------------------------------------------------------------- +// Slugging: a transcription of github-slugger, pinned by SLUG_CASES below +// --------------------------------------------------------------------------- + +/** + * Strip the one inline construct that changes a slug: a markdown link, whose URL must not + * reach the slug while its text must. This is the RENDERING STEP, and it is why the module + * cannot be fed raw heading source. Nothing else needs stripping, and that is measured rather + * than a shortcut: backticks, asterisks and underscores-as-emphasis are all removed (or kept) + * by the punctuation filter below in exactly the way github-slugger removes (or keeps) them. + * `_` in particular is KEPT by the filter, which is what makes an anchor minted from a heading + * naming a `CLI_*` diagnostic code resolve. + */ +function stripInline(text: string): string { + return text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"); +} + +/** + * github-slugger's transformation: lowercase, drop everything that is not a letter, a number, + * a space separator, a hyphen or an underscore, then replace each remaining space with a + * hyphen. + * + * THREE THINGS HERE ARE NOT COSMETIC, and all three are reachable on THIS tree. + * + * PER-SPACE, NOT PER-RUN: `a b` becomes `a--b` on GitHub and must here too. This is not a + * hypothetical shape here. The real heading "`ci / prepublish`, and the hazard that a context + * can arrive with no commit here" slugs with a DOUBLE hyphen, because the backticks and the + * slash are deleted and the two spaces around them both survive as separators. Measured + * against github-slugger@2.0.0; a per-run collapse reds the live pointer at it. + * + * NO `.trim()`. github-slugger does not trim: it deletes the disallowed character and leaves + * the space behind, so a heading led by a marker glyph slugs with a LEADING HYPHEN. A trim + * makes a pointer written without that hyphen pass this gate and resolve to nothing on GitHub, + * which is the exact shape this file exists to catch. It is reachable rather than exotic here: + * this repository's own documents lead load-bearing rules with marker glyphs throughout, so a + * glyph-led heading is the likeliest next one anybody writes. Pinned by SLUG_CASES for both + * a text marker and an emoji one. + * + * THE KEPT SPACE IS THE ASCII SPACE ALONE, NOT `\p{Zs}`. Same rule, same direction of care: a + * disallowed character is DELETED, and every space separator other than U+0020 is disallowed, + * so a heading holding U+00A0 between two letters slugs them together upstream. A `\p{Zs}` + * keep-class leaves the separator in the slug, which reds a pointer that works. + * + * A SOFTBREAK IS DELETED, NOT HYPHENATED, WHICH IS WHY THE SETEXT JOIN USES `\n`. A wrapped + * setext heading is ONE heading whose text contains a newline, and `\n` is not a space + * separator, so upstream removes it and the two halves RUN TOGETHER. + */ +function slugify(text: string): string { + return stripInline(text) + .toLowerCase() + .replace(/[^\p{L}\p{N} \-_]/gu, "") + .replace(/ /g, "-"); +} + +/** + * github-slugger's DEDUPLICATION, transcribed as the loop it actually is rather than as the + * counter it looks like. + * + * The obvious implementation (count occurrences of the base slug, suffix `-N`) is wrong on one + * input and the difference is a false red: headings `Same`, `Same`, `Same-1` yield `same`, + * `same-1`, `same-1-1` upstream, because the third heading's OWN slug collides with the second + * heading's GENERATED one and the suffix is applied again. A counter yields `same-1` twice, so + * a pointer at `#same-1-1` reds against a link GitHub resolves. Measured against + * github-slugger@2.0.0 and pinned by a self-test. + */ +function makeSlugger(): (text: string) => string { + const occurrences = new Map(); + return (text: string): string => { + const original = slugify(text); + let result = original; + while (occurrences.has(result)) { + occurrences.set(original, (occurrences.get(original) ?? 0) + 1); + result = `${original}-${String(occurrences.get(original))}`; + } + occurrences.set(result, 0); + return result; + }; +} + +/** + * The anchor character class, kept in lockstep with what `slugify` can EMIT. If this were the + * ASCII `[A-Za-z0-9_-]` the obvious way, a pointer at a heading containing an accented letter + * would be truncated mid-anchor by the matcher and reported as dangling, a false red against a + * link that works. Aligning the two is what makes the matcher's silence meaningful. + */ +const ANCHOR_CHARS = "[\\p{L}\\p{N}_-]"; + +/** The qualified form, the only live spelling here. Matched in every opened file. */ +function pointerPattern(): RegExp { + return new RegExp(`agent-notes\\.md#(${ANCHOR_CHARS}+)`, "gu"); +} + +/** + * The bare shape: an inline code span holding nothing but `#` and an anchor run. This is NOT a + * matcher for a form this gate checks. It is the CENSUS that keeps "the bare form is absent + * here" a measurement instead of an assumption, and it runs in the pair only. See the header. + */ +function barePattern(): RegExp { + return new RegExp(`\`#(${ANCHOR_CHARS}+)\``, "gu"); +} + +/** A bare span whose anchor is all digits is a pull-request reference, not a pointer. */ +const DIGITS_ONLY = /^\p{Nd}+$/u; + +// --------------------------------------------------------------------------- +// Heading extraction +// --------------------------------------------------------------------------- + +const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/; +/** + * ATX. Up to three leading spaces (CommonMark), one to six hashes, and then EITHER whitespace + * or end of line: `#hashtag` is not a heading. Trailing closing hashes are stripped. + * + * A NAIVE `/^#{1,6} /` HAS TWO MEASURED BYPASSES, both handled here and both asserted in the + * test file: a single leading space, and a setext underline. A missed heading is a missing + * anchor and a missing anchor is a FALSE RED on a pointer that works. + */ +const ATX_RE = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/; +/** Setext: a `=` or `-` run under a non-blank paragraph line. `=` is h1, `-` is h2. */ +const SETEXT_RE = /^ {0,3}(=+|-+)[ \t]*$/; + +function stripTrailingHashes(text: string): string { + return text.replace(/[ \t]+#+[ \t]*$/, ""); +} + +/** + * Extract every heading GitHub would give an anchor, in document order, deduplicated by + * `makeSlugger`. + * + * THREE BLOCK CONSTRUCTS ARE TRACKED, each because leaving it out is a measured divergence: + * + * * FENCED CODE. An ATX line inside ``` or ~~~ is a comment in a sample, not a heading. + * Without this a shell snippet mints a phantom anchor and a dangling pointer passes, which + * is the one direction this gate must never fail in. Not hypothetical in this file's + * target: the narrative file embeds shell and `gh api` reproductions. + * * YAML FRONT MATTER. A `---` fence at the very start of the file is front matter, and its + * CLOSING `---` sits directly under a non-blank line, so a setext reader mints an anchor + * from `title: x`. + * * THE SETEXT PARAGRAPH. An underline belongs to the WHOLE paragraph above it, not to its + * last line. The lines are joined with `\n`, NOT a space, because a softbreak is DELETED + * by the slug rule rather than hyphenated. + */ +function extractHeadings(lines: readonly string[]): Heading[] { + const headings: Heading[] = []; + const slugger = makeSlugger(); + let inFence = false; + let fenceMarker = ""; + + const push = (line: number, rawText: string, bodyFrom: number, level: number): void => { + const text = rawText.trim(); + headings.push({ line, text, slug: slugger(text), bodyFrom, level }); + }; + + // Front matter, if any: a `---` on the very first line opens it and the next `---` or `...` + // closes it. Everything between is metadata, never a heading and never a pointer surface. + let start = 0; + if ((lines[0] ?? "").trimEnd() === "---") { + for (let i = 1; i < lines.length; i += 1) { + const t = (lines[i] ?? "").trimEnd(); + if (t === "---" || t === "...") { + start = i + 1; + break; + } + } + } + + for (let i = start; i < lines.length; i += 1) { + const line = lines[i] ?? ""; + + const fence = FENCE_RE.exec(line); + if (fence) { + const marker = (fence[1] ?? "")[0] ?? ""; + if (!inFence) { + inFence = true; + fenceMarker = marker; + } else if (marker === fenceMarker) { + inFence = false; + fenceMarker = ""; + } + continue; + } + if (inFence) continue; + + const atx = ATX_RE.exec(line); + if (atx) { + push(i + 1, stripTrailingHashes(atx[2] ?? ""), i + 2, (atx[1] ?? "#").length); + continue; + } + + // Setext. The underline is only a heading when it sits under a non-blank paragraph that is + // not itself a heading and not a list item. A `---` after a blank line is a thematic break. + const setext = SETEXT_RE.exec(line); + if (setext && i > start) { + const prev = lines[i - 1] ?? ""; + const prevIsText = prev.trim() !== "" && !ATX_RE.test(prev) && !/^ {0,3}[-*+>] /.test(prev); + if (prevIsText) { + // THE UNDERLINE BELONGS TO THE WHOLE PARAGRAPH, so walk back to its first line and + // join. Reading only `lines[i - 1]` slugs a wrapped heading from its last line alone, + // which is a false red on the pointer GitHub resolves. + let first = i - 1; + while (first > start) { + const above = lines[first - 1] ?? ""; + if (above.trim() === "" || ATX_RE.test(above) || /^ {0,3}[-*+>] /.test(above)) break; + first -= 1; + } + const paragraph = lines + .slice(first, i) + .map((l) => l.trim()) + .join("\n"); + // The anchor belongs to the paragraph; the body starts after the underline. + // `===` is a level-1 heading and `---` a level-2 one, exactly as CommonMark reads them. + push(first + 1, paragraph, i + 2, (setext[1] ?? "-").startsWith("=") ? 1 : 2); + } + } + } + + return headings; +} + +/** + * A section is EMPTY when nothing but blank lines separates its heading from the next heading + * or from the end of the file. That is the check the item asks for and it is deliberately the + * weak form: see disclosed miss (viii). A heading whose only body is a fence or a single word + * counts as non-empty, because judging sufficiency is not something a script can do honestly. + * + * A CONTAINER IS NOT AN EMPTIED SECTION, AND CONFLATING THEM IS A FALSE RED. A heading + * immediately followed by a DEEPER one (`## Group` then `### Sub` with no prose between) is a + * container whose body IS its subsections. A pointer at `#group` resolves on GitHub and the + * reader lands on real content, so reporting it is a red against a link that works. That shape + * is live in this repository's narrative file, where a top-level heading is followed straight + * away by its first subsection. + * + * IT OPENS NO FALSE-GREEN HOLE, which is the only direction that would matter. The exemption + * moves the obligation DOWN rather than removing it: the deeper heading is still checked, so + * an emptied leaf still reds, and a container can only be exempt when something deeper exists + * to carry the body. A trailing heading has no `next` at all and is therefore never a + * container. Both directions are pinned in `test/scripts/agent-notes.test.ts`. + */ +function emptySections( + lines: readonly string[], + headings: readonly Heading[], +): { readonly empty: Heading[]; readonly containers: number } { + const empty: Heading[] = []; + let containers = 0; + for (let h = 0; h < headings.length; h += 1) { + const here = headings[h]; + if (!here) continue; + const next = headings[h + 1]; + // A container: its body is the subsections beneath it, and the obligation moves to them. + // COUNTED, NOT SILENTLY SKIPPED, because the OK line must not claim every section has a + // body when a container was never asked. + if (next && next.level > here.level) { + containers += 1; + continue; + } + const end = next ? next.line - 1 : lines.length; + let hasBody = false; + for (let i = here.bodyFrom; i <= end; i += 1) { + if ((lines[i - 1] ?? "").trim() !== "") { + hasBody = true; + break; + } + } + if (!hasBody) empty.push(here); + } + return { empty, containers }; +} + +// --------------------------------------------------------------------------- +// Self-tests. A gate is believed only after it has shown it can still see. +// --------------------------------------------------------------------------- + +/** + * Slug transcription cases. Every row is either a REAL heading from this repository's + * `agent-notes.md` or a shape a future one is likely to take here, and every row was produced + * by running github-slugger@2.0.0 rather than copied from a sibling's table. If someone + * "simplifies" `slugify`, this table reds here rather than turning every working pointer on + * the tree into a false red. + * + * DO NOT DESCRIBE THIS TABLE BY POSITION. A positional reference is a claim that goes stale on + * the next append. Each row carries its own reason where it needs one. + */ +const SLUG_CASES: ReadonlyArray = [ + // Real headings. The first is the file's own H1, and proves `@` and `/` are deleted while + // the letters either side run together. + ["@cosyte/cli: agent notes", "cosytecli-agent-notes"], + // Real, and the PER-SPACE rule in its live form: backticks and a slash are deleted and both + // surrounding spaces survive, giving a DOUBLE hyphen. A per-run collapse reds this pointer. + [ + "`ci / prepublish`, and the hazard that a context can arrive with no commit here", + "ci--prepublish-and-the-hazard-that-a-context-can-arrive-with-no-commit-here", + ], + // Real. An apostrophe is deleted with no separator left behind, and a parenthesised date + // survives as digits and hyphens. + [ + "Widening the walk to this repository's whole authored corpus (2026-08-07)", + "widening-the-walk-to-this-repositorys-whole-authored-corpus-2026-08-07", + ], + [ + "Branch protection (and the limits of this claim)", + "branch-protection-and-the-limits-of-this-claim", + ], + ["Standing disciplines (every change)", "standing-disciplines-every-change"], + ["The pre-commit PHI gate and git mv", "the-pre-commit-phi-gate-and-git-mv"], + // NO TRIM, with a leading marker. Both spellings this repository actually uses. + ["▶ The section", "-the-section"], + ["🛑 THE STOP", "-the-stop"], + // An underscore is KEPT, which is what lets a heading naming a diagnostic code resolve. + ["CLI_NOT_IMPLEMENTED and 69", "cli_not_implemented-and-69"], + ["A `code` heading with **bold**", "a-code-heading-with-bold"], + // The rendering step: the URL must not reach the slug while the link text must. + ["A [linked](https://example.test/x) heading", "a-linked-heading"], + ["A double space", "a--double--space"], + // A SOFTBREAK IS DELETED, NOT HYPHENATED. The two halves run together. This is the wrapped + // setext heading, and getting it wrong is a false green on a link that resolves to nothing. + ["The long\nsection name", "the-longsection-name"], + // Every space separator other than U+0020 is deleted too, for the same reason. Written as + // ESCAPES, not literals: a bare U+00A0 in a table is invisible to a reader and to a diff. + ["a\u00a0b", "ab"], + ["a\u2009b", "ab"], +]; + +function selfTest(): void { + for (const [text, want] of SLUG_CASES) { + const got = slugify(text); + if (got !== want) { + throw new RefusalError( + `SELF-TEST FAILED: slugify(${JSON.stringify(text)}) produced ${JSON.stringify(got)}, ` + + `expected ${JSON.stringify(want)}. The slug transcription no longer matches ` + + `github-slugger, so every anchor this gate computes is suspect and no result from ` + + `it can be believed.`, + ); + } + } + + // The heading detector must see every shape that mints an anchor and NONE of the shapes that + // do not. The second half matters most: a phantom anchor lets a dangling pointer pass, which + // is the single outcome this gate exists to prevent. Blank lines separate the blocks, because + // that is what makes each shape unambiguous markdown. Keep them. + const sample = [ + "---", + "title: front matter", + "---", + "", + "# Top", + "body", + "", + " ## Indented by two", + "body", + "", + " ### Indented by four", + "", + "Setext one", + "==========", + "body", + "", + "A wrapped setext", + "heading over two lines", + "----------------------", + "body", + "", + "#hashtag", + "", + "```sh", + "# not a heading", + "```", + "body", + ]; + const got = extractHeadings(sample).map((h) => h.slug); + const want = ["top", "indented-by-two", "setext-one", "a-wrapped-setextheading-over-two-lines"]; + if (got.length !== want.length || got.some((s, i) => s !== want[i])) { + throw new RefusalError( + `SELF-TEST FAILED: the heading detector produced [${got.join(", ")}], expected ` + + `[${want.join(", ")}]. A missed heading is a false red on a working pointer; a ` + + `phantom one lets a dangling pointer through. Refusing to report on the tree.`, + ); + } + + // Deduplication is a LOOP, not a counter: the third heading's own slug collides with the + // second heading's GENERATED one, so the suffix applies again. + const dedup = extractHeadings(["## Same", "a", "## Same", "b", "## Same-1", "c"]).map( + (h) => h.slug, + ); + const wantDedup = ["same", "same-1", "same-1-1"]; + if (dedup.length !== wantDedup.length || dedup.some((s, i) => s !== wantDedup[i])) { + throw new RefusalError( + `SELF-TEST FAILED: duplicate headings slugged as [${dedup.join(", ")}], expected ` + + `[${wantDedup.join(", ")}]. GitHub disambiguates by re-suffixing until the slug is ` + + `free, and a pointer at a repeated heading depends on it.`, + ); + } + + const flat = ["## A", "## B", "body"]; + const { empty } = emptySections(flat, extractHeadings(flat)); + if (empty.length !== 1 || empty[0]?.slug !== "a") { + throw new RefusalError( + `SELF-TEST FAILED: the empty-section detector found ${String(empty.length)} empty ` + + `section(s) in a sample with exactly one. Refusing to report on the tree.`, + ); + } + + // THE CONTAINER EXEMPTION, SELF-TESTED IN BOTH DIRECTIONS, because it is the one rule here + // that makes the gate report LESS. + const nested = ["## A", "### B"]; + const nestedResult = emptySections(nested, extractHeadings(nested)); + if ( + nestedResult.containers !== 1 || + nestedResult.empty.length !== 1 || + nestedResult.empty[0]?.slug !== "b" + ) { + throw new RefusalError( + `SELF-TEST FAILED: the container exemption found ${String(nestedResult.containers)} ` + + `container(s) and ${String(nestedResult.empty.length)} empty section(s) in a sample ` + + `holding exactly one of each. Either a container is being reported as emptied, which ` + + `is a false red, or an emptied leaf beneath one is being skipped, which is a false ` + + `green. Refusing to report on the tree.`, + ); + } + + const re = pointerPattern(); + const hits = [ + ...`see documentation/${CONTRACT_BASENAME}#a-b, ./${CONTRACT_BASENAME}#c_d.`.matchAll(re), + ].map((m) => m[1]); + if (hits.length !== 2 || hits[0] !== "a-b" || hits[1] !== "c_d") { + throw new RefusalError( + `SELF-TEST FAILED: the qualified pointer matcher found [${hits.join(", ")}] in a sample ` + + `holding exactly two pointers, one path-qualified and one relative. A matcher that ` + + `stopped matching reports a clean tree it never read.`, + ); + } + + // THE CENSUS MUST SEPARATE A REFERENCE FROM A SUSPECTED POINTER, or it either refuses on + // every pull-request number in the narrative file or never fires at all. + const bare = [...["`#36` and `#a-real-anchor`"].join("\n").matchAll(barePattern())].map( + (m) => m[1] ?? "", + ); + if (bare.length !== 2 || !DIGITS_ONLY.test(bare[0] ?? "") || DIGITS_ONLY.test(bare[1] ?? "")) { + throw new RefusalError( + `SELF-TEST FAILED: the bare census classified [${bare.join(", ")}] wrongly in a sample ` + + `holding one reference and one suspected pointer. The census is the only thing ` + + `keeping this gate's single-form scope a measurement rather than an assumption.`, + ); + } +} + +// --------------------------------------------------------------------------- +// The corpus: enumerate with git, account for every path +// --------------------------------------------------------------------------- + +interface Corpus { + readonly tracked: readonly string[]; + readonly gitlinks: readonly string[]; +} + +function gitCorpus(root: string): Corpus { + let raw: string; + try { + raw = execFileSync("git", ["ls-files", "-s", "-z"], { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + } catch (err) { + throw new RefusalError( + `could not enumerate tracked files under ${root} with \`git ls-files\`: ` + + `${err instanceof Error ? err.message : String(err)}. A gate that cannot list its ` + + `corpus has not observed it.`, + ); + } + + const tracked: string[] = []; + const gitlinks: string[] = []; + for (const record of raw.split("\0")) { + if (record === "") continue; + // ` \t` + const tab = record.indexOf("\t"); + if (tab < 0) { + throw new RefusalError( + `unparseable \`git ls-files -s\` record: ${JSON.stringify(record)}. Refusing rather ` + + `than dropping a path from the corpus silently.`, + ); + } + const mode = record.slice(0, 6); + const path = record.slice(tab + 1); + // AN UNMERGED PATH IS REFUSED, NOT COUNTED. `git ls-files -s` emits stages 1, 2 and 3 for + // a conflicted path, so the same path arrives three times. Reading the working-tree copy + // of a conflicted file means scanning conflict markers and reporting on a tree nobody has + // yet decided the contents of, and counting it three times is what would make the + // reconciliation below balance while the SET of paths did not. + // + // RE-DERIVED FOR THIS REPOSITORY RATHER THAN INHERITED. `scripts/phi-scan.ts` leaves the + // unmerged status UNENUMERATED in its staged route, and records why: `git commit` refuses + // an unmerged index outright at exit 128, so nothing unmerged can reach a commit through + // that gate. THAT REASONING DOES NOT TRANSFER HERE, because this gate has no staged route. + // It runs from the test suite and from CI on whatever tree it is handed, and a half-merged + // one is exactly the tree where a working-tree copy is conflict markers. + const stage = record.slice(tab - 1, tab); + if (stage !== "0") { + throw new RefusalError( + `tracked path is unmerged (stage ${stage}): ${path}. Resolve the conflict before ` + + `running this gate; a scan of a half-merged tree reports on nothing anyone has ` + + `decided yet.`, + ); + } + // A gitlink (mode 160000) is a submodule pointer with no bytes here to read. Counted and + // reported, never silently skipped: the OK line's arithmetic has to account for it. This + // repository tracks none today, which is why the count exists rather than an assumption. + if (mode === "160000") gitlinks.push(path); + else tracked.push(path); + } + + if (tracked.length === 0) { + throw new RefusalError( + `\`git ls-files\` under ${root} listed no readable tracked file. There is nothing here ` + + `to observe, so "the contract holds" would be a statement about an empty set. This ` + + `is the control case: a gate pointed at nothing must refuse, never report OK.`, + ); + } + + return { tracked, gitlinks }; +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +interface Args { + readonly root: string; +} + +function parseArgs(argv: readonly string[]): Args { + let root = process.cwd(); + let i = 0; + while (i < argv.length) { + const a = argv[i]; + if (a === "--root") { + const next = argv[i + 1]; + if (next === undefined) throw new InvocationError("--root requires a directory argument"); + root = isAbsolute(next) ? next : resolve(process.cwd(), next); + i += 2; + } else if (a !== undefined && a.startsWith("--")) { + throw new InvocationError(`Unknown flag: ${a}`); + } else if (a !== undefined) { + throw new InvocationError(`Unexpected positional argument: ${a}`); + } else { + i += 1; + } + } + return { root }; +} + +/** + * ONE OPEN, THEN `fstat` AND READ THROUGH THAT SAME DESCRIPTOR, NOT `lstat`-then-read-by-path. + * + * The obvious shape (`lstatSync(abs)`, check, then `readFileSync(abs)`) is a TIME-OF-CHECK / + * TIME-OF-USE RACE, and this repository runs a required `codeql / analyze` context that flags + * it as `js/file-system-race` at high severity. The two calls resolve the path INDEPENDENTLY, + * so what was checked and what was read need not be the same object: anything that can replace + * the path between them (a concurrent `git checkout`, a rebase, an editor's atomic save, a + * hostile symlink swap) gets its bytes read under a path this gate already decided was a safe + * regular file. The symlink refusal is the one that matters, because defeating it is how bytes + * from OUTSIDE the tree get scanned and reported on as though they were tracked content. + * + * The fix is structural rather than a re-check, because a re-check is the same race again. The + * path is resolved EXACTLY ONCE, by `openSync`, and every question after that is asked of the + * resulting descriptor, which is bound to one inode for its lifetime: + * + * * `O_NOFOLLOW` makes the SYMLINK REFUSAL PART OF THE OPEN. The kernel fails with `ELOOP` + * rather than handing back a descriptor on the target, so there is no window between + * "is it a link" and "read it". + * * `O_NONBLOCK` is not decoration: opening a FIFO for reading BLOCKS until a writer + * appears, so a tracked FIFO would hang the gate forever instead of refusing it. + * * `fstatSync(fd)` asks about the OPENED OBJECT, not about a path that may since have + * moved, which is what makes the regular-file check meaningful rather than advisory. + * + * STATED LIMIT: `O_NOFOLLOW` only refuses a symlink as the FINAL path component. A symlinked + * PARENT DIRECTORY is still traversed. Closing that needs `openat2(RESOLVE_BENEATH)`, which + * Node does not expose. This repository's PHI scanner has the same boundary and discloses it + * the same way; do not restate either as closed. + * + * If `O_NOFOLLOW` is unavailable (it is not defined on Windows), the gate REFUSES rather than + * quietly dropping the symlink guarantee. A gate that cannot keep a promise says so. + */ +function readTracked(root: string, path: string): Buffer { + const abs = join(root, path); + + if (typeof constants.O_NOFOLLOW !== "number") { + throw new RefusalError( + `this platform does not provide O_NOFOLLOW, so a tracked path cannot be opened with ` + + `the symlink refusal applied atomically. Refusing rather than scanning with the ` + + `guarantee silently dropped.`, + ); + } + + let fd: number; + try { + fd = openSync(abs, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + // ELOOP is Linux's answer to O_NOFOLLOW on a symlink; some BSDs answer EMLINK. + if (code === "ELOOP" || code === "EMLINK") { + throw new RefusalError( + `tracked path is a symbolic link: ${path}. Reading through it would scan bytes from ` + + `somewhere else under this path's name. Refused by name rather than skipped, so ` + + `the reconciliation below stays honest. The link target is deliberately not ` + + `printed: a target path is itself a surface that can carry PHI, which is the rule ` + + `this repository's PHI scanner already refuses under.`, + ); + } + if (code === "ENOENT" || code === "ENOTDIR") { + throw new RefusalError( + `tracked path is missing from the working tree: ${path} ` + + `(${err instanceof Error ? err.message : String(err)}). A scan that could not open ` + + `one of its inputs has not observed the corpus it is about to report on.`, + ); + } + throw new RefusalError( + `tracked path is not readable: ${path} ` + + `(${err instanceof Error ? err.message : String(err)}).`, + ); + } + + try { + if (!fstatSync(fd).isFile()) { + throw new RefusalError( + `tracked path is not a regular file: ${path}. Refusing to report green from a scan ` + + `that skipped one of its inputs.`, + ); + } + return readFileSync(fd); + } catch (err) { + if (err instanceof RefusalError) throw err; + throw new RefusalError( + `tracked path is not readable: ${path} ` + + `(${err instanceof Error ? err.message : String(err)}).`, + ); + } finally { + closeSync(fd); + } +} + +function main(argv: readonly string[]): number { + selfTest(); + + const { root } = parseArgs(argv); + const { tracked, gitlinks } = gitCorpus(root); + + const violations: Violation[] = []; + + // ---- 1. The pair exists ------------------------------------------------ + const contractPaths = tracked.filter( + (p) => p === CONTRACT_BASENAME || p.endsWith(`/${CONTRACT_BASENAME}`), + ); + if (contractPaths.length > 1) { + throw new RefusalError( + `${String(contractPaths.length)} tracked files are named ${CONTRACT_BASENAME} ` + + `(${contractPaths.join(", ")}). Every pointer would be ambiguous, so no verdict on ` + + `them is meaningful. Refusing rather than guessing which one a pointer meant.`, + ); + } + + const cursorTracked = tracked.includes(CURSOR_PATH); + if (!cursorTracked) { + violations.push({ + where: CURSOR_PATH, + what: `the cursor half of the pair is not tracked. The contract is two files; one of them is gone.`, + }); + } + + const contractPath = contractPaths[0]; + if (contractPath === undefined) { + violations.push({ + where: `documentation/${CONTRACT_BASENAME}`, + what: + `the narrative half of the pair is not tracked. Every rule in ${CURSOR_PATH} that ` + + `cites it is now an imperative with no grounding. Restore the file or move the ` + + `narrative back; do not delete the pointers.`, + }); + } + + // ---- 2. Anchors and sections ------------------------------------------ + let anchors = new Set(); + let sectionCount = 0; + let containerCount = 0; + if (contractPath !== undefined) { + const buf = readTracked(root, contractPath); + if (buf.includes(0)) { + throw new RefusalError( + `${contractPath} contains a NUL byte, so it is not the markdown this gate parses. ` + + `Refusing rather than reporting on bytes it cannot read as text.`, + ); + } + const text = buf.toString("utf8"); + if (text.trim() === "") { + violations.push({ + where: contractPath, + what: `the narrative file is empty. Its existence is not the contract; its content is.`, + }); + } + const lines = text.split("\n"); + const headings = extractHeadings(lines); + sectionCount = headings.length; + anchors = new Set(headings.map((h) => h.slug)); + + if (headings.length === 0 && text.trim() !== "") { + throw new RefusalError( + `extracted no headings from ${contractPath}, which is ${String(lines.length)} line(s) ` + + `long and not empty. Every anchor this gate resolves comes from that extraction, so ` + + `an empty one means the extractor broke, not that the file has no sections.`, + ); + } + + const sections = emptySections(lines, headings); + containerCount = sections.containers; + for (const h of sections.empty) { + violations.push({ + where: `${contractPath}:${String(h.line)}`, + what: + `section "${h.text}" (#${h.slug}) has no body. A pointer at it resolves to nothing, ` + + `which is the same defect as a dangling anchor with a friendlier error message. ` + + `Restore the narrative; do not delete the heading to clear this.`, + }); + } + } + + // ---- 3. Every qualified pointer resolves, and the bare form stays absent ----------------- + // THE TWO SETS BELOW ARE SETS, NOT COUNTERS, AND THAT IS THE WHOLE POINT OF THE + // RECONCILIATION. A pair of counters incremented one per loop iteration can only ever sum to + // the number of iterations, so comparing that sum against the corpus size is a tautology + // dressed as a check. Sets of PATHS cannot: they catch a path enumerated twice, a path + // visited twice, and a path in the corpus that no branch ever reached. + const openedPaths = new Set(); + const skippedPaths = new Set(); + let pointerCount = 0; + const pointerFiles = new Set(); + let bareReferences = 0; + + for (const path of tracked) { + const buf = readTracked(root, path); + if (buf.includes(0)) { + // A NUL-bearing file is not markdown and cannot be edited to clear a red, so it is + // skipped. THIS IS DISCLOSED MISS (v), NOT A PASS: a pointer inside such a file is never + // read. The tell is the skipped count on the OK line. + skippedPaths.add(path); + continue; + } + openedPaths.add(path); + const text = buf.toString("utf8"); + const lines = text.split("\n"); + + // THE BARE CENSUS, IN THE PAIR ONLY. It runs before the early return below, because a bare + // span is not accompanied by the qualified basename and would otherwise never be seen. + if (path === CURSOR_PATH || path === contractPath) { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] ?? ""; + const re = barePattern(); + let bm: RegExpExecArray | null; + while ((bm = re.exec(line)) !== null) { + const anchor = bm[1] ?? ""; + if (DIGITS_ONLY.test(anchor)) { + bareReferences += 1; + continue; + } + throw new RefusalError( + `suspected BARE pointer \`#${anchor}\` at ${path}:${String(i + 1)}. This gate ` + + `matches the QUALIFIED spelling only, and that scope was derived from a ` + + `measurement of this tree in which no bare pointer existed. A bare-shaped span ` + + `that is not a digits-only pull-request reference means that measurement is ` + + `now stale, so "all resolving" would be a claim about a corpus this gate no ` + + `longer covers. RE-DERIVE THE MATCHER against the new spelling, or write the ` + + `pointer in the qualified form. Do not delete the span to clear this.`, + ); + } + } + } + + if (!text.includes(`${CONTRACT_BASENAME}#`)) continue; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] ?? ""; + const re = pointerPattern(); + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) { + const anchor = m[1] ?? ""; + pointerCount += 1; + pointerFiles.add(path); + if (anchors.has(anchor)) continue; + + // Disclosed miss (i): the wrap join, attempted ONLY on an anchor that already failed + // to resolve and only when it ran to the end of the line. It can rescue a false red; + // it cannot manufacture a pass for a pointer the line pass already resolved. + if (m.index + m[0].length === line.length) { + const tail = (lines[i + 1] ?? "").replace(/^[ \t>*-]*/, ""); + const joined = new RegExp(`^(${ANCHOR_CHARS}+)`, "u").exec(tail); + if (joined && anchors.has(anchor + (joined[1] ?? ""))) continue; + } + + violations.push({ + where: `${path}:${String(i + 1)}`, + what: + `pointer #${anchor} does not resolve to a heading in ` + + `${contractPath ?? `documentation/${CONTRACT_BASENAME}`}. Fix the anchor or ` + + `restore the section. Deleting the pointer to clear this deletes the grounding ` + + `for the rule that cited it.`, + }); + } + } + } + + // ---- 4. Reconcile, then report ---------------------------------------- + const opened = openedPaths.size; + const skippedBinary = skippedPaths.size; + const unaccounted = tracked.filter((p) => !openedPaths.has(p) && !skippedPaths.has(p)); + if (opened + skippedBinary !== tracked.length || unaccounted.length > 0) { + throw new RefusalError( + `reconciliation failed: ${String(tracked.length)} tracked non-gitlink path(s) ` + + `enumerated, ${String(opened)} opened and ${String(skippedBinary)} skipped as binary, ` + + `${String(unaccounted.length)} reached by no branch` + + `${unaccounted.length > 0 ? ` (first: ${String(unaccounted[0])})` : ""}. Every path ` + + `must be opened or skipped for a named reason, and no path twice. A corpus that does ` + + `not reconcile means the scan is reporting on something it did not read.`, + ); + } + + if (pointerCount === 0) { + throw new RefusalError( + `found ZERO qualified pointers at ${CONTRACT_BASENAME} across ${String(opened)} opened ` + + `file(s). In this repo that is not a clean tree: ${CURSOR_PATH} opens by linking the ` + + `narrative file and cites it by anchor throughout. Zero means the matcher stopped ` + + `matching, so the pointer half of this gate observed nothing and proved nothing. ` + + `EXISTENCE IS NOT OBSERVATION, and a denominator would not have caught this either.`, + ); + } + + if (violations.length > 0) { + process.stderr.write( + `ERROR: check-agent-notes - the two-file contract is broken in this repo ` + + `(${String(violations.length)} finding(s)).\n\n`, + ); + for (const v of violations) { + process.stderr.write(` ${v.where}\n ${v.what}\n\n`); + } + process.stderr.write( + ` This gate asserts THIS repo's contract only. It says nothing about any sibling: ` + + `measured 2026-08-06, seven cosyte repos carry no ${CONTRACT_BASENAME} at all, and ` + + `for those the honest outcome is a written exemption rather than an invented file.\n`, + ); + return 1; + } + + process.stdout.write( + // NOT "all with a body". A container is exempt and is never asked, so that phrasing became + // false the moment the exemption landed. + `check-agent-notes: OK (${contractPath ?? "?"}: ${String(sectionCount)} section(s), ` + + `${String(containerCount)} of them container(s) whose body is their subsections and the ` + + `rest with a body of their own; ${String(pointerCount)} qualified pointer(s) from ` + + `${String(pointerFiles.size)} file(s), all resolving; ${String(bareReferences)} ` + + `bare-shaped span(s) in the pair, every one a digits-only reference and none a pointer; ` + + `${String(tracked.length)} tracked path(s) reconciled = ${String(opened)} opened + ` + + `${String(skippedBinary)} skipped as binary, plus ${String(gitlinks.length)} gitlink(s) ` + + `with no bytes here)\n`, + ); + return 0; +} + +function run(): number { + try { + return main(process.argv.slice(2)); + } catch (err) { + if (err instanceof RefusalError) { + process.stderr.write(`[check-agent-notes] refusing: ${err.message}\n`); + return 2; + } + if (err instanceof InvocationError) { + process.stderr.write(`[check-agent-notes] bad invocation: ${err.message}\n`); + return 2; + } + process.stderr.write( + `[check-agent-notes] refusing: the check failed before it could finish: ` + + `${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`, + ); + return 2; + } +} + +process.exit(run()); diff --git a/test/scripts/agent-notes.test.ts b/test/scripts/agent-notes.test.ts new file mode 100644 index 0000000..8ff3250 --- /dev/null +++ b/test/scripts/agent-notes.test.ts @@ -0,0 +1,771 @@ +/** + * Unit tests for `scripts/check-agent-notes.ts`, the two-file-contract gate. + * + * WHAT IS BEING PROVED, in the order it matters: + * + * 1. THE GATE SEES. Each of the three things it claims to check is seeded into a throwaway + * repo and shown RED, then repaired and shown GREEN in the same tree. A gate is only + * worth its exit code once it has been watched to fail. + * 2. THE GATE REFUSES RATHER THAN REPORTING CLEAN OVER A CORPUS IT NEVER OPENED. This is the + * control, and it is the whole reason the exit codes are split 1/2. A gate pointed at an + * empty tree must exit 2, never 0. So must a tree with no pointers in it at all. + * 3. THE BARE CENSUS FIRES IN BOTH DIRECTIONS. A digits-only reference must NOT refuse (or + * the gate reds on every pull-request number in the narrative file), and a genuine bare + * pointer MUST refuse (or this gate's single-form scope silently stops covering the + * corpus, which is the most repeated defect in this whole class of gate). + * 4. THE BYPASS CLASSES ARE REPRODUCED END TO END, not asserted in the abstract. + * 5. THE REAL TREE IS GREEN, run through the real script. That case is what puts this gate + * on the meta-repo's `scripts/verify.sh cli` ladder without the ladder having to name it, + * and it is what reds if a future edit breaks an anchor on either side of the pair. + * + * WHAT IS DELIBERATELY NOT PROVED HERE: that any sibling repo satisfies the same contract. + * Measured 2026-08-06, seven cosyte repos carry no `documentation/agent-notes.md` at all, so a + * universal assertion would be an overclaim and the honest outcome for those repos is a + * written exemption. The script's banner says so; this file does not restate the argument, it + * declines to test the universal. + * + * ASSERT THE PREMISE, NOT ONLY THE REMEDY. This repository has already sprung two vacuity + * traps in `test/scripts/phi-scan.test.ts`: a fixture whose `git merge` refused on an + * identity-less runner, so every later assertion held over an empty result, and a loop that + * asserted only the detection-OFF side. So every repair case here asserts the RED first and + * the GREEN second in the same tree, and the fixture builder below never needs a commit (and + * therefore never needs a committer identity): `git ls-files` reads the INDEX, so `git init` + * plus `git add` is the whole setup and there is no identity-dependent step to fail silently. + * + * RUNNER: `tsx`, matching `test/scripts/phi-scan.test.ts` and `pnpm check:agent-notes`. Node + * 22 is this package's floor and its native type stripping is behind a flag there, so spawning + * `node` directly on the `.ts` gate would test something the commit gate does not run. The + * cost is a cold `tsx` start per case, which this repository has measured at 3.7s under + * contention against a shared 10s default, so every describe below sets its own budget. + * + * The throwaway repos are created under the OS temp dir, never under `test/`. `test/` is a + * `phi-scan` walk root and that scanner refuses a root it did not observe, so putting churn + * there would couple two gates that have nothing to do with each other. + */ + +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const REPO_ROOT = process.cwd(); +const GATE_PATH = join(REPO_ROOT, "scripts", "check-agent-notes.ts"); +const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx"); + +/** Each case spawns `tsx` cold. Measured here at 3.7s under contention; budget generously. */ +const SLOW_MS = 60_000; + +interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +function runGate(args: string[]): RunResult { + const r = spawnSync(TSX_BIN, [GATE_PATH, ...args], { + cwd: REPO_ROOT, + encoding: "utf8", + shell: false, + }); + return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; +} + +let scratch: string; + +/** + * Build a throwaway git repo and return its path. Only the INDEX is populated (`git add`), not + * a commit: `git ls-files` reads the index, so no commit and therefore no committer identity + * is needed, which keeps these cases independent of whatever git config the box carries. + */ +function repo(files: Record): string { + const dir = mkdtempSync(join(scratch, "tree-")); + git(dir, ["init", "-q"]); + write(dir, files); + return dir; +} + +function write(dir: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const abs = join(dir, rel); + const slash = rel.lastIndexOf("/"); + if (slash > 0) mkdirSync(join(dir, rel.slice(0, slash)), { recursive: true }); + writeFileSync(abs, content); + } + git(dir, ["add", "-A"]); +} + +function git(dir: string, args: string[]): void { + const r = spawnSync("git", args, { cwd: dir, encoding: "utf8", shell: false }); + if ((r.status ?? -1) !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`); +} + +/** + * A sample pointer, ASSEMBLED rather than written out, and that is load-bearing rather than a + * style choice. + * + * The gate scans every tracked text file in the repo and carves out NO exemption for its own + * source or for this file. So a pointer written literally here would be read as a pointer into + * THIS repo's `documentation/agent-notes.md` and checked against its anchors, and every fixture + * below names a section that exists only inside a throwaway repo. + * + * The fix is deliberately the fixture and NOT an exemption. This repository's PHI scanner + * already shows what an exemption costs when it is drawn wider than one path. A gate's own + * tests are exactly where a genuinely broken pointer would hide, so they stay in scope. + */ +function ptr(anchor: string): string { + return `documentation/agent-notes.md${"#"}${anchor}`; +} + +/** A bare-form span, assembled for the same reason as `ptr`. */ +function bare(anchor: string): string { + return `\`${"#"}${anchor}\``; +} + +/** The narrative half, with one real section. */ +const NOTES = "# notes\n\nPreamble.\n\n## The section\n\nBody.\n"; + +beforeAll(() => { + scratch = mkdtempSync(join(tmpdir(), "cli-agent-notes-")); +}); + +afterAll(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +describe("check-agent-notes: the contract it asserts", { timeout: SLOW_MS }, () => { + it("is green on a tree that keeps the contract", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("check-agent-notes: OK"); + // The OK line must show its arithmetic, not just its verdict. + expect(r.stdout).toContain("2 tracked path(s) reconciled = 2 opened"); + }); + + it("reds when a pointer dangles, and goes green when the anchor is repaired", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-wrong-anchor")}\n`, + "documentation/agent-notes.md": NOTES, + }); + + const before = runGate(["--root", dir]); + expect(before.code).toBe(1); + expect(before.stderr).toContain("#the-wrong-anchor does not resolve"); + expect(before.stderr).toContain("CLAUDE.md:3"); + + write(dir, { "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n` }); + const after = runGate(["--root", dir]); + expect(after.code).toBe(0); + }); + + it("reds on a section that is nothing but its heading, and goes green when the body returns", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## The section\n\n## Next\n\nBody.\n", + }); + + const before = runGate(["--root", dir]); + expect(before.code).toBe(1); + expect(before.stderr).toContain('section "The section" (#the-section) has no body'); + + write(dir, { "documentation/agent-notes.md": NOTES }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("does NOT red a container heading, whose body is its subsections", () => { + // `## Group` immediately followed by `### Sub` has no prose of its own, but `#group` + // resolves on GitHub and the reader lands on real content, so reporting it is a red + // against a link that works. This shape is live in this repository's narrative file. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("group")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## Group\n\n### Sub\n\nReal body here.\n", + }); + const r = runGate(["--root", dir]); + expect(r.stderr).toBe(""); + expect(r.code).toBe(0); + }); + + it("still reds an emptied LEAF beneath a container, so the exemption opens no false green", () => { + // The only direction that would matter. The exemption moves the obligation DOWN to the + // deeper heading rather than removing it, so an emptied leaf is still a finding. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("sub")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## Group\n\n### Sub\n\n## Other\n\nBody.\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain('section "Sub" (#sub) has no body'); + }); + + it("treats a TRAILING heading as a leaf, never a container, since nothing deeper follows it", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": `${NOTES}\n### Trailing\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain('section "Trailing" (#trailing) has no body'); + }); + + it("reds when the narrative half is not tracked at all, and calls it a finding rather than a refusal", () => { + const dir = repo({ "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n` }); + const r = runGate(["--root", dir]); + // Exit 1, not 2: this is a broken contract a human acts on, not a scan that failed. + expect(r.code).toBe(1); + expect(r.stderr).toContain("the narrative half of the pair is not tracked"); + }); + + it("reds when the cursor half is not tracked", () => { + const dir = repo({ + "documentation/agent-notes.md": NOTES, + "README.md": `See ${ptr("the-section")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("the cursor half of the pair is not tracked"); + }); + + it("finds a qualified pointer wherever it is written, not only in CLAUDE.md", () => { + // The qualified form carries its own filename, so it is unambiguous anywhere. A + // markdown-only sweep would not open a source file at all. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "scripts/thing.ts": `// see ${ptr("not-a-section")}\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("scripts/thing.ts:1"); + }); +}); + +/** + * THE BARE CENSUS. This repository's pointers are 100% the QUALIFIED spelling, measured before + * the matcher was written, so the bare form is matched by no checker here. That scope is only + * safe while the bare form stays absent, and these cases are what keep the absence a + * measurement. Both directions are asserted, because a census that refuses on a pull-request + * reference is deleted within a day and a census that never fires proves nothing. + */ +describe("check-agent-notes: the bare census", { timeout: SLOW_MS }, () => { + it("does NOT refuse on a digits-only reference, and reports it on the OK line", () => { + // GitHub renders `#36` as a pull-request reference, and this repository's narrative file + // uses it that way. Refusing on these would red a healthy tree. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\nSee ${bare("36")} and ${bare("27")}.\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("2 bare-shaped span(s) in the pair"); + }); + + it("REFUSES on a genuine bare pointer, even one whose anchor RESOLVES", () => { + // THE DIRECTION THAT MATTERS. The tree is not necessarily broken; the evidence this gate's + // single-form scope was derived from is. Green here would be a claim about a corpus the + // gate no longer covers, which is exactly how a ported matcher printed `all resolving` + // over 3 of 38 pointers in a sibling repository. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\nAlso: ${bare("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stdout).not.toContain("OK"); + expect(r.stderr).toContain("suspected BARE pointer"); + expect(r.stderr).toContain("RE-DERIVE THE MATCHER"); + }); + + it("REFUSES on a bare pointer written into the NARRATIVE half too, not only the cursor", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": `${NOTES}\nSee ${bare("the-section")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("suspected BARE pointer"); + }); + + it("does NOT census a bare-shaped span OUTSIDE the pair, which is why the scope is the pair", () => { + // Measured on this tree: `CHANGELOG.md` carries backticked pull-request references, + // `tsup.config.ts` carries a quoted shebang, and `scripts/phi-allow-list.txt` carries a + // quoted comment marker. Widening the census to the whole corpus turns those into + // refusals, so a bare-shaped span elsewhere is deliberately not read as a pointer. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "README.md": `A CSS id is written ${bare("main")} and is not a pointer.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + // The census counted nothing outside the pair, so the reported total stays zero. + expect(r.stdout).toContain("0 bare-shaped span(s) in the pair"); + }); +}); + +describe("check-agent-notes: the control, and every other refusal", { timeout: SLOW_MS }, () => { + it("REFUSES when pointed at a tree with nothing tracked in it", () => { + const dir = mkdtempSync(join(scratch, "empty-")); + git(dir, ["init", "-q"]); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.code).not.toBe(0); + expect(r.stderr).toContain("listed no readable tracked file"); + expect(r.stdout).not.toContain("OK"); + }); + + it("REFUSES when pointed at something that is not a git repository", () => { + const dir = mkdtempSync(join(scratch, "norepo-")); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stdout).not.toContain("OK"); + }); + + it("REFUSES a tree that holds the pair but zero pointers, instead of calling it clean", () => { + // The `observed nothing` shape, in this gate's own terms: the corpus exists, the files + // open, and the answer is still meaningless. EXISTENCE IS NOT OBSERVATION, and a + // denominator would read healthy here. This is the case that would have caught a matcher + // ported into a tree whose pointers are spelled the other way. + const dir = repo({ + "CLAUDE.md": "# cursor\n\nNo link here.\n", + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("ZERO qualified pointers"); + }); + + it("REFUSES when two tracked files carry the contract basename, rather than guessing", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "docs-content/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("named agent-notes.md"); + }); + + it("REFUSES an unknown flag rather than scanning with it ignored", () => { + expect(runGate(["--everything"]).code).toBe(2); + }); + + it("REFUSES a tracked path that is a SYMLINK, rather than scanning bytes from outside the tree", () => { + // Enforced by O_NOFOLLOW on the open itself, so there is no window between the check and + // the read. Defeating this refusal is how untracked bytes get read under a tracked name. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + }); + writeFileSync(join(dir, "outside.txt"), "not tracked\n"); + symlinkSync(join(dir, "outside.txt"), join(dir, "link.md")); + git(dir, ["add", "-A"]); + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("tracked path is a symbolic link"); + // The target is deliberately not printed: a target path is itself a PHI surface. + expect(r.stderr).not.toContain("outside.txt"); + }); + + it("REFUSES a tracked path that is missing from the working tree", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "gone.md": "here for now\n", + }); + rmSync(join(dir, "gone.md")); + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("tracked path is missing from the working tree"); + }); + + it("REFUSES a tracked path replaced on disk by a DIRECTORY", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "swapped.md": "a regular file, for now\n", + }); + rmSync(join(dir, "swapped.md")); + mkdirSync(join(dir, "swapped.md")); + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("tracked path is not a regular file"); + }); + + it("REFUSES a tracked path replaced by a FIFO instead of hanging on it forever", () => { + // THIS IS WHAT MAKES O_NONBLOCK LOAD-BEARING RATHER THAN DECORATION. Opening a FIFO for + // reading blocks until a writer appears, so without the flag the gate would hang here + // indefinitely rather than refuse, and a hung gate reports nothing at all. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "pipe.md": "a regular file, for now\n", + }); + const fifo = join(dir, "pipe.md"); + rmSync(fifo); + const mk = spawnSync("mkfifo", [fifo], { encoding: "utf8", shell: false }); + if ((mk.status ?? -1) !== 0) return; // no mkfifo here; the directory case still covers the class + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("tracked path is not a regular file"); + }); + + it("REFUSES an UNMERGED path rather than scanning conflict markers", () => { + // This repository's PHI scanner leaves the unmerged status unenumerated in its STAGED + // route, and records why: `git commit` refuses an unmerged index at exit 128, so nothing + // unmerged can reach a commit through that gate. THAT REASONING DOES NOT TRANSFER HERE, + // because this gate has no staged route: it runs from the test suite and from CI over + // whatever tree it is handed. So the refusal is re-derived rather than inherited, and this + // case is what proves it fires. + // + // An unmerged index is built directly with `git update-index`, which needs no commit and + // therefore no committer identity. A `git merge` fixture is what sprang a vacuity trap in + // this repository's other script suite: it refused on an identity-less runner and every + // later assertion then held over an empty result. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "conflicted.md": "ours\n", + }); + const blob = spawnSync("git", ["hash-object", "-w", "--stdin"], { + cwd: dir, + input: "theirs\n", + encoding: "utf8", + shell: false, + }); + expect(blob.status).toBe(0); + const sha = blob.stdout.trim(); + git(dir, ["rm", "--cached", "-q", "conflicted.md"]); + const upd = spawnSync( + "git", + ["update-index", "--index-info"], + // Stages 1, 2 and 3 for one path: exactly what a conflicted index holds. + { + cwd: dir, + input: `100644 ${sha} 1\tconflicted.md\n100644 ${sha} 2\tconflicted.md\n100644 ${sha} 3\tconflicted.md\n`, + encoding: "utf8", + shell: false, + }, + ); + expect(upd.status).toBe(0); + + // ASSERT THE PREMISE: the index really is unmerged before the gate is asked about it. + const staged = spawnSync("git", ["ls-files", "-u"], { cwd: dir, encoding: "utf8" }); + expect(staged.stdout).toContain("conflicted.md"); + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("is unmerged"); + expect(r.stdout).not.toContain("OK"); + }); +}); + +describe( + "check-agent-notes: the bypass classes, reproduced end to end", + { timeout: SLOW_MS }, + () => { + it("sees a heading indented by one space (a /^#{1,6} / guard misses it)", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": "# notes\n\nPreamble.\n\n ## The section\n\nBody.\n", + }); + // A guard that missed this would report a FALSE RED against a link GitHub resolves. + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("sees a setext heading (an underline, not a hash)", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\nThe section\n-----------\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("does NOT mint an anchor from an ATX line inside a code fence", () => { + // The opposite direction from the two above, and the one that would let a dangling pointer + // through. A shell sample containing `# The section` is a comment, not a section, and this + // repository's narrative file embeds shell and `gh api` reproductions. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## Real\n\n```sh\n# The section\n```\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("#the-section does not resolve"); + }); + + it("resolves a pointer whose anchor is split across a line wrap", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-long-")}\nsection-name\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## The long section name\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("still reds a wrapped pointer whose join does not resolve either", () => { + // The join can rescue a false red; it must not manufacture a pass. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-long-")}\nwrong-name\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## The long section name\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(1); + }); + + it("disambiguates two identical headings the way GitHub does", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("same-1")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## Same\n\nA.\n\n## Same\n\nB.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("re-suffixes a slug that collides with an already-generated one", () => { + // github-slugger loops rather than counting: `Same`, `Same`, `Same-1` yields + // `same`, `same-1`, `same-1-1`. A counter yields `same-1` twice and reds `#same-1-1`. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("same-1-1")}\n`, + "documentation/agent-notes.md": + "# notes\n\nP.\n\n## Same\n\nA.\n\n## Same\n\nB.\n\n## Same-1\n\nC.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("keeps the leading hyphen a dropped leading character leaves behind", () => { + // github-slugger does NOT trim, so a heading led by a marker glyph slugs with a LEADING + // hyphen. A trim makes a pointer written without it pass here and resolve to nothing on + // GitHub. This repository's documents lead load-bearing rules with marker glyphs + // throughout, so the shape is reachable rather than exotic. + const notes = "# notes\n\nP.\n\n## ▶ The section\n\nBody.\n"; + const green = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("-the-section")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", green]).code).toBe(0); + + const red = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", red]).code).toBe(1); + }); + + it("does the same for an EMOJI marker, which this repository also uses", () => { + const notes = "# notes\n\nP.\n\n## \u{1f6d1} The section\n\nBody.\n"; + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("-the-section")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("does NOT mint a setext anchor from YAML front matter", () => { + // The closing `---` of front matter sits directly under a non-blank line, so a naive + // setext reader mints an anchor from `title: phantom`. That is the false-green direction. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("title-phantom")}\n`, + "documentation/agent-notes.md": + "---\ntitle: phantom\n---\n\n# notes\n\nP.\n\n## Real\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(1); + }); + + it("gives a wrapped setext heading the anchor of the whole paragraph, with the softbreak DELETED", () => { + // A wrapped setext heading is ONE heading whose text carries a newline, and the slug rule + // DELETES a newline rather than hyphenating it, so the two halves run together. + const notes = "# notes\n\nP.\n\nThe long\nsection name\n------------\n\nBody.\n"; + + const green = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-longsection-name")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", green]).code).toBe(0); + + const red = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-long-section-name")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", red]).code).toBe(1); + }); + + it("deletes a non-ASCII space separator from a slug, as the upstream rule does", () => { + // The separator is written as an escape, not a literal: a bare U+00A0 in a fixture is + // invisible to a reader and to a diff. + const notes = "# notes\n\nP.\n\n## A\u00a0B\n\nBody.\n"; + const green = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("ab")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", green]).code).toBe(0); + }); + + it("does not read a four-space-indented hash line as a heading, matching CommonMark", () => { + // Disclosed miss (vi) says the fence tracker does not model indented code blocks. It also + // says that is not reachable as a phantom anchor, because ATX indentation is bounded at + // three spaces. This pins the second half. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## Real\n\n ## The section\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(1); + }); + + it("keeps an underscore in a slug, which is what makes a diagnostic-code heading resolve", () => { + // A slugger that treated `_` as emphasis, or stripped it as punctuation, would red a + // pointer at a heading naming one of this package's `CLI_*` codes. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("cli_not_implemented-and-69")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## CLI_NOT_IMPLEMENTED and 69\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + }, +); + +/** + * One case per DISCLOSED MISS in the script header that is marked [PINNED], each written in the + * direction the miss actually fails. If a miss is added to the header, a case belongs here or + * the marking must say [SCOPE]. A disclosure that names a test must name one that exists. + */ +describe( + "check-agent-notes: the disclosed misses, each in the direction it fails", + { timeout: SLOW_MS }, + () => { + it("(i) passes a wrapped pointer whose head fragment is itself a valid anchor", () => { + // The join is attempted only after the unwrapped anchor fails, so a head that resolves + // ends the check there and the garbage tail is never seen. Asserted as green so that + // closing it later is a deliberate change and not a surprise. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\nzzz-not-an-anchor\n`, + "documentation/agent-notes.md": NOTES, + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("(ii) does not decode a percent-encoded anchor, so it reds", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the%20section")}\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + // Matched only up to the `%`, so it is reported as `#the`, not as `#the section`. + expect(r.stderr).toContain("pointer #the does not resolve"); + }); + + it("(iii) ignores an anchor on any other file, including the cursor half", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\nAlso CLAUDE.md#no-such-anchor.\n`, + "documentation/agent-notes.md": NOTES, + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("(iv) checks a pointer inside a fenced code block exactly like prose", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\n\`\`\`sh\n# ${ptr("bogus")}\n\`\`\`\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("pointer #bogus does not resolve"); + }); + + it("(v) skips a NUL-bearing file whole, so a pointer inside one is never read", () => { + // THE MISS THAT CAN PRINT `all resolving` OVER A DANGLING POINTER. Asserted green on + // purpose: it is disclosed in the script header and in CLAUDE.md, and the tell is the + // skipped count, which is asserted here too so a silent widening of the skip reds. + // + // The exclusion is REQUIRED in this repository rather than a tidy-up: the tree tracks + // vendored `@cosyte/*` tarballs and a synthetic DICOM fixture, none of which can be read + // as markdown or edited to clear a red. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "blob.bin": `binary \0 payload ${ptr("totally-bogus-anchor")}\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("3 tracked path(s) reconciled = 2 opened + 1 skipped as binary"); + }); + + it("(v) opens a file GIT would call binary but that holds no NUL, so the skip stays narrow", () => { + // The skip is keyed on an ACTUAL NUL byte, which is NARROWER than git's own binary + // classification. A lone-CR file (an HL7 v2 or ASTM terminator is a lone `CR`) is + // `i/-text` to git and holds no NUL, so this gate OPENS it and reads the pointers inside. + // Pinned so that nobody "simplifies" the skip to `grep -I` or `git ls-files --eol`, either + // of which would drop a readable text file from the sweep with no tell. + // `scripts/check-no-emdash.sh` keys on NUL for the same reason and agrees here. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + // Lone CR terminators, no NUL, and a dangling pointer inside. + "loneCr.txt": `first record\rsecond record, see ${ptr("not-a-section")}\r`, + }); + const r = runGate(["--root", dir]); + // Opened, therefore the pointer inside it is READ and reported. + expect(r.code).toBe(1); + expect(r.stderr).toContain("loneCr.txt"); + expect(r.stderr).toContain("#not-a-section does not resolve"); + }); + }, +); + +describe("check-agent-notes: against this repo", { timeout: SLOW_MS }, () => { + it("is green on this tree, with every pointer resolving", () => { + const r = runGate([]); + expect(r.stderr).toBe(""); + expect(r.code).toBe(0); + expect(r.stdout).toContain("documentation/agent-notes.md"); + expect(r.stdout).toContain("all resolving"); + }); + + it("accounts for every tracked path on the OK line", () => { + // The reconciliation is the anti-`observed nothing` property, so it is asserted as a + // property of the OUTPUT, not just of the exit code: `tracked == opened + skipped`. + const r = runGate([]); + const m = /(\d+) tracked path\(s\) reconciled = (\d+) opened \+ (\d+) skipped as binary/.exec( + r.stdout, + ); + expect(m).not.toBeNull(); + const tracked = Number(m?.[1]); + const opened = Number(m?.[2]); + const skipped = Number(m?.[3]); + expect(opened).toBeGreaterThan(0); + expect(tracked).toBe(opened + skipped); + }); + + it("reads a real, non-zero population of qualified pointers on this tree", () => { + // ASSERT THE PREMISE. The gate refuses at zero, so this cannot be vacuous, but the count + // is asserted as positive here anyway so that a future change which drops the corpus to a + // single pointer is visible as a deliberate act rather than a silent narrowing. + const r = runGate([]); + const m = /(\d+) qualified pointer\(s\) from (\d+) file\(s\), all resolving/.exec(r.stdout); + expect(m).not.toBeNull(); + expect(Number(m?.[1])).toBeGreaterThan(1); + expect(Number(m?.[2])).toBeGreaterThan(0); + }); + + it("reports the bare census on this tree, and every span on it is a digits-only reference", () => { + // The census is what keeps the single-form scope honest, so its result is asserted rather + // than merely produced. A non-digits bare span would have refused the run above. + const r = runGate([]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/\d+ bare-shaped span\(s\) in the pair/); + expect(r.stdout).toContain("none a pointer"); + }); +}); From 4a41be95502c383e19522946c41ac8b4f86e609e Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 10 Aug 2026 14:34:35 +0000 Subject: [PATCH 2/5] address the gate refuter: widen the bare census, withdraw three claims Pass 1 returned REFUTED with three INTRODUCED major findings. None needed a bigger matcher; all three were a claim that was wrong or a hole left open. 1. THE BARE CENSUS WAS SCOPED TO THE PAIR ON A FALSE JUSTIFICATION. The prose claimed a tree-wide census would refuse on CHANGELOG.md's pull-request references, on quoted shebangs in tsup.config.ts and docs-content/, and on a quoted comment marker in scripts/phi-allow-list.txt. Running it tree-wide proves none of them can: a shebang, a `# synthetic` marker and a lone hash are all outside the anchor class, and the CHANGELOG spans are digits-only, which the census classifies as references by design. The only files that could refuse were this gate's own source and its own test, which wrote bare spans out literally while illustrating the rule. So the narrow scope was a self-exemption for the two files where a broken pointer would hide, which the gate's own CONTRACT_BASENAME note already forbids. The samples are now assembled from parts, as the qualified ones always were, and the census covers every opened file. That also closes the real hole: a bare pointer in a THIRD file was covered by neither the matcher nor a pair-scoped census. 2. "A RENAME" WAS IN THE OPENING PROMISE AND IS NOT CAUGHT. Both halves match on basename, which is deliberate and is what lets a qualified, a ./-relative and a bare-path pointer reach one target, but the directory is never compared: moving the file to docs/agent-notes.md while pointers keep their prefix exits 0 while every rendered link 404s. The promise is narrowed to "stops being tracked at all" and the miss is disclosed and pinned. The guard was not grown. 3. A HEADING INSIDE AN HTML COMMENT MINTS A PHANTOM ANCHOR, and the disclosed-miss list omitted it. Every sibling copy of this gate discloses it; this one forgot, and the omission was the defect. Now disclosed and pinned in the false-green direction. Also from that pass: the written-down count of sibling repos lacking the file was already wrong (eight, not seven) and the set moves whenever a submodule is added, so the number is dropped in favour of the class, including in the stderr a human reads. Two [PINNED] misses had no case in the block whose own invariant promises one; both now do, alongside cases for the two misses added here. The stale "twenty real headings" figure is gone for the same reason. CLAUDE.md gained the basename trap as a one-line imperative and stays under its ceiling; the attw block was compressed to its imperatives, with the reasoning it compresses already in the archive. 53 tests pass. Every local gate green. --- CHANGELOG.md | 21 ++++-- CLAUDE.md | 23 +++--- documentation/agent-notes.md | 52 +++++++++++--- scripts/check-agent-notes.ts | 100 +++++++++++++++++++------- test/scripts/agent-notes.test.ts | 117 ++++++++++++++++++++++++++----- 5 files changed, 247 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22bea1e..93af64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,10 +33,23 @@ still do. Each entry was assigned to the release whose tag first contains it, re prose as a promise; the OK line prints all of them on every run, because it measures rather than remembers. - **A bare-form census keeps that scope honest.** Matching one spelling is safe only while the other - stays absent, so in the pair every backticked `#` span is enumerated: a digits-only one is - a pull-request reference and is counted and reported, and **any other one refuses the run at exit - 2** with an instruction to re-derive the matcher. Refusal rather than a finding, because the tree - has not necessarily broken but the evidence the scope rested on has. + stays absent, so in **every opened file** each backticked `#` span is enumerated: a + digits-only one is a pull-request reference and is counted and reported, and **any other one + refuses the run at exit 2** with an instruction to re-derive the matcher. Refusal rather than a + finding, because the tree has not necessarily broken but the evidence the scope rested on has. + A first draft scoped the census to the pair and justified that with tree files it claimed a + widening would red; **running it tree-wide proved none of them can, and that the only files that + could were the gate's own source and test**, which wrote bare spans out literally. That made the + narrow scope a self-exemption for the very files where a broken pointer would hide, so the samples + are now assembled from parts and the census covers the corpus. It closes the hole a pair-scoped + census leaves: a bare pointer in a third file was covered by neither the matcher nor the census. + - **Three claims were withdrawn after review rather than shipped.** The opening promise said the + gate catches "a rename"; it does not, because both halves match on **basename** and the directory + is never compared, so moving the file while pointers keep their prefix exits 0 with every rendered + link broken. That is now a narrowed promise and a disclosed miss rather than a bigger guard. A + **heading inside an HTML comment** mints a phantom anchor, a miss every sibling copy discloses and + this one had omitted; it is now disclosed and pinned. And a written-down count of sibling repos + lacking the file was already wrong, so **the number was dropped in favour of the class**. - **It refuses rather than reporting green over a corpus it never opened.** There is no declared scan root to be wrong about: the corpus is `git ls-files`, reconciled as **sets of paths**, and zero pointers, zero tracked files, an unmerged path, a symlink, a non-regular file or two files diff --git a/CLAUDE.md b/CLAUDE.md index 5ae550f..949fb12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,11 +13,12 @@ > it rides `ci / verify` and `prepublishOnly`): the narrative file must be tracked, every section must > have a body (a container's is its subsections), and every pointer at it **in a file it opened** must > resolve. **A NUL-bearing file is skipped: a disclosed miss, not a pass**; the tell is the skipped -> count. It matches the **QUALIFIED spelling only**; a **bare** backticked anchor in either half -> **REFUSES the run**: the measurement that scoped the matcher has gone stale. **Never port a -> sibling's matcher without re-counting both spellings here.** It asserts **this repo's promise, not a -> universal**, and **refuses (exit 2) rather than reporting green over a corpus it never opened**. -> **Never clear a red by deleting the pointer or the heading.** Why, and every miss: +> count, and **only the BASENAME is compared, so moving the file to another directory keeps this +> green while every link 404s**. It matches the **QUALIFIED spelling only**; a **bare** backticked +> anchor anywhere **REFUSES the run**: the measurement that scoped the matcher has gone stale. +> **Never port a sibling's matcher without re-counting both spellings here.** It asserts **this +> repo's promise, not a universal**, and **refuses (exit 2) rather than reporting green over a +> corpus it never opened**. **Never clear a red by deleting the pointer or the heading.** Why: > [agent-notes § The gate](documentation/agent-notes.md#the-two-file-contract-gate). ## Project @@ -342,16 +343,14 @@ return 0`, so no `--profile`, `--ignore-rules` or config setting reaches that ea - **`--profile node16` is load-bearing and is forwarded, never reinterpreted.** Without it `@cosyte/cli/mcp` fails `node10` resolution. - **Only a TOTAL loss of declarations is the false green; a PARTIAL one `attw` catches itself**, so - the preflight must report both outcomes and **must not assert the exit 0**. Six packed-but-undeclared - declarations decide which silence you get, and the obvious two-line version of this is false: a - refuter falsified a first draft of it in one run. **Re-measure before you shorten it.** + the preflight must report both outcomes and **must not assert the exit 0**. The obvious two-line + version of this is false: a refuter falsified a draft in one run. **Re-measure before shortening.** - **The post-check reads a string, so what would hide that string is refused**: `--quiet`, `-q`, `--format`, `-f`, `--config-path`, and a `.attw.json` setting `quiet` or `format`. **Say "exact argv - token" of the ARGV refusal, never "wholesale"** (the stronger wording was live and was refuted); the - `.attw.json` refusal **is** wholesale, and the two messages differ on purpose. + token" of the ARGV refusal, never "wholesale"** (the stronger wording was refuted); the `.attw.json` + refusal **is** wholesale, and the two messages differ on purpose. - **Two holes are disclosed and deliberately left open**: clustered short forms `-fjson` / `-Pf json`, - and a declared path not starting with `.`. Left open on purpose; the reasoning is in the linked - section. + and a declared path not starting with `.`. The reasoning is in the linked section. - `test/scripts/attw-gate.test.ts` pins both nets, the upstream exit 0 itself, a negative control, and that a real `attw` failure still fails with `attw`'s own status. diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index d7764a2..8888f4a 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -66,19 +66,33 @@ on the next commit with nobody touching it. Matching one form is safe exactly as long as the other stays absent, and an assumption nothing re-checks is how a matcher silently stops covering its corpus. So the absence is observed on every -run. In the **pair only**, every backticked span of the shape `#` is enumerated. A span -whose anchor is **all digits** is a pull-request reference, not a pointer: it is counted and reported -on the OK line rather than dropped in silence. **Any other bare span refuses the run at exit 2.** +run. In **every opened file**, every backticked span of the shape `#` is enumerated. A +span whose anchor is **all digits** is a pull-request reference, not a pointer: it is counted and +reported on the OK line rather than dropped in silence. **Any other bare span refuses the run at exit +2.** That is a refusal rather than a violation on purpose. The tree has not necessarily broken, but the evidence the matcher's scope was derived from has, so "all resolving" would be a claim about a corpus the gate no longer covers. **The remedy is to re-derive the matcher, never to delete the span.** -**The census is scoped to the pair, and that is measured rather than tidy.** Outside the pair the -bare shape is genuinely ambiguous on this tree: `CHANGELOG.md` carries backticked pull-request -references, `tsup.config.ts` and `docs-content/` carry quoted shebangs and shell comments, and -`scripts/phi-allow-list.txt` carries a quoted comment marker. Widening the census to the whole corpus -turns every one of those into a refusal. +**The route to the whole-corpus scope is worth recording, because the first draft got it wrong and a +refuter caught it.** That draft scoped the census to the pair and justified it by claiming the bare +shape is ambiguous elsewhere on this tree, naming `CHANGELOG.md`'s pull-request references, quoted +shebangs in `tsup.config.ts` and `docs-content/`, and a quoted comment marker in +`scripts/phi-allow-list.txt`. **That justification was false**, and running the census tree-wide is +what proved it: not one of those files can refuse. A shebang, a `# synthetic` marker and a lone `#` +do not match the pattern at all (a space, a `!`, a `/` and an empty run are all outside the anchor +class), and `CHANGELOG.md`'s spans are digits-only, which the census classifies as references by +design. + +**The only files that could refuse were the gate's own source and its own test**, because both wrote +bare spans out literally while illustrating the rule. So the narrow scope was not protecting innocent +content: it was a **self-exemption for the gate's own files**, which is the one place a genuinely +broken pointer would hide, and this gate's own `CONTRACT_BASENAME` note already forbids exactly that. +**The fix was the fixture and not the exemption.** Every bare sample in both files is now assembled +from parts, as the qualified samples always were, and the census reaches every opened file. That also +closes the real hole a pair-scoped census leaves: **a bare pointer written into a third file would +have been covered by neither the matcher nor the census.** ### What was re-derived here rather than inherited @@ -142,6 +156,28 @@ believed: The last two are the ones that matter most: they are the `terminology` scenario, and a gate that went green on either of them would be worthless while looking healthy. +### Three claims this gate made and had to withdraw + +All three were written into the first draft, all three were refuted, and each is recorded because the +wrong version is the easy one to write again. + +- **"A rename" was in the opening promise, and the gate does not catch one.** Both halves match on + **basename** (which is deliberate: it lets a path-qualified, a `./`-relative and a bare pointer all + reach the same target), so the **directory is never compared**. Measured: moving the narrative file + to `docs/agent-notes.md` while the pointers keep their old prefix exits **0**, "all resolving", + while every rendered link 404s on GitHub. **The promise was narrowed and the miss disclosed; the + guard was not grown.** It now reads "stops being tracked at all". +- **A heading inside an HTML comment mints a phantom anchor**, and the first draft's disclosed-miss + list omitted it. `` are not tracked as a block, so a commented-out `## Section` is + counted here and renders no anchor on GitHub: a pointer at it passes green and resolves to nothing. + **Every sibling copy of this gate discloses it; this one forgot, and that omission was the defect.** + Now disclosed and pinned. +- **A count of sibling repos was written down and was already wrong.** The draft said seven repos + carry no `agent-notes.md`; it was eight, and the set moves whenever a submodule is added. The rule + is **keep the list or drop the number**, so the number was dropped, including from the stderr a + human reads. The same file, forty lines higher, already warned that a figure written into a comment + goes stale with nobody touching it. + ### What no mechanical check here can see The heading, anchor and body checks all verify that prose **moved**. **None of them notices a trap diff --git a/scripts/check-agent-notes.ts b/scripts/check-agent-notes.ts index 577e8cc..87cd0da 100644 --- a/scripts/check-agent-notes.ts +++ b/scripts/check-agent-notes.ts @@ -10,22 +10,33 @@ * followed by an anchor into the narrative file, and if that anchor does not exist the reader * gets an imperative with no grounding. Three things can break silently and none had a check: * - * 1. the narrative file stops existing (a rename, a bad merge, a `git rm`); + * 1. the narrative file stops being tracked at all (a bad merge, a `git rm`); * 2. a section is emptied down to its heading, so a pointer resolves to nothing; and * 3. an anchor is edited on one side of the pair and not the other, so a pointer dangles. * * This gate checks those three, on this tree, and nothing else. * + * ▶ SAY "STOPS BEING TRACKED", NEVER "A RENAME". An earlier draft of the list above opened + * "the narrative file stops existing (A RENAME, a bad merge, a `git rm`)" and that was an + * OVERCLAIM, measured: a tree that moves the file to `docs/agent-notes.md` while `CLAUDE.md` + * still points at `documentation/agent-notes.md#...` exits 0, "all resolving", even though + * every one of those links now 404s on GitHub. Both halves of the gate match on BASENAME + * (`contractPaths` and `pointerPattern`), which is deliberate and is what lets a qualified, a + * `./`-relative and a bare-path pointer all reach the same target, but the cost is that the + * DIRECTORY is never compared. That is disclosed as miss (xi) rather than closed, because + * closing it means a path-agreement check, and the promise is the thing that was wrong here. + * * --------------------------------------------------------------------------- * IT IS NAMED FOR WHAT IT CHECKS AND IS DELIBERATELY NOT A UNIVERSAL, AND THAT IS THE MOST * IMPORTANT LINE IN THIS FILE. * * The two-file split was applied across the cosyte tree, so the tempting framing is "every * repo has a `CLAUDE.md` and a `documentation/agent-notes.md`, and this gate enforces the - * contract". MEASURED 2026-08-06, across the umbrella's own checkout: SEVEN repos have NO - * `documentation/agent-notes.md` at all. So the ecosystem-wide contract is either not - * universal or is violated in seven places, and a gate written as though it were universal - * would be asserting something seven repos disprove. That is an OVERCLAIM, and an + * contract". IT IS NOT. Re-measured across the umbrella's own checkout, a whole GROUP of + * cosyte repos carry NO `documentation/agent-notes.md` at all, `config` and `hl7` among them. + * So the ecosystem-wide contract is either not universal or is violated in every one of those + * places, and a gate written as though it were universal would be asserting something they + * disprove. That is an OVERCLAIM, and an * overclaiming guard is worse than a narrow one: it invites a reader to trust a promise the * tree does not keep, and the first repo that trips it deletes the gate instead of fixing * anything. For those repos the honest outcome is a WRITTEN EXEMPTION, not an invented file. @@ -55,7 +66,7 @@ * MEASURED HERE, ON THIS TREE, BEFORE THIS MATCHER WAS WRITTEN: the qualified form is the * only live one. Every pointer at the narrative file in this repository is written * `documentation/agent-notes.md#`, and there is no bare pointer anywhere. The - * bare-shaped spans that DO exist in the pair are pull-request references (`#` followed by + * bare-shaped spans that DO exist anywhere on it are pull-request references (`#` followed by * digits, which is what GitHub renders them as) plus spans that are not anchor-shaped at all, * such as a quoted shebang and a quoted heading. So a bare MATCHER here would have a * denominator of zero, and a per-form refusal keyed on it would refuse on a healthy tree @@ -73,8 +84,10 @@ * an assumption nothing re-checks is how a matcher silently stops covering its corpus. So the * absence is not assumed, it is OBSERVED on every run: * - * * In the PAIR ONLY (`CLAUDE.md` and the narrative file), every backticked span of the - * shape `#` is enumerated. + * * In EVERY OPENED FILE, every backticked span of the shape `#` is + * enumerated. Not the pair alone: a bare pointer written into a third file would be + * covered by neither the matcher nor a pair-scoped census, which is precisely the + * uncovered corner this census exists to remove. * * A span whose anchor is ALL DIGITS is a pull-request or issue reference, not a pointer. * It is COUNTED AND REPORTED on the OK line rather than dropped in silence, because a * matcher that quietly declines to look at some of its own hits is the defect this whole @@ -87,11 +100,23 @@ * about a corpus this gate no longer covers. The fix is to re-derive the matcher against * the new spelling, exactly as this one was derived, NOT to delete the span. * - * THE CENSUS IS SCOPED TO THE PAIR, AND THAT IS MEASURED RATHER THAN TIDY. Outside the pair - * the bare shape is genuinely ambiguous rather than merely noisy on THIS tree: `CHANGELOG.md` - * carries backticked pull-request references, `tsup.config.ts` and `docs-content/` carry - * quoted shebangs and shell comments, and `scripts/phi-allow-list.txt` carries a quoted - * comment marker. Widening the census to the whole corpus turns those into refusals. + * THE CENSUS COVERS THE WHOLE CORPUS, AND THE ROUTE TO THAT IS WORTH RECORDING BECAUSE A + * FIRST DRAFT GOT IT WRONG. That draft scoped the census to the pair and justified it by + * claiming the bare shape is ambiguous elsewhere on this tree, naming `CHANGELOG.md`'s + * pull-request references, quoted shebangs in `tsup.config.ts` and `docs-content/`, and a + * quoted comment marker in `scripts/phi-allow-list.txt`. THAT JUSTIFICATION WAS FALSE, and + * running the census tree-wide is what proved it: not one of those files can refuse. A + * shebang, `# synthetic` and a lone `#` do not match `barePattern` at all (a space, a `!`, a + * `/` and an empty run are all outside `ANCHOR_CHARS`), and `CHANGELOG.md`'s spans are + * digits-only, which the census classifies as references by design. + * + * The only files that could refuse were THIS ONE AND ITS TEST, because both wrote bare spans + * out literally while illustrating the rule. So the narrow scope was not protecting innocent + * content; it was a SELF-EXEMPTION FOR THE GATE'S OWN SOURCE, which is the one place a + * genuinely broken pointer would hide, and the rule against that is already written below at + * `CONTRACT_BASENAME`. The fix was the fixture and not the exemption: every bare sample in + * both files is now assembled from parts, exactly as the qualified samples always were, and + * the census reaches every opened file. * * --------------------------------------------------------------------------- * EXISTENCE IS NOT OBSERVATION, WHICH IS WHY THE OK LINE RECONCILES. @@ -157,7 +182,7 @@ * fragment is ITSELF a valid anchor and the tail is garbage, which passes green. * Closing that needs a markdown renderer, not a bigger regex. * (ii) [PINNED] A PERCENT-ENCODED OR HTML-ENTITY ANCHOR IS NOT DECODED. `#a%20b` matches - * only up to the `%`, so it is checked as `#a` and reds. No such pointer exists here. + * only up to the `%`, so it is checked as the anchor `a` alone and reds. None exists here. * (iii) [PINNED] A POINTER AT ANY OTHER FILE'S ANCHOR IS OUT OF SCOPE, including * `CLAUDE.md#...`. This gate is about the narrative file. A general markdown link * checker is a different tool with a different failure surface, and writing half of one @@ -194,7 +219,7 @@ * (vii) [PINNED] THE SLUGGER IS A TRANSCRIPTION OF github-slugger, NOT THE MODULE, so that * this gate adds no runtime dependency to a package whose dependency count is capped. * It was verified against github-slugger@2.0.0 by running the real module over THIS - * tree's twenty real headings plus the shapes below, not by reading a sibling's table. + * tree's every real heading plus the shapes below, not by reading a sibling's table. * `SLUG_CASES` carries the rows that would diverge from the obvious implementation. * ONE THING THE MODULE CANNOT BE FED DIRECTLY, AND GETTING THIS BACKWARDS IS A FALSE * RED: github-slugger slugs the RENDERED text of a heading, so a heading carrying a @@ -211,6 +236,20 @@ * (ix) [SCOPE] IT DOES NOT CHECK ANY BYTE BUDGET. `CLAUDE.md`'s ceiling is enforced by the * meta-repo's `.claude/hooks/doc-budget.mjs`, which holds the budget table; a script * inside this package cannot see it and must not keep a second copy of a number. + * (vi-b)[PINNED] AN ATX HEADING INSIDE AN HTML COMMENT IS COUNTED AS A HEADING HERE AND + * RENDERS NO ANCHOR ON GITHUB. `` are not tracked as a block, so a + * commented-out `## Section` mints a phantom anchor and a pointer at it passes green + * while resolving to nothing. This is the SAME false-green direction as (vi) and is the + * one the fence tracker does close, which is exactly why leaving it undisclosed was the + * defect: it is disclosed rather than guarded in EVERY copy of this gate across the + * cosyte repos, and this file's first draft was the copy that forgot to say so. + * Reproduced end to end before it was written down. + * (xi) [PINNED] THE NARRATIVE FILE'S DIRECTORY IS NEVER COMPARED, ONLY ITS BASENAME. Moving + * it to another directory while the pointers keep their old path prefix exits 0 with + * every rendered link broken. See the note under the opening list for the measurement + * and for why the basename match is deliberate. THE PROMISE WAS NARROWED RATHER THAN + * THE GUARD GROWN, and the miss is asserted green so that closing it later is a + * deliberate act. * (x) [SCOPE] IT DOES NOT NOTICE A TRAP THAT REACHED THE ARCHIVE WITH NO IMPERATIVE * FOLLOWING IT INTO `CLAUDE.md`. Heading, anchor and body checks all verify prose * MOVED. The exposed class is a trap phrased as a deliberate omission, which carries no @@ -341,7 +380,7 @@ function slugify(text: string): string { * input and the difference is a false red: headings `Same`, `Same`, `Same-1` yield `same`, * `same-1`, `same-1-1` upstream, because the third heading's OWN slug collides with the second * heading's GENERATED one and the suffix is applied again. A counter yields `same-1` twice, so - * a pointer at `#same-1-1` reds against a link GitHub resolves. Measured against + * a pointer at the anchor `same-1-1` reds against a link GitHub resolves. Measured against * github-slugger@2.0.0 and pinned by a self-test. */ function makeSlugger(): (text: string) => string { @@ -374,7 +413,8 @@ function pointerPattern(): RegExp { /** * The bare shape: an inline code span holding nothing but `#` and an anchor run. This is NOT a * matcher for a form this gate checks. It is the CENSUS that keeps "the bare form is absent - * here" a measurement instead of an assumption, and it runs in the pair only. See the header. + * here" a measurement instead of an assumption, and it runs over every opened file. See the + * header for why it is not scoped to the pair, and for the false justification that was. */ function barePattern(): RegExp { return new RegExp(`\`#(${ANCHOR_CHARS}+)\``, "gu"); @@ -390,7 +430,7 @@ const DIGITS_ONLY = /^\p{Nd}+$/u; const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/; /** * ATX. Up to three leading spaces (CommonMark), one to six hashes, and then EITHER whitespace - * or end of line: `#hashtag` is not a heading. Trailing closing hashes are stripped. + * or end of line: a hash run with no space after it is not a heading. Trailing hashes are stripped. * * A NAIVE `/^#{1,6} /` HAS TWO MEASURED BYPASSES, both handled here and both asserted in the * test file: a single leading space, and a setext underline. A missed heading is a missing @@ -506,7 +546,7 @@ function extractHeadings(lines: readonly string[]): Heading[] { * * A CONTAINER IS NOT AN EMPTIED SECTION, AND CONFLATING THEM IS A FALSE RED. A heading * immediately followed by a DEEPER one (`## Group` then `### Sub` with no prose between) is a - * container whose body IS its subsections. A pointer at `#group` resolves on GitHub and the + * container whose body IS its subsections. A pointer at the anchor `group` resolves on GitHub and the * reader lands on real content, so reporting it is a red against a link that works. That shape * is live in this repository's narrative file, where a top-level heading is followed straight * away by its first subsection. @@ -712,7 +752,14 @@ function selfTest(): void { // THE CENSUS MUST SEPARATE A REFERENCE FROM A SUSPECTED POINTER, or it either refuses on // every pull-request number in the narrative file or never fires at all. - const bare = [...["`#36` and `#a-real-anchor`"].join("\n").matchAll(barePattern())].map( + // + // ASSEMBLED FROM PARTS, EXACTLY AS THE QUALIFIED SAMPLE ABOVE IS, AND FOR THE SAME REASON. + // The census now runs over the WHOLE corpus, which includes this file, so a literally + // written bare span here would be a suspected bare pointer in this repository's own source + // and would refuse every run. Writing the samples out was what forced the earlier, narrower + // scope; assembling them is what let the scope widen. + const span = (anchor: string): string => `\`${"#"}${anchor}\``; + const bare = [...`${span("36")} and ${span("a-real-anchor")}`.matchAll(barePattern())].map( (m) => m[1] ?? "", ); if (bare.length !== 2 || !DIGITS_ONLY.test(bare[0] ?? "") || DIGITS_ONLY.test(bare[1] ?? "")) { @@ -1029,9 +1076,11 @@ function main(argv: readonly string[]): number { const text = buf.toString("utf8"); const lines = text.split("\n"); - // THE BARE CENSUS, IN THE PAIR ONLY. It runs before the early return below, because a bare - // span is not accompanied by the qualified basename and would otherwise never be seen. - if (path === CURSOR_PATH || path === contractPath) { + // THE BARE CENSUS, OVER EVERY OPENED FILE. It runs before the early return below, because a + // bare span is not accompanied by the qualified basename and would otherwise never be seen. + // It is deliberately NOT scoped to the pair: a bare pointer in a third file would then be + // covered by neither the matcher nor the census. See the header for the measurement. + { for (let i = 0; i < lines.length; i += 1) { const line = lines[i] ?? ""; const re = barePattern(); @@ -1123,7 +1172,7 @@ function main(argv: readonly string[]): number { } process.stderr.write( ` This gate asserts THIS repo's contract only. It says nothing about any sibling: ` + - `measured 2026-08-06, seven cosyte repos carry no ${CONTRACT_BASENAME} at all, and ` + + `several cosyte repos carry no ${CONTRACT_BASENAME} at all, and ` + `for those the honest outcome is a written exemption rather than an invented file.\n`, ); return 1; @@ -1136,7 +1185,8 @@ function main(argv: readonly string[]): number { `${String(containerCount)} of them container(s) whose body is their subsections and the ` + `rest with a body of their own; ${String(pointerCount)} qualified pointer(s) from ` + `${String(pointerFiles.size)} file(s), all resolving; ${String(bareReferences)} ` + - `bare-shaped span(s) in the pair, every one a digits-only reference and none a pointer; ` + + `bare-shaped span(s) across every opened file, each a digits-only reference and none a ` + + `pointer; ` + `${String(tracked.length)} tracked path(s) reconciled = ${String(opened)} opened + ` + `${String(skippedBinary)} skipped as binary, plus ${String(gitlinks.length)} gitlink(s) ` + `with no bytes here)\n`, diff --git a/test/scripts/agent-notes.test.ts b/test/scripts/agent-notes.test.ts index 8ff3250..2c9b18f 100644 --- a/test/scripts/agent-notes.test.ts +++ b/test/scripts/agent-notes.test.ts @@ -19,7 +19,7 @@ * and it is what reds if a future edit breaks an anchor on either side of the pair. * * WHAT IS DELIBERATELY NOT PROVED HERE: that any sibling repo satisfies the same contract. - * Measured 2026-08-06, seven cosyte repos carry no `documentation/agent-notes.md` at all, so a + * A group of cosyte repos carry no `documentation/agent-notes.md` at all, so a * universal assertion would be an overclaim and the honest outcome for those repos is a * written exemption. The script's banner says so; this file does not restate the argument, it * declines to test the universal. @@ -179,7 +179,7 @@ describe("check-agent-notes: the contract it asserts", { timeout: SLOW_MS }, () }); it("does NOT red a container heading, whose body is its subsections", () => { - // `## Group` immediately followed by `### Sub` has no prose of its own, but `#group` + // `## Group` immediately followed by `### Sub` has no prose of its own, but the anchor `group` // resolves on GitHub and the reader lands on real content, so reporting it is a red // against a link that works. This shape is live in this repository's narrative file. const dir = repo({ @@ -256,7 +256,7 @@ describe("check-agent-notes: the contract it asserts", { timeout: SLOW_MS }, () */ describe("check-agent-notes: the bare census", { timeout: SLOW_MS }, () => { it("does NOT refuse on a digits-only reference, and reports it on the OK line", () => { - // GitHub renders `#36` as a pull-request reference, and this repository's narrative file + // GitHub renders a hash followed by digits as a pull-request reference, and the narrative file // uses it that way. Refusing on these would red a healthy tree. const dir = repo({ "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\nSee ${bare("36")} and ${bare("27")}.\n`, @@ -264,7 +264,7 @@ describe("check-agent-notes: the bare census", { timeout: SLOW_MS }, () => { }); const r = runGate(["--root", dir]); expect(r.code).toBe(0); - expect(r.stdout).toContain("2 bare-shaped span(s) in the pair"); + expect(r.stdout).toContain("2 bare-shaped span(s)"); }); it("REFUSES on a genuine bare pointer, even one whose anchor RESOLVES", () => { @@ -293,20 +293,48 @@ describe("check-agent-notes: the bare census", { timeout: SLOW_MS }, () => { expect(r.stderr).toContain("suspected BARE pointer"); }); - it("does NOT census a bare-shaped span OUTSIDE the pair, which is why the scope is the pair", () => { - // Measured on this tree: `CHANGELOG.md` carries backticked pull-request references, - // `tsup.config.ts` carries a quoted shebang, and `scripts/phi-allow-list.txt` carries a - // quoted comment marker. Widening the census to the whole corpus turns those into - // refusals, so a bare-shaped span elsewhere is deliberately not read as a pointer. + it("censuses a bare span in a THIRD file too, not only in the pair", () => { + // THE HOLE A PAIR-SCOPED CENSUS LEAVES, AND WHY THE SCOPE IS THE WHOLE CORPUS. A bare + // pointer written into any file that is neither half of the pair would be covered by + // neither the matcher nor a pair-scoped census, which is exactly the uncovered corner the + // census exists to remove. A first draft scoped it to the pair and justified that with + // files on this tree that supposedly could not survive a widening; running it tree-wide + // proved none of them can refuse, and that the only files that could were the gate's own. const dir = repo({ "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, "documentation/agent-notes.md": NOTES, - "README.md": `A CSS id is written ${bare("main")} and is not a pointer.\n`, + "README.md": `See ${bare("some-anchor")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("suspected BARE pointer"); + expect(r.stderr).toContain("README.md"); + }); + + it("still treats a digits-only span in a third file as a reference, not a refusal", () => { + // The other direction of the widening. `CHANGELOG.md` on this tree carries backticked + // pull-request references, and refusing on those would red a healthy repository. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "CHANGELOG.md": `Fixed in ${bare("34")} and ${bare("27")}.\n`, }); const r = runGate(["--root", dir]); expect(r.code).toBe(0); - // The census counted nothing outside the pair, so the reported total stays zero. - expect(r.stdout).toContain("0 bare-shaped span(s) in the pair"); + expect(r.stdout).toContain("2 bare-shaped span(s)"); + }); + + it("does not match a bare-shaped span that is not anchor-shaped, so a shebang is inert", () => { + // Measured on this tree: a quoted shebang, a quoted `# synthetic` comment and a lone `#` + // are all outside the anchor class, so they never reach the reference/pointer decision. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "tsup.config.ts": "// a shebang is written `#!/usr/bin/env node` and a marker `# on`.\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("0 bare-shaped span(s)"); }); }); @@ -536,7 +564,7 @@ describe( it("re-suffixes a slug that collides with an already-generated one", () => { // github-slugger loops rather than counting: `Same`, `Same`, `Same-1` yields - // `same`, `same-1`, `same-1-1`. A counter yields `same-1` twice and reds `#same-1-1`. + // `same`, `same-1`, `same-1-1`. A counter yields `same-1` twice and reds the last of those. const dir = repo({ "CLAUDE.md": `# cursor\n\nWhy: ${ptr("same-1-1")}\n`, "documentation/agent-notes.md": @@ -663,7 +691,7 @@ describe( }); const r = runGate(["--root", dir]); expect(r.code).toBe(1); - // Matched only up to the `%`, so it is reported as `#the`, not as `#the section`. + // Matched only up to the `%`, so the reported anchor is `the`, not `the section`. expect(r.stderr).toContain("pointer #the does not resolve"); }); @@ -722,6 +750,60 @@ describe( expect(r.stderr).toContain("loneCr.txt"); expect(r.stderr).toContain("#not-a-section does not resolve"); }); + + it("(vi) does NOT mint an anchor from an ATX line inside a code fence", () => { + // (vi) was marked [PINNED] while this block held no case for it, which broke this + // block's own stated invariant even though the shape was exercised further up the file. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("fenced")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## Real\n\n```sh\n## Fenced\n```\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("#fenced does not resolve"); + }); + + it("(vi-b) DOES mint a phantom anchor from a heading inside an HTML comment", () => { + // THE MISS EVERY SIBLING COPY DISCLOSES AND THIS ONE FIRST FORGOT TO. `` + // are not tracked as a block, so a commented-out heading is counted here and renders no + // anchor on GitHub: a pointer at it passes green and resolves to nothing. Asserted GREEN + // on purpose, because it is a disclosed miss rather than a promise, and asserting it is + // what makes closing it later a deliberate act instead of a surprise. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("commented-out")}\n`, + "documentation/agent-notes.md": + "# notes\n\nP.\n\n## Real\n\nBody.\n\n\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + }); + + it("(vii) transcribes the slugger rather than importing it, and reds if that drifts", () => { + // (vii) was marked [PINNED] with no case in this block. The transcription is exercised + // by `SLUG_CASES` inside the gate's own self-test, so the direction it fails in is a + // REFUSAL before any tree is read: proved here by the fact that every other case in this + // file gets a verdict at all, and directly by a heading whose slug only a faithful + // transcription produces (no trim, and an underscore kept). + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("-cli_not_implemented")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## ▶ CLI_NOT_IMPLEMENTED\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("(xi) compares the narrative file's BASENAME only, so a move to another directory passes", () => { + // MEASURED, AND IT IS WHY THE OPENING PROMISE NO LONGER SAYS "a rename". The file moved + // out of `documentation/` while every pointer keeps the old path prefix: every rendered + // link 404s on GitHub and this gate still exits 0. Asserted green as a disclosed miss. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "docs/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + // The gate reports the path it actually found, which is the only tell a reader gets. + expect(r.stdout).toContain("docs/agent-notes.md"); + }); }, ); @@ -760,12 +842,13 @@ describe("check-agent-notes: against this repo", { timeout: SLOW_MS }, () => { expect(Number(m?.[2])).toBeGreaterThan(0); }); - it("reports the bare census on this tree, and every span on it is a digits-only reference", () => { + it("reports the bare census over this whole tree, every span a digits-only reference", () => { // The census is what keeps the single-form scope honest, so its result is asserted rather - // than merely produced. A non-digits bare span would have refused the run above. + // than merely produced. A non-digits bare span ANYWHERE on this tree, this file and the + // gate's own source included, would have refused the run above. const r = runGate([]); expect(r.code).toBe(0); - expect(r.stdout).toMatch(/\d+ bare-shaped span\(s\) in the pair/); + expect(r.stdout).toMatch(/\d+ bare-shaped span\(s\) across every opened file/); expect(r.stdout).toContain("none a pointer"); }); }); From 9e85950531b2555d62afda2f2b68d89e21f85b64 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 10 Aug 2026 14:47:47 +0000 Subject: [PATCH 3/5] address pass 2: withdraw the sibling universal, disclose the widening's cost Pass 2 returned REFUTED: the fix for an overclaim had shipped a new one. 1. "EVERY SIBLING COPY DISCLOSES THE HTML-COMMENT MISS" IS FALSE, and I took the phrasing from the review rather than from the tree. Re-measured on this checkout: ncpdp, terminology and astm disclose it; mllp, ccda, transform and docs do not, anywhere. mllp decides it, because this gate's disclosed-miss block is transcribed from mllp's shape and that list stops at (ix) as well, so the shared ancestor never carried the entry and "this one forgot" is backwards. The claim is withdrawn in the script, the archive and the changelog, and what replaces it is the enumeration rather than a count, so it can self-correct. Recorded with it: the umbrella's conventions assert the universal ("in every copy including ccda's") and the repos do not bear that out. Four sibling repos are owed this disclosure. That is the umbrella's to reconcile. 2. THE WIDENED CENSUS REFUSES ON AN ANCHOR-SHAPED SPAN THAT WAS NEVER A POINTER, and nothing disclosed it. A hex colour or a CSS id in an inline code span reds at exit 2 with advice to re-derive the matcher, which is wrong for a colour. Now disclosed as miss (xii) and pinned. DISCLOSED, NOT NARROWED: the direction is conservative (refuse, never a false green), and the pair-scoped census this replaced avoided the cost only by leaving every third file uncovered by both the matcher and the census. 3. The list read (viii), (ix), (vi-b), (xi), (x). Reordered so (vi-b) follows the (vi) it cross-references and (xi), (xii) follow (x). A draft of (xii) wrote its two examples out literally and the gate refused on its own source at exit 2, which is the widened census working exactly as intended. Both examples are now described rather than written, for the same reason every sample in these two files is assembled from parts. Swept: no non-digit literal bare span survives in any of the five files this slice touches. 54 tests pass. Every local gate green. CLAUDE.md unchanged and under its ceiling. --- CHANGELOG.md | 12 ++++++--- documentation/agent-notes.md | 12 ++++++++- scripts/check-agent-notes.ts | 45 +++++++++++++++++++++++--------- test/scripts/agent-notes.test.ts | 18 +++++++++++++ 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93af64b..69fa699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,9 +47,15 @@ still do. Each entry was assigned to the release whose tag first contains it, re gate catches "a rename"; it does not, because both halves match on **basename** and the directory is never compared, so moving the file while pointers keep their prefix exits 0 with every rendered link broken. That is now a narrowed promise and a disclosed miss rather than a bigger guard. A - **heading inside an HTML comment** mints a phantom anchor, a miss every sibling copy discloses and - this one had omitted; it is now disclosed and pinned. And a written-down count of sibling repos - lacking the file was already wrong, so **the number was dropped in favour of the class**. + **heading inside an HTML comment** mints a phantom anchor, which the list had omitted; it is now + disclosed and pinned. And a written-down count of sibling repos lacking the file was already + wrong, so **the number was dropped in favour of the class**. + - **A fourth claim was withdrawn on the next review pass, and it arrived inside the fix for the + third.** That fix asserted the HTML-comment miss is disclosed in "every sibling copy"; the + phrasing came from a review rather than from the tree, and it is false. Measured: three sibling + gates disclose it and four do not, including the one this gate's disclosed-miss block is + transcribed from, whose list stops at the same entry. Repeating an unmeasured universal is the + overclaim the gate's own banner refuses. - **It refuses rather than reporting green over a corpus it never opened.** There is no declared scan root to be wrong about: the corpus is `git ls-files`, reconciled as **sets of paths**, and zero pointers, zero tracked files, an unmerged path, a symlink, a non-regular file or two files diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index 8888f4a..37e5b4f 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -170,8 +170,18 @@ wrong version is the easy one to write again. - **A heading inside an HTML comment mints a phantom anchor**, and the first draft's disclosed-miss list omitted it. `` are not tracked as a block, so a commented-out `## Section` is counted here and renders no anchor on GitHub: a pointer at it passes green and resolves to nothing. - **Every sibling copy of this gate discloses it; this one forgot, and that omission was the defect.** Now disclosed and pinned. + - **The correction inside the correction, which is the one worth reading.** The first fix for this + said "**every** sibling copy discloses it; this one forgot". That was taken from a review's + phrasing rather than from the tree, and it is **false**. Measured across this checkout: `ncpdp`, + `terminology` and `astm` disclose it; **`mllp`, `ccda`, `transform` and `docs` do not, anywhere**. + `mllp` decides it, because this gate's disclosed-miss block is transcribed from `mllp`'s shape and + that list stops at (ix) as well, so the shared ancestor never carried the entry and "this one + forgot" is backwards. The umbrella's `documentation/conventions.md` asserts the universal ("in + every copy including `ccda`'s") and the repos do not bear it out. **That is the umbrella's to + reconcile, and four sibling repos are owed this disclosure.** Repeating an unmeasured universal is + exactly the overclaim this gate's banner refuses, so it cost a second refuter pass to notice that + a fix for an overclaim had shipped a new one. - **A count of sibling repos was written down and was already wrong.** The draft said seven repos carry no `agent-notes.md`; it was eight, and the set moves whenever a submodule is added. The rule is **keep the list or drop the number**, so the number was dropped, including from the stderr a diff --git a/scripts/check-agent-notes.ts b/scripts/check-agent-notes.ts index 87cd0da..149f93c 100644 --- a/scripts/check-agent-notes.ts +++ b/scripts/check-agent-notes.ts @@ -216,6 +216,21 @@ * block, but that is not reachable as a phantom anchor: `ATX_RE` bounds indentation at * three spaces, so a four-space-indented `#` line is not a heading here either, which * is what CommonMark does anyway. Both halves are asserted. + * (vi-b)[PINNED] AN ATX HEADING INSIDE AN HTML COMMENT IS COUNTED AS A HEADING HERE AND + * RENDERS NO ANCHOR ON GITHUB. `` are not tracked as a block, so a + * commented-out `## Section` mints a phantom anchor and a pointer at it passes green + * while resolving to nothing. This is the SAME false-green direction as (vi) and is the + * one the fence tracker does close, which is why leaving it undisclosed was worth fixing. + * Reproduced end to end before it was written down. + * ▶ DO NOT WRITE "EVERY SIBLING COPY DISCLOSES THIS". A draft of this entry did, taking + * the phrasing from a review rather than from the tree, and it is FALSE. Measured across + * this checkout: `ncpdp`, `terminology` and `astm` disclose it; `mllp`, `ccda`, + * `transform` and `docs` do not, anywhere. `mllp` is the decisive one, because the + * disclosed-miss block in this file is transcribed from its shape and ITS list stops at + * (ix) too, so the shared ancestor never carried this entry. The umbrella's + * `documentation/conventions.md` asserts the universal ("in every copy including + * `ccda`'s"), and the repos do not bear it out; that is the umbrella's to reconcile, and + * adopting the claim here would just be the overclaim this banner exists to refuse. * (vii) [PINNED] THE SLUGGER IS A TRANSCRIPTION OF github-slugger, NOT THE MODULE, so that * this gate adds no runtime dependency to a package whose dependency count is capped. * It was verified against github-slugger@2.0.0 by running the real module over THIS @@ -236,24 +251,30 @@ * (ix) [SCOPE] IT DOES NOT CHECK ANY BYTE BUDGET. `CLAUDE.md`'s ceiling is enforced by the * meta-repo's `.claude/hooks/doc-budget.mjs`, which holds the budget table; a script * inside this package cannot see it and must not keep a second copy of a number. - * (vi-b)[PINNED] AN ATX HEADING INSIDE AN HTML COMMENT IS COUNTED AS A HEADING HERE AND - * RENDERS NO ANCHOR ON GITHUB. `` are not tracked as a block, so a - * commented-out `## Section` mints a phantom anchor and a pointer at it passes green - * while resolving to nothing. This is the SAME false-green direction as (vi) and is the - * one the fence tracker does close, which is exactly why leaving it undisclosed was the - * defect: it is disclosed rather than guarded in EVERY copy of this gate across the - * cosyte repos, and this file's first draft was the copy that forgot to say so. - * Reproduced end to end before it was written down. + * (x) [SCOPE] IT DOES NOT NOTICE A TRAP THAT REACHED THE ARCHIVE WITH NO IMPERATIVE + * FOLLOWING IT INTO `CLAUDE.md`. Heading, anchor and body checks all verify prose + * MOVED. The exposed class is a trap phrased as a deliberate omission, which carries no + * identifier to grep for. Enumerate those by hand. * (xi) [PINNED] THE NARRATIVE FILE'S DIRECTORY IS NEVER COMPARED, ONLY ITS BASENAME. Moving * it to another directory while the pointers keep their old path prefix exits 0 with * every rendered link broken. See the note under the opening list for the measurement * and for why the basename match is deliberate. THE PROMISE WAS NARROWED RATHER THAN * THE GUARD GROWN, and the miss is asserted green so that closing it later is a * deliberate act. - * (x) [SCOPE] IT DOES NOT NOTICE A TRAP THAT REACHED THE ARCHIVE WITH NO IMPERATIVE - * FOLLOWING IT INTO `CLAUDE.md`. Heading, anchor and body checks all verify prose - * MOVED. The exposed class is a trap phrased as a deliberate omission, which carries no - * identifier to grep for. Enumerate those by hand. + * (xii) [PINNED] THE TREE-WIDE CENSUS REFUSES ON AN ANCHOR-SHAPED BACKTICKED SPAN THAT WAS + * NEVER A POINTER, AND THAT IS THE PRICE OF WIDENING IT. A three-digit hex colour, or a + * CSS id, written inside an inline code span with a leading hash refuses the run at exit + * 2 in any tracked file, and the refusal text says to re-derive the matcher, which is + * the wrong advice for a colour because there is no new spelling. (Those two examples + * are DESCRIBED rather than written out, for the same reason every sample in this file + * is assembled from parts: writing one would refuse this gate on its own source, which + * is precisely what a draft of this entry did.) DISCLOSED RATHER THAN NARROWED, + * deliberately, and the DIRECTION is what makes that defensible: it refuses rather than + * reporting a false green. The pair-scoped census this replaced avoided this cost only + * by carrying a much worse one, since a bare pointer in any third file was then seen by + * neither the matcher nor the census. If such a span ever lands legitimately, the answer + * is a per-path carve-out argued on its own merits, NOT a retreat to the pair, and NOT + * deleting the span if it turns out to be a real pointer. * * Run it locally with `pnpm check:agent-notes`. `pnpm test` runs it against this tree too * (`test/scripts/agent-notes.test.ts`), which is what puts it on the meta-repo's diff --git a/test/scripts/agent-notes.test.ts b/test/scripts/agent-notes.test.ts index 2c9b18f..d302bd8 100644 --- a/test/scripts/agent-notes.test.ts +++ b/test/scripts/agent-notes.test.ts @@ -791,6 +791,24 @@ describe( expect(runGate(["--root", dir]).code).toBe(0); }); + it("(xii) refuses on an anchor-shaped span that was never a pointer, the price of widening", () => { + // THE COST OF THE TREE-WIDE CENSUS, DISCLOSED RATHER THAN NARROWED. A hex colour or a CSS + // id written as a backticked anchor-shaped span refuses, and the refusal's advice to + // re-derive the matcher is wrong for a colour. Asserted so the cost is a known, deliberate + // trade rather than a surprise: the direction is conservative (refuse, never false green), + // and the pair-scoped census it replaced avoided this only by leaving every third file + // uncovered by both the matcher and the census. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "docs-content/theme.md": `The brand ink is ${bare("fff")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("suspected BARE pointer"); + expect(r.stderr).toContain("docs-content/theme.md"); + }); + it("(xi) compares the narrative file's BASENAME only, so a move to another directory passes", () => { // MEASURED, AND IT IS WHY THE OPENING PROMISE NO LONGER SAYS "a rename". The file moved // out of `documentation/` while every pointer keeps the old path prefix: every rendered From 096aa69f1896bd0e7741c35a91333db5bc3f8eea Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 10 Aug 2026 14:59:30 +0000 Subject: [PATCH 4/5] address pass 3: three sibling repos are owed the disclosure, not four Third generation of one defect, and the last: a correction carrying its own unmeasured claim about other repositories. 1. "FOUR SIBLING REPOS ARE OWED THIS DISCLOSURE" IS WRONG. Only three are. mllp, ccda and transform derive an anchor by SLUGGING a heading, so a commented-out heading mints a phantom anchor there and the HTML-comment disclosure applies to each. docs does not slug at all: it resolves pointers against explicit anchors, so the failure mode cannot arise there, and writing the disclosure into it would name a failure that repo cannot have. That is exactly the trap mllp's own miss (vi) states: a disclosure that names the wrong failure mode sends the next reader hunting something that cannot happen. The durable lesson is recorded with it, because "count more carefully" is not it: A CLAIM ABOUT ANOTHER REPOSITORY IS NOT CHECKABLE FROM INSIDE THIS ONE, so it is measured against that repository's source or it is not made. Three review passes were spent on three generations of that one mistake. 2. Miss (xii) overstated its own cost. A hex colour refuses only when it carries a LETTER; one whose digits are all decimal is classified as a pull-request reference and exits 0. Corrected, and the direction is noted: an entry that overstates its own cost is the safe way round to be wrong. 3. The changelog carried a bare count of a moving set and a dangling comparison ("whose list stops at the same entry", with no numeral anywhere in the entry). Both replaced by the enumeration, which is the rule this slice already adopted twice: keep the list or drop the number. No gate behaviour changed. scripts/check-agent-notes.ts is comment-only in this diff. 54 tests pass, every local gate green, CLAUDE.md untouched and under its ceiling. --- CHANGELOG.md | 16 ++++++++++------ documentation/agent-notes.md | 18 +++++++++++++++--- scripts/check-agent-notes.ts | 18 +++++++++++------- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69fa699..70a7fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,12 +50,16 @@ still do. Each entry was assigned to the release whose tag first contains it, re **heading inside an HTML comment** mints a phantom anchor, which the list had omitted; it is now disclosed and pinned. And a written-down count of sibling repos lacking the file was already wrong, so **the number was dropped in favour of the class**. - - **A fourth claim was withdrawn on the next review pass, and it arrived inside the fix for the - third.** That fix asserted the HTML-comment miss is disclosed in "every sibling copy"; the - phrasing came from a review rather than from the tree, and it is false. Measured: three sibling - gates disclose it and four do not, including the one this gate's disclosed-miss block is - transcribed from, whose list stops at the same entry. Repeating an unmeasured universal is the - overclaim the gate's own banner refuses. + - **Two further claims were withdrawn on later review passes, each arriving inside the fix for the + one before it.** The first fix asserted the HTML-comment miss is disclosed in "every sibling + copy"; that phrasing came from a review rather than from the tree, and it is false. `ncpdp`, + `terminology` and `astm` disclose it; `mllp`, `ccda`, `transform` and `docs` do not, and `mllp` + is the copy this gate's disclosed-miss block is transcribed from, so the shared ancestor never + carried the entry. The second fix then said the four that lack it are all owed it. Also false: + `mllp`, `ccda` and `transform` derive anchors by **slugging headings** and are owed it, while + `docs` resolves explicit `` anchors and cannot have the miss at all. **A claim about + another repository is not checkable from inside this one**, so it is measured against that + repository's source or not made. - **It refuses rather than reporting green over a corpus it never opened.** There is no declared scan root to be wrong about: the corpus is `git ls-files`, reconciled as **sets of paths**, and zero pointers, zero tracked files, an unmerged path, a symlink, a non-regular file or two files diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index 37e5b4f..bada753 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -179,9 +179,21 @@ wrong version is the easy one to write again. that list stops at (ix) as well, so the shared ancestor never carried the entry and "this one forgot" is backwards. The umbrella's `documentation/conventions.md` asserts the universal ("in every copy including `ccda`'s") and the repos do not bear it out. **That is the umbrella's to - reconcile, and four sibling repos are owed this disclosure.** Repeating an unmeasured universal is - exactly the overclaim this gate's banner refuses, so it cost a second refuter pass to notice that - a fix for an overclaim had shipped a new one. + reconcile.** Repeating an unmeasured universal is exactly the overclaim this gate's banner + refuses, so it cost a second refuter pass to notice that a fix for an overclaim had shipped a new + one. + - **And a third pass to notice that the fix for THAT one did it again, which is why this bullet is + three levels deep.** The corrected text went on to say "four sibling repos are owed this + disclosure", counting the four that do not disclose it. **Wrong, and unmeasured in the same way: + only three are owed it.** `mllp`, `ccda` and `transform` derive an anchor by SLUGGING a heading, + so a commented-out heading mints a phantom anchor there and the disclosure applies. **`docs` does + not slug at all**: it resolves pointers against explicit `` anchors, so the + failure mode cannot arise there and writing the disclosure into it would name a failure that + repo cannot have. That is the trap `mllp`'s own miss (vi) states in one line: _a disclosure that + names the wrong failure mode sends the next reader hunting something that cannot happen._ **The + lesson is not "count more carefully". It is that a claim about another repository is not + checkable from inside this one, so either measure it against that repository's source or do not + make it.** - **A count of sibling repos was written down and was already wrong.** The draft said seven repos carry no `agent-notes.md`; it was eight, and the set moves whenever a submodule is added. The rule is **keep the list or drop the number**, so the number was dropped, including from the stderr a diff --git a/scripts/check-agent-notes.ts b/scripts/check-agent-notes.ts index 149f93c..2681720 100644 --- a/scripts/check-agent-notes.ts +++ b/scripts/check-agent-notes.ts @@ -262,13 +262,17 @@ * THE GUARD GROWN, and the miss is asserted green so that closing it later is a * deliberate act. * (xii) [PINNED] THE TREE-WIDE CENSUS REFUSES ON AN ANCHOR-SHAPED BACKTICKED SPAN THAT WAS - * NEVER A POINTER, AND THAT IS THE PRICE OF WIDENING IT. A three-digit hex colour, or a - * CSS id, written inside an inline code span with a leading hash refuses the run at exit - * 2 in any tracked file, and the refusal text says to re-derive the matcher, which is - * the wrong advice for a colour because there is no new spelling. (Those two examples - * are DESCRIBED rather than written out, for the same reason every sample in this file - * is assembled from parts: writing one would refuse this gate on its own source, which - * is precisely what a draft of this entry did.) DISCLOSED RATHER THAN NARROWED, + * NEVER A POINTER, AND THAT IS THE PRICE OF WIDENING IT. A hex colour or a CSS id + * written inside an inline code span with a leading hash refuses the run at exit 2 in + * any tracked file, and the refusal text says to re-derive the matcher, which is the + * wrong advice for a colour because there is no new spelling. BUT ONLY WHEN IT CARRIES A + * LETTER, and a draft of this entry claimed otherwise: a hex colour whose digits are all + * decimal is classified by `DIGITS_ONLY` as a pull-request reference and exits 0. So the + * cost is real but SMALLER than the obvious statement of it, and the entry overstating + * its own cost is the safe direction to be wrong in. (Both examples are DESCRIBED rather + * than written out, for the same reason every sample in this file is assembled from + * parts: writing one refuses this gate on its own source, which is precisely what that + * same draft did.) DISCLOSED RATHER THAN NARROWED, * deliberately, and the DIRECTION is what makes that defensible: it refuses rather than * reporting a false green. The pair-scoped census this replaced avoided this cost only * by carrying a much worse one, since a bare pointer in any third file was then seen by From ddc5b25003e0790af7468396170edc32c41805fe Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 10 Aug 2026 15:10:49 +0000 Subject: [PATCH 5/5] correct two prose statements pass 4 measured as not exactly true Both are wording, neither changes gate behaviour, and pass 4 returned PASS with them filed rather than blocking. Fixed anyway, because leaving a known-false sentence inside a slice whose whole subject is overclaiming would be perverse. 1. The archive said a bullet is "three levels deep". Measured, it is two, and a sibling of the correction above it rather than its child. Clause deleted; a pure deletion cannot introduce a new claim. 2. Miss (xii) said a span refuses "only when it carries a letter". Exact for the hex-colour half, over-narrow for the CSS-id half: an id of digits with an underscore or a hyphen carries no letter and still refuses. The boundary is exactly DIGITS_ONLY, so it is now stated as "not all decimal digits", with both wrong versions named so neither is written again. Every version of this boundary was fail-closed, so none was ever a false green. 484 tests pass. Every local gate green. --- documentation/agent-notes.md | 4 ++-- scripts/check-agent-notes.ts | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index bada753..314b6c9 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -182,8 +182,8 @@ wrong version is the easy one to write again. reconcile.** Repeating an unmeasured universal is exactly the overclaim this gate's banner refuses, so it cost a second refuter pass to notice that a fix for an overclaim had shipped a new one. - - **And a third pass to notice that the fix for THAT one did it again, which is why this bullet is - three levels deep.** The corrected text went on to say "four sibling repos are owed this + - **And a third pass to notice that the fix for THAT one did it again.** The corrected text went on + to say "four sibling repos are owed this disclosure", counting the four that do not disclose it. **Wrong, and unmeasured in the same way: only three are owed it.** `mllp`, `ccda` and `transform` derive an anchor by SLUGGING a heading, so a commented-out heading mints a phantom anchor there and the disclosure applies. **`docs` does diff --git a/scripts/check-agent-notes.ts b/scripts/check-agent-notes.ts index 2681720..44b7195 100644 --- a/scripts/check-agent-notes.ts +++ b/scripts/check-agent-notes.ts @@ -265,11 +265,14 @@ * NEVER A POINTER, AND THAT IS THE PRICE OF WIDENING IT. A hex colour or a CSS id * written inside an inline code span with a leading hash refuses the run at exit 2 in * any tracked file, and the refusal text says to re-derive the matcher, which is the - * wrong advice for a colour because there is no new spelling. BUT ONLY WHEN IT CARRIES A - * LETTER, and a draft of this entry claimed otherwise: a hex colour whose digits are all - * decimal is classified by `DIGITS_ONLY` as a pull-request reference and exits 0. So the - * cost is real but SMALLER than the obvious statement of it, and the entry overstating - * its own cost is the safe direction to be wrong in. (Both examples are DESCRIBED rather + * wrong advice for a colour because there is no new spelling. BUT ONLY WHEN THE ANCHOR IS + * NOT ALL DECIMAL DIGITS, and two drafts of this entry got that boundary wrong in + * opposite directions. The rule is exactly `DIGITS_ONLY`: an all-decimal anchor is + * classified as a pull-request reference and exits 0, so a hex colour of decimal digits + * alone does NOT refuse, while one carrying a letter does. Do not restate that as "only + * when it carries a letter" either: an id of digits with an underscore or a hyphen + * carries no letter and still refuses. Every wrong version of this boundary is still + * fail-closed, so none of them was a false green. (Both examples are DESCRIBED rather * than written out, for the same reason every sample in this file is assembled from * parts: writing one refuses this gate on its own source, which is precisely what that * same draft did.) DISCLOSED RATHER THAN NARROWED,