Skip to content

Find PACT's CLAUDE.md markers outside code fences, and rebuild the pin-cap gate on that reading - #1656

Merged
michael-wojcik merged 123 commits into
mainfrom
feature/fence-aware-claude-md-markers
Oct 14, 2026
Merged

michael-wojcik merged 123 commits into
mainfrom
feature/fence-aware-claude-md-markers

Conversation

@michael-wojcik

@michael-wojcik michael-wojcik commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

PACT now finds its own markers in CLAUDE.md the same way everywhere, and never takes a line inside a fenced code example for the real thing. The pin-cap gate is rebuilt on that reading.

Why this is safe to merge

  • No ordinary edit is newly refused. Every refusal of an edit that remains is a user ruling, and each is listed under Limits: a pin grown past 1,500 characters, or a new one over it, without a valid size override; a split that adds oversize text; a move between oversize pins that adds formatting, or a blank line added inside an oversize pin; a copy of an override that is padded past 120 characters or reads invalid, on a pin the continuation rule ranks below the continuation; a reconfirmation date not in YYYY-MM-DD shape; a rationale that reads empty before a reconfirmation, or a symbol-only one changed while reconfirmed; and refusals main makes too. The evidence: across 53,524 generated certification cases the gate refuses 0 ordinary edits (the reviewed code refused 5, which the stray-heading ruling allows); on the maintainer's real files the only new refusals are per-pin size refusals; the security pass on c1ae93c's tree found no refusal of an ordinary edit; and the commits after it refuse nothing new outside the ruled classes, checked by differentials, fuzzing and the security rigs re-run on each tree.
  • Every failure allows. When the gate cannot read the file, cannot locate the Pinned section, runs past its budget, or fails, it allows the change and says the cap was not checked.
  • When PACT cannot read the file with certainty, it says so instead of guessing, and its own writers leave that part of the file alone. They now also refuse any change of their own that would make part of the file unreadable.
  • Every remaining gap is listed under Limits, one line each, with the reason it is accepted. None is open: before merge the user ruled on every entry that refuses an ordinary edit or lets an extra pin or extra size in; the others refuse no user's edit and grant no pin or size.
  • CI on Python 3.9, 3.13 and 3.14 at the final head, 6ff21d8: 20,724, 20,723 and 20,723 passed, 0 failed. Earlier heads' CI figures are under Testing.

What the PR does

  • One parser for every CLAUDE.md reader and writer. Session start, the Current Session block, the managed region, the legacy migration, the memory syncs, the pin markers, the staleness checks, the pin scripts and the pin-cap gate all locate PACT's markers and headings through one module. Before this, each did its own substring search, so a quoted example of a PACT block could be written into, duplicated, or counted as pins.
  • A marker inside a fenced code block is never a real marker. A heading inside one is never a section, and a ### line inside one is never a pin.
  • When the file cannot be read with certainty, PACT says so instead of guessing. An unclosed code fence, two copies of one PACT block, a start marker with no end marker, a marker that is indented, quoted or commented out, or an HTML block that can be read two ways leaves PACT unsure which block is real. Writers leave that part alone and keep everything before the problem working. At every launch the lead is told the file, the line, the likely cause, and which sections PACT will not update until it is fixed.
  • PACT's writers never make the file less readable. The two memory syncs, the stale-pin markings, the pin archive, the pin-marker writer and the kernel strip each check their planned text and refuse, naming the line, when it would add lines PACT cannot read. A refusal leaves the file as the user left it.
  • The pin-cap gate has one growth rule. It refuses an Edit or Write only when the change itself adds pins and the file ends up over the 12-pin cap, or when it leaves a pin over 1,500 characters, without a size override, that is new or larger than the pin it came from. At the cap, renaming, moving, reordering and rewriting pins are allowed. Renaming, moving or splitting a pin keeps it the same pin, and oversize pins the change does not grow are left alone.
  • The gate covers first Writes, empty-old-string Edits and teammates. A Write or an Edit that creates the project CLAUDE.md is counted by its own pins. PACT teammates, and subagents in a PACT team session, are gated like the lead; a refused teammate is told "Do not change CLAUDE.md yourself, by any route; tell the team-lead."
  • The gate reads the file without the writers' lock, and its cost is predictable. Each part of the decision (the pin-growth rule, the pin-size rule, and the override check's pin mapping, contest and matching) counts its work against its own limit, sized so that together they take about 7 s on the slowest CI runner; a text pair over 1,000,000 characters is not checked. Past any limit, or past 30 s of the check's own processor time or 540 s of wall time, the gate allows with a note saying which; running out never refuses, and a busy machine changes an answer only if it slows the check about 140-fold (see Measurements).
  • It keeps copies of CLAUDE.md text in the session folder. To report pin growth that arrives outside Edit and Write, PACT keeps a copy of the whole project CLAUDE.md for each agent that touched it, in that session's folder (<config dir>/pact-sessions/<project>/<session>/claude-md-last-seen/, files 0600 in a 0700 directory). A copy is replaced at that agent's next Bash call, or next checked Edit or Write, that finds the file changed, so until then it can hold text since deleted from CLAUDE.md. The pin-cap credit record (<session folder>/claude-md-pin-credit/, same modes) keeps the text of pins an agent removed with Edit or Write and has not put back: at most 24 pins and 32 KB for each recorded file state, and 32 states. Credit older than 12 hours is ignored, and its text is dropped at the record's next write. Both go with the session folder, which a later session of the same project deletes 30 days after the folder was last written (180 if the session was paused or refreshed, and never while its journal holds unharvested handoffs). A per-project baseline beside the session folders keeps only the file's path, hash and pin count, and is never removed. main keeps no CLAUDE.md text in session folders; its only comparable copy is the pin block /PACT:prune-memory saves to pact-memory, kept until deleted.
  • Pin growth through Bash is reported, not refused. After each Bash call, successful or failed, the agent is told when the project CLAUDE.md has grown past a pin cap since the last check. At each prompt the lead is told about growth from any route the gate cannot see (Bash, git, another session, the user's editor) and about growth the gate allowed past the cap.
  • The legacy migration keeps the user's text byte for byte. It keeps the indentation of the first kept line, the trailing spaces of the last, and a byte-order mark at the start of the file. It drops the old loader line only where the user's file shows it as visible prose. Before writing, it reads its own output back, and it refuses, leaving the file untouched with the existing "Migration skipped" notice, when the result would read differently from the original.

Version 4.8.3 (patch).

What changed since the review

The reviewers read e8626c9. The branch was 49 commits past it at a9484e6, where the measurements and CI figures below were taken; the commits after that are listed at the end of this section. Every review finding about the code is fixed here.

The user's rulings after the review

  • Fixing a stray heading that reveals pins is allowed. Demoting, deleting or fencing a stray # or ## line inside the Pinned section is allowed, and the pins it reveals count as already there, including an oversize one. Merging a user's own section into Pinned by deleting or demoting its heading is allowed too, and the lead is told at the next prompt. The same change plus a new pin is still refused, and so is growing a revealed oversize pin. A blank line carries no identity, so deleting the stray heading and replacing the pin it hid by a new one is refused even with the blank lines kept.
  • An unreadable CLAUDE.md is allowed with a note, for a Write as for an Edit. The note reads "PACT could not read , so the pin cap was not checked for this Write."
  • Each new or grown pin's size is checked on its own. Before, only the largest pin was checked, so a smaller oversize pin could grow, or a new one appear, while a larger one existed. Renames, moves, reorders and paragraph moves between pins (verbatim, rewrapped or with typos) stay the same pin. A pure split of an oversize pin is allowed, and a split that adds oversize text is refused. Replacing an oversize pin with different text of no greater size is allowed, because it cannot be told apart from a rename plus rewording.
  • An HTML block that hides nothing makes the file uncertain only where it matters. A block that is never closed, is closed mid-line, or is a declaration makes the rest of the file uncertain only when it covers a line that would otherwise open a code fence or another HTML block. A never-closed comment counts too: the ruling's wording does not exclude comments, and counting them removed refusals of ordinary edits. Where the file is uncertain, the gate allows with its note and PACT's writers leave the rest alone.

Size overrides

  • An old invalid size override no longer blocks edits to its pin. This was the review's main blocking finding. The gate now re-checks an override only when the change adds one or edits its rationale, comparing rationales as text with whitespace collapsed. A typo, shrink, CRLF rewrite, trailing space, rename, move or date-only edit beside an untouched invalid override is allowed. Adding, editing, re-casing or duplicating an invalid override is still refused. The refusal no longer tells the agent to "compress the pin body".
  • The gate reads an override only from the comment row the parser assigns to a pin, through the same reader the cap uses. Its private override pattern, and a line-terminator branch that could never run, are gone.
  • An overridden pin can be reconfirmed as /PACT:prune-memory instructs, with the reconfirmation before or after the override. Adding, re-dating or removing a reconfirmation, or rewriting it into the prune-memory template, leaves an untouched override untouched. The 120-character limit applies to the rationale alone. commands/prune-memory.md now tells the curator to keep the override clause in the same comment.
  • Which pin keeps an old override. Each pin is mapped to the pin it continues. A row standing for its own mapped pin's old row takes it first and competes for no other, if its pin keeps some of that old pin's text (a mapping by heading alone does not count), and never loses it to an exact copy on a pin keeping strictly less of that old pin. The rows left compete: the pin keeping the most of the old text continues it, a merged pin included; the mapping breaks an equal share; identical texts tie. A row leading two old rows is placed for the one whose pin it keeps the most of, then for the one whose content sorts first, so no order of pins decides a verdict. Old pins that tie on all of that are ordered by their raw text, so which twin a row holds does not depend on their order. Between two exact copies on different new pins, the first in the edited file holds the old row; the verdict is the same either way. A copy that loses is checked on its own.
  • One definition of a reconfirmation (reconfirmed: YYYY-MM-DD) is shared by the age reader and the override reader, so a reconfirmation that resets a pin's age is never read as part of its rationale.
  • Any run of separators before a reconfirmation is dropped from the rationale. When that would leave nothing, because the rationale is made only of symbols, just the usual separators are dropped, so the rationale survives.

How the gates read a change

  • Moving a pin with two Edits, un-indenting a ### line and moving PACT's Pinned end marker down past pins are allowed. Pins the last two reveal count as already there unless this agent's own changes added the line this session; a pin one Edit removes can come back in a later Edit of the same agent if nothing else changed the file. The same changes plus a new pin, or growing a revealed oversize pin, stay refused.
  • One simulation of the Edit and Write tools serves both CLAUDE.md gates. An Edit with an empty old string that fills a blank or missing CLAUDE.md is now counted. A curly-quote old string is matched the way the tool matches it, so a 13th pin added that way is refused instead of slipping through.
  • A CLAUDE.md with no Pinned section at all is allowed with no note. The note stays for a section PACT cannot locate.
  • The pin-comment strike is linear (it was quadratic on a crafted row), and the timers cover the whole decision, parsing included.
  • The staleness gate counts pins from the same whole-file parse as the cap gate, and no longer counts an add where the Pinned section is uncertain. On uncertain files it had refused 112 of 640 ordinary edits; now it refuses 0. It also no longer refuses a change to a blank CLAUDE.md, and a revealed or credited pin is not an add.

The drift report

  • When pins cannot be counted, the baseline stores "unknown", not 0, and the report says PACT could not count them, without asking whether the growth was intended. A first check without the memory markers warns once. On a legacy file's first session the first check waits for the next prompt, after the migration has had its chance to add the markers.
  • Growth the gate allowed past the cap (a merged section, a Write over an unreadable file) is reported once at the next prompt instead of being absorbed into the baseline. The report no longer lists routes, which would now be wrong for growth the gate allowed.
  • A move the gate credited moves the baseline instead of being reported as growth, and headings this agent's own changes added earn no reveal.
  • A plain session no longer prints a line on every Bash call when it has no PACT context file.
  • The test of the report's cost now fails when the report job is dead.

Readers and writers

  • The migration's and the Current Session writer's read-back refusals name the line.
  • The staleness readers and the pin archive read rows of the whole-file parse, not a fresh parse of the section's text. The archive says why it found no pins: no section, an unreadable or duplicated one, or one with no pins.

The legacy migration

  • The old loader line is dropped only on visible prose lines, decided on the original file's parse. Copies inside a fenced code block, or inside an HTML block that hides them (a comment, <pre>, <script>, <?php ?>, CDATA), are kept byte for byte. Copies under a declaration, <details> or <div> are still dropped, because the parser does not hide those lines. The private fence walker, which could delete a user's line inside a code block, is gone.
  • The kernel strip gives the same result on a file that starts with a byte-order mark as on the same file without one, with the mark kept first.

Invisible characters in Python source

  • No Python file under the plugin holds a raw invisible character any more: the 12 raw byte-order marks this branch had added are escapes, and so are 16 other invisible characters in 8 files (line and paragraph separators, next-line controls, zero-width spaces), 14 of them already on main. The test values are unchanged, and the one shipped site, the sanitising pattern in hooks/shared/plugin_manifest.py, spells its three as regex escapes and matches the same characters.
  • A new test refuses, naming file, line and code point, any raw format character, line or paragraph separator, control other than tab and line ends, non-plain space, or byte that is not UTF-8; it names the nine format characters missing from Python 3.9's Unicode data, so every CI interpreter refuses the same set, and fails if its scan misses the parser's file or finds no Python file in any of hooks, scripts, skills, telegram and tests.

CI

  • The Python 3.9 failure is fixed. A test file used a Path.write_text keyword that exists only from 3.10, at three sites; 120 tests failed on 3.9 before the fix and pass after.
  • A CI-only test times the gate's worst case on every runner, asserts that a part's own limit, never a timer, stops it, and prints every part's steps against its limit.
  • A CI test replays one synthetic file through the real gate on every run and checks the replay's labels (ordinary, size, growth) against the gate's decisions.

Clean-ups

  • Removed dead and duplicated code: an edit helper in pin_caps.py that nothing in production called, an override pattern that no shipped code read, duplicated Pinned-section constants and row-start helpers (one copy each now), and test-only helpers that lived in shipped modules (moved to tests/fixtures/).

Messages

  • The size refusal names each violating pin: new with its size, grown with its old and new size, or no longer carrying a valid override, and for a group the total or largest that grew. A pin is named by its 1-based position and at most 40 characters of its heading, control and format characters dropped, JSON-quoted; at most five are listed. No hook message carries unbounded CLAUDE.md text.
  • The pin-slot line reads "(over the cap)" past 12 pins and "(FULL)" at exactly 12.
  • A stop note names the part that ran out, for example "the pin-size rule passed its step budget of 1400000"; a text pair over 1,000,000 characters gets "the file is too large for the pin cap check". The timers' notes read "the pin cap check used more than 30 s of processor time" and "the pin cap check ran for more than 540 s".
  • The count refusal names how many pins to demote ("demote 3 pins"), counting this agent's earlier removals, so demoting that many and retrying passes.

After a9484e6, up to 58e4381, the commits behind the sections above: reconfirming an old over-long override, matching overrides as a whole, naming the refused pin, the drift report's wording, the archive's reasons, bounded and identity-first override matching, the pin-slot wording, the invisible-character checks, the CI timing test and two docstrings.

After 58e4381, one line per commit; the rules they build are under "Which pin keeps an old override" and Limits.

  • d78cb13 to 7f40a58: the continuation rule in steps (own pin first, best-match mapping, share of the old text, the continuation contest); the classes each moved are under Limits.
  • c1ae93c: the contest runs with or without an exact copy; every new refusal is a copy ranked below the continuation.
  • 2b7fcff: a merged pin continues both of its old pins; the rows it moved are under Measurements.
  • 90f8fc4: every part of the decision stops on its own counted limit, with a note naming the part, and on a 1,000,000-character cap; no ordinary decision changed.
  • 00398c0: the override check's parts are named, the work they did unbudgeted is counted, and the contest searches one group of rows; no verdict changed.
  • cc7a452: a row takes its own pin's old row before contesting any other, and a row leading two old rows is placed by share, then by content, so no order of pins decides a verdict; the merged-pin carry needs half the old text.
  • ee55ee1: ties between old pins equal in content break on their raw text; no verdict moved.
  • f21ea25: the check is timed in its own processor time, with a 540 s wall backstop and a 600 s hook timeout.
  • ff9e714: one shared trim of the run before a reconfirmation, in one match; output unchanged.
  • 8942d22: two tests check that the simulated edit exists before reading it; tests only.
  • 31d5ce4: tests that timed wall clock now check what load cannot move; tests only.
  • 515a09d: un-indented headings and pins below a moved end marker count as already there; a reveal needs a non-blank row aligned; the count refusal names how many pins to demote.
  • 01f8c93: a per-agent credit lets a pin moved by two Edits back in; the staleness gate and the drift report read it.
  • a93d8c5: the chain's credit moves by the growth rule's count in every case, so a reveal under the cap neither spends nor keeps credit wrongly.
  • 47778d6: a rewritten pin's own heading can no longer pay for a new pin elsewhere; a heading moved with its text counts on neither side.
  • 7ddf18b: the older timing waits in the test files this branch changed follow the same rule as its own tests; tests only.
  • 9d8d391: the gate's messages and failure log name an exception by its type only, so no exception can carry CLAUDE.md text into them; the load failure keeps the exception text on stderr, and a test checks that loading reads no CLAUDE.md.
  • a28068e: a first Write or Edit that creates the project CLAUDE.md is compared only with a file under its own base, or with its repository's main checkout's file. Before, it could be compared with a file found under another base (another repository, or another directory of the same one when CLAUDE_PROJECT_DIR is unset) and pass, so those creations of an over-cap file are now refused, as any other over-cap creation is. A security pass over 134 cases in 35 directory layouts found no other verdict changed.
  • 43b99ed: the staleness gate's fail-open report on stderr names the exception's type only, as the pin-cap gate's messages do; its load-failure refusal keeps the text, and a test checks that loading reads no CLAUDE.md.
  • 6ff21d8: the two load-time read checks require no open while loading and at least one for the control read, because on Python 3.9 one Path.read_text raises two open events (io.open, then os.open through pathlib's opener); tests only. Its first version expected exactly one and failed on 3.9 in CI. A read of CLAUDE.md added at module load fails both rows at the loading count; the mutant first used for them failed earlier, in the test's own import, so it never reached the check.

What changed

The parser (new: hooks/shared/claude_md_markers.py)

  • Splits the file into lines (only \r\n, \r and \n end a line) and classes each as prose, a fence line, code, or unknown. Unknown means past a fence whose end cannot be placed, or past an HTML block that can be read two ways (one never closed, closed mid-line, or a declaration) that covers a line which would open a fence or another HTML block. It also marks rows hidden inside a closed multi-line HTML comment, <pre>, processing instruction or CDATA block.
  • Every lookup returns one of found, absent, duplicate, malformed or unknown, with a reason that names the line and a cause (for example unclosed_fence, stray, commented). Absent is only returned when every line it had to search could be read. A lone marker reads as unknown when a copy may sit past an uncertain boundary.
  • uncertainty_added(before, after) tells a writer whether its planned text adds unreadable lines, and names the line.
  • Imports cheaply (typing.NamedTuple records, no dataclasses; difflib only inside uncertainty_added, on a refusal), because shared is loaded by every PACT hook.

Readers and writers moved onto the parser

  • Current Session block: hooks/shared/session_resume.py, hooks/shared/stale_session.py, hooks/session_init.py, hooks/bootstrap_prompt_gate.py. The Resume line is read only inside a found block. In a file with a memory block and no managed region, a new session block now goes before the memory block, not inside it or at the end of the file. A new block that would land inside an HTML block the file never closes is refused, naming the line.
  • Managed region, kernel strip and migration: hooks/shared/claude_md_manager.py. This also fixes a migration that wrote a second memory block into a file that already had one but no managed region. The kernel strip stays silent when a file cannot hold a kernel block, and refuses when removing the block would hide part of the file.
  • Pin markers: hooks/shared/pin_markers.py (now imports the Pinned locator's constants from staleness.py), hooks/pin_marker_writer.py.
  • pact-memory syncs: skills/pact-memory/scripts/working_memory.py (each sync refuses a write that adds unreadable lines or whose section would not read back where it wrote it) and skills/pact-memory/scripts/memory_api.py (an uncertain result for a refused sync). Retrieved Context keeps one entry per memory, where a repeated retrieval used to leave two.
  • Staleness and the pin scripts: hooks/staleness.py, hooks/pin_caps.py, scripts/archive_pin.py, scripts/check_pin_caps.py. One Pinned-section locator serves all of them and the gate, and the readers take rows of one whole-file parse. The archive refuses to remove a pin when that would hide part of the file, and says whether a section it found no pins in is absent, unreadable, duplicated or empty. The scripts reuse an already-loaded hook module instead of loading a second copy.

The pin-cap gate

  • hooks/shared/pin_growth.py (new): the growth rule, shared by the gate and the Bash report. It aligns the memory block before and after the change (shared start and end trimmed first), counts pins, and never counts a ### line inside a code example as an added pin. Pins revealed by fixing a stray heading, un-indenting a heading or moving the Pinned end marker count as already there, and it takes the move credit. It counts its work, through a counted copy of difflib's matcher, against its own limit, defines every part's limit and stop note, stops a text pair over 1,000,000 characters first, and gives the stop-early allow every part returns; two timers, of its own processor time and of wall time, cover the whole decision.
  • hooks/pin_caps.py: the per-pin size check (size_violation), which joins each pin after the change to the pins it came from by heading, shared word pairs and moved text, through indexes and against its own limit, and a size refusal that names each violating pin by position and a short quoted excerpt; the one override reader, and the trim of the run before a reconfirmation, that the gate and the cap share; and the reconfirmation pattern, shared with scripts/check_pin_caps.py. The unused add-time check, the retired embedded-pin check, the unused edit helper and the test-only pin parser are removed; the parser moved to tests/fixtures/pin_helpers.py.
  • hooks/shared/edit_simulation.py (new): one simulation of the Edit and Write tools for both CLAUDE.md gates, including replace_all, an empty old string, and the tool's curly-quote matching.
  • hooks/shared/pin_credit.py (new): per agent, the pins its allowed changes removed and the headings they added, in the session directory, keyed by the file text each change produced, so any other change to the file starts over; a failed read or write gives no credit. hooks/shared/state_file.py gains a lock timeout (1 s here).
  • hooks/pin_caps_gate.py: decides through the shared rule and re-checks a size override only when the change adds it or edits its rationale. It maps each pin to the pin it continues, settles which row keeps each old override row (see "Which pin keeps an old override"), matches the rest by a maximum matching across a reconfirmation's cut, and passes the size check any untouched override whose validity changed between continuing pins; its mapping, contest and matching each count work against their own limits. It reads and records the move credit. It fails open everywhere, including a Write over a CLAUDE.md it cannot read.
  • hooks/pin_staleness_gate.py: counts pins from the whole-file parse through the shared simulation, and does not count an add where the Pinned section is uncertain, or a revealed or credited pin.
  • hooks/shared/claude_md_manager.py: gate_frame decides which callers are gated (lead, PACT specialist types, and any frame in a PACT team session); gate_target decides which file is gated, using the same resolver as the rest of PACT with the target counted as present, so a first Write and a case-variant path are gated. A creating change is compared only with a file under the target's own base, or with the main checkout's file for a worktree that has none (for a worktree of a bare repository, the file in the bare repository's parent directory, which PACT also reads as that worktree's project file); any other file the resolver falls back to is ignored, and the change is counted from empty.

The drift report for routes the gate cannot see

  • hooks/shared/claude_md_drift.py (new): keeps one last-seen record per agent, plus a per-project baseline written only by the lead, which stores an unknown count rather than 0 and waits, on a legacy file's first session, until the migration has run, and confirms each move-credit record that lands. It never writes, restores or locks CLAUDE.md.
  • hooks/track_files.py: after each Bash call (PostToolUse and PostToolUseFailure), report growth past a cap since the last check to the agent; after a gated Edit or Write, record the file.
  • hooks/missed_wake_scan.py: at each prompt and session start, tell the lead about growth past a cap since the baseline, and once when PACT's memory markers are missing. Runs as its own job, so a failure in it changes nothing else the hook emits.
  • hooks/shared/pact_context.py: a missing context file is silent.
  • hooks/hooks.json: registers track_files.py on PostToolUseFailure for Bash, and gives the pin-cap gate an explicit 600 s timeout (the default), above its 540 s wall timer, so its note comes before the platform's silent cancel, which lets the call through (https://code.claude.com/docs/en/hooks).

The per-launch report (hooks/session_init.py): one system message to the lead naming the file, the earliest problem (with line and cause) and the sections not updated. It adds "the pin cap is not checked" whenever the Pinned section cannot be located.

Docs and commands

  • commands/pin-memory.md, commands/prune-memory.md: describe the gate as it ships (what counts as a pin and toward its size, the per-pin size rule, what is allowed at the cap, Bash changes reported only, keeping an override clause when reconfirming, the archive's refusal reason, the teammate hand-off, removing before re-adding in a two-Edit move, demoting the number of pins the refusal names). commands/pause.md and commands/wrap-up.md no longer say /PACT:pin-memory prunes.
  • hooks/shared/HOOK_STDIN_DISCRIMINATORS.md: the in-process teammate PreToolUse frame shape.
  • hooks/shared/plugin_manifest.py: its sanitising pattern spells three invisible characters as regex escapes instead of raw characters; it matches the same characters.
  • hooks/shared/hook_infra_classifier.py: the hook import closure follows names re-exported by the shared package, so a dependency reached through a re-export is no longer missed.

Tests

  • A 132-file corpus of hand-written CLAUDE.md shapes with expected results (tests/fixtures/claude_md_corpus/), and an independent oracle (tests/claude_md_fence_oracle.py) that shares no code with the parser.
  • A locator census (tests/test_claude_md_locator_census.py) that fails if shipped code looks up a PACT marker or heading without the parser, or parses a slice of the file where it should parse the whole text.
  • The growth rule's rows, certification populations and mutants (tests/test_pin_growth*.py, tests/fixtures/pin_growth/), the per-pin size rule (tests/test_pin_size_rule.py, tests/fixtures/pin_growth/size_rows.py), and the CI-only timing test (tests/test_pin_growth_ci_timing.py).
  • Gate frames, paths, forms and overrides (tests/test_pin_caps_gate_frames.py, tests/test_pin_caps_gate_paths.py, tests/test_pin_caps_gate_override.py, tests/fixtures/role_frames.py), the edit simulation (tests/test_edit_simulation.py), the move credit (tests/test_pin_credit.py, tests/test_pin_caps_gate_credit.py), the drift report (tests/test_claude_md_drift.py), and the migration's read-back and marker coverage (tests/test_claude_md_migration_readback.py, tests/test_claude_md_migration_marker_coverage.py).
  • Readers, writers and syncs over the corpus, the HTML-block rule and the invisible-character check on Python source, each in its own tests/test_claude_md_*, tests/test_pin_readers_* or tests/test_python_source_* file, plus many existing pin-cap and session tests updated to the new behaviour.

Measurements

Every figure here was measured on the remediated code, and each names where it comes from. "Committed" means a test in this PR reproduces it. "One-off" means a run during development whose script is not in the PR.

Ordinary edits across the certification populations. All 53,524 generated cases, decided by the reviewed code and by the remediated code (at commit ddf05e8) in separate processes: 5 decisions changed, all ordinary edits that were refused and are now allowed (3 stray-heading fixes and 2 user-section merges, which the user's ruling allows). Ordinary edits refused: 0 (the reviewed code refused those 5). Re-run at 68d1d16: 0 decisions changed. The commits after a9484e6 change the growth rule only in its messages, optional inputs only the gate passes, and the reveal clauses; its 286 fixed, size and reveal rows give byte-identical verdicts across them, and the reveal commits move none of 53,260 generated items at full scale. Source: one-off; the populations are committed (tests/test_pin_growth_populations.py) and CI runs them at full scale.

The gate on main against the remediated gate. Every case decided by both. A flip is a correction when the old verdict was wrong for how the case was built. Ordinary changes now refused: 0 in every row set. These verdicts are identical to the ones recorded at the review (39,662 cases, 0 changed).

Over the 202 hand-written rule rows and four generated row sets (transition, located, and two layout sets): 19,612 same verdict, 18,069 ordinary changes now allowed, 1,977 growth cases now allowed (under the cap, or under a signed-off limit), and 3 growth cases now refused.

One of the 202 rows has no file before the change and is ungated by both gates. Source: one-off comparison; the populations, their labels and the rule-row flips are committed (tests/test_pin_growth_populations.py, tests/fixtures/pin_growth/fixed_row_flips.py).

New rows for the rulings, decided by main and by the remediated gate:

  • Stray-heading reveals (11 rows): 4 ordinary edits refused by main and now allowed, 4 allowed by both, 1 growth case allowed by both (a signed-off limit), 2 growth cases refused by both.
  • Per-pin size (73 rows): 50 allowed by both, 16 allowed by main and now refused, 7 refused by both. All 73 match their expected verdicts.
  • Uncertain HTML blocks (960 transitions): 656 ordinary edits allowed by both, 176 refused by main and now allowed, 40 growth cases now allowed with the note (the uncertain-file limit), 88 growth cases refused by both. Ordinary edits refused: 0.

Source: one-off comparison; every row is committed.

Real files. An opt-in runner replays edits on CLAUDE.md files (read-only; nothing derived from them is committed).

  • Against the reviewed code, at ddf05e8: 12 replayable files (10 of the maintainer's own, 2 corpus files), 7,732 cases. 25 decisions changed, all ordinary edits now refused for size, which the per-pin size ruling asks for: 19 grow a pin already over 1,500 characters, and 6 grow a pin across 1,500.
  • With labels for expected size refusals, on the maintainer's real files only (6 replayable, 3,868 cases), identical at 60d17d5 and 68d1d16: 894 ordinary edits allowed, 0 refused; 67 size cases refused for size, 0 allowed; 24 growth cases refused. Fence-closing variants: 2,682 ordinary edits allowed; of the size cases 180 allowed and 21 refused for size (an allow is acceptable there); 0 refused for any other cause.

Source: one-off runs; the runner is committed behind PACT_PIN_GROWTH_REAL_FILES.

CI synthetic replay. One synthetic file, with pins of 1,483 and 1,784 characters plus a small one, padded to 13 pins, replayed through the real gate: 644 cases in 1.85 s locally. 0 ordinary edits refused, 8 of 8 size cases refused for size, 4 of 4 growth cases refused, and of 24 fence-closing size cases, 14 allowed and 10 refused for size. Committed: it runs in every CI cell.

What the uncertain-HTML rule costs. Over 10,000 generated documents, it changed 933 outcomes: 180 honest legacy migrations and 118 already-migrated files are newly refused (PACT's writers leave them alone and the per-launch report names the line). Counting never-closed comments refused 19 more migrations. 0 of 54 real files changed. Source: one-off census.

Writers that refuse. Over a sweep of 17,986 files: 72 of 1,369 kernel strips would have added unreadable lines and are now refused. Neither memory sync adds an unreadable line (13,167 and 13,193 writes); with their checks switched off, 369 and 213 would have. Source: one-off sweep; the all-writers check is committed and runs in CI over the corpus and a sample of the sweep.

Migration outputs. 264 migration plans (the 132 corpus files, each as-is and behind a legacy header) are byte-identical across the last three heads measured (00581ca, ddf05e8, 60d17d5), with 116 refusals on each. A census of 18,118 migration inputs shows 0 changes across the override and legacy-line commits. Source: one-off.

Byte-order mark kernel strip. 4,794 documents (the corpus, the 54 real files and a kernel-strip sweep), each as-is and with a leading byte-order mark: 0 plans without a mark changed, and exactly the 1,562 plans with a mark that stripped differently from their twin without one changed, so 0 differ now. The 54 real files hold no kernel block, so they exercise nothing here. Source: one-off; committed rows cover each of the three places the mark is restored.

Work limits. Each part's limit is sized by what one of its steps costs on the slowest CI runner: 12,000,000 steps for the pin-growth rule, 1,400,000 for the pin-size rule, 1,500,000 each for the pin mapping and the override contest, 1,000,000 for the override matching, about 7 s together. Ordinary work stays far below them: over the full suite, the populations at CI scale, edits replayed on 83 real CLAUDE.md files and the security rigs, the largest spends are 7,481 steps (pin-growth rule), 32,172 (pin-size rule) and 19,800 (override check), and the largest text pair is 57,010 characters. Crafted shapes stop on the part that ran out: padded originals beside as many exact copies decide up to 1,200 originals and stop on the override contest from 1,250 (625 before the own-row-first fix cut its work from about 4n² to about n²); two pin-mapping shapes stop on the pin mapping from 625 and 220 pins. Under a 100-process CPU hog, a 300-pin file the cap refuses was allowed 3 times of 3 by the old 30 s wall timer and is now refused 3 of 3; the heaviest input the limits allow stops on its own limit, as when idle. The wall timer can decide only once the check runs about 480 times slower than its processor time on that file, and about 140 times for any input the limits allow. Source: one-off runs; the limits and the CI timing test are committed.

The commits after a9484e6.

  • Reconfirm fix (ba829cf), whole-override matching (a4ca76c), bounded matching (0704dcb) and identity first (73777a8): of 8,679 single-override edits, 1,088 moved from deny to allow and 0 the other way; of 27,317 edits, 340 moved at a4ca76c (226 to allow, and 114 to deny, each growing an over-cap pin through an override written over 120, which 60d17d5 refuses too) and 0 at the next two; of 640 whitespace variants, 64 moved to a ruled refusal of a padded copy on a new pin.
  • Own pin first to the continuation contest, d78cb13 to c1ae93c: at each commit the censuses (27,317, 640, 675 and 5,448 edits) moved 0 verdicts outside the commit's target rows, as did the full instrument set from abc8b6b on; at d78cb13 a new 5,448-edit symbol-only census moved 990 to refusals, all in the empty or over-long symbol-only classes under Limits; the graded copy-and-mapping rig passes 51 of 51 (main 29) and a 52-row contest grader 52 of 52.
  • After c1ae93c. The merged-pin commit moved 108 of 344 merge rows: 40 to refusals, each ranked below the merged continuation or exempt only through a reconfirmed invalid override, and 28 to allows. The work-limit and naming commits changed no verdict over 35,342 captured inputs and 40,000 fuzz cases. The own-row-first commit passes the security graders (538, 24 and 52 of as many), with 0 verdicts differing over 130 shapes in every pin order; on 4 fuzz seeds it moved 38 rows to ranked-below refusals and 57 to allows, each holding an old row it stands for (two of them an untouched old invalid override, ruled to stay). The tie-break commit moved no verdict over the same 35,342 inputs and 40,000 fuzz cases, and the twin a row holds no longer follows the old pins' order (it did in 34 of 82 twin shapes). The timer commit changed no rig output but the timer note's wording. The trim commit decides identically on those inputs and cases and on 2,428,224 raw strings. The move-credit commit moved no verdict over the same inputs and fuzz cases, with or without a real credit store; each of the reveal commit's three narrowings (a blank row carries no identity, the end-marker reveal stops at the last row still in the section, a line this agent added earns no pairing) has a row and a mutant that flips it. The credit-measure commit moves no decision field over the same inputs; the un-indent pairing cap moves no generated item.

Source: one-off censuses and timings; each verdict class above has committed test rows.

Security re-verification. An independent rig of 123 cases through the real gate hook gives the same verdicts at every commit verified from 00581ca through b8ef66c, against the previous one (new size-refusal wording aside). At 73777a8, 13 hostile headings (quotes, bidi overrides, line separators, a 4,400-character instruction, a lone surrogate) became excerpts of at most 40 characters inside valid JSON. Every refusal of an ordinary edit these passes found, from d78cb13 on, was fixed in a later commit, and exhausting any step budget refuses nothing. Source: one-off rigs.

Security passes. On c1ae93c's tree no row refuses an ordinary edit: the contest grader passes 52 of 52, the full instrument set moves 0 verdicts, every newly refused row is a copy ranked below the continuation, and at the bounds the gate allows. On 8942d22 the grader and every instrument move 0 verdicts, no verdict, reason or held pin follows the old pins' order, no edit mints an override (each newly allowed one is a padded or cut form of one valid before), and the 300-pin refusal holds under a CPU hog. On the move and reveal commits, through the real hooks in a scratch session: no ordinary edit is refused where the previous head, main or 2b7fcff allows, outside the ruled classes; the three formerly refused edits are allowed; after demoting the number the refusal names, the add passes in 5 of 5 cases where the old advice failed; a 295-step walk with byte-identical restores never passed the chain's start; and each of 23 injected record faults gave today's verdict. A re-run at 47778d6 moved only the designed rows and refuses nothing 31d5ce4 or main allows. At a28068e, the real-hook sequences and the reveal and stray-heading rows give the same verdicts as at 47778d6; across 134 first-change and edit cases in 35 directory layouts (worktrees, subdirectories, symlinks, nested repositories, submodules, a bare repository, no git), the only verdicts that move are creations of an over-cap file under a base with no file of its own, and nothing newly passes; an exception carrying a marker line leaks into the advisory, the failure log and the load message at 47778d6 and into none of them at a28068e. The same injection at the staleness gate's three fail-open sites leaks into stderr 3 of 3 times at a28068e and 0 of 3 at 43b99ed. Source: one-off rigs.

Limits

Every way a pin can pass the cap without a refusal, and every refusal kept on purpose, is listed here with why it is accepted. The certification classifies each case allowed past the cap under one of these limits and fails on any it cannot classify; CI's full sweep has none at 6ff21d8.

Allowed with a note (the gate allows and says the cap was not checked)

  • The Pinned section cannot be located, for example after an unclosed fence in a pin body, a fence opened on a list or quote line, or a bare second copy of the memory-end marker below PACT's block. Allowed at any pin count; the per-launch report names the line. A file with no Pinned section at all is allowed with no note. Accepted: signed off by the user.
  • An HTML block that can be read two ways covers a line that would open a fence or another HTML block. Pins added are allowed with the note until the file is fixed; the per-launch report names the line. Accepted: user ruling.
  • The memory block is found above an uncertain region that holds a pasted copy of a memory marker. Allowed with the note; the per-launch report names the line. Accepted: same case as the user ruling on sections that cannot be located; it needs a marker copy past an unclosed fence or block.
  • An unfenced <?php line in a pin turns the cap off for that file, with the note. Accepted: the user's ruling treats a never-closed HTML block (<pre>, <script>, <?, a declaration or CDATA) as a point past which the file is uncertain, and on uncertainty the gates allow with a note rather than refuse an ordinary edit.
  • The only ## Pinned Context heading sits inside a multi-line HTML comment, <pre>, processing instruction or CDATA block. Writers refuse, and the per-launch report tells the user. Accepted: signed off by the user.
  • An old Pinned heading hidden by an HTML declaration, or by a comment closed mid-line, is not treated as hidden, so the file reads two Pinned headings. Accepted: signed off by the user.
  • Both PACT memory markers removed. The cap is off for that file until they return; the lead is told once. Accepted: user ruling.
  • A change that runs past a part's work limit, either timer or the 1,000,000-character cap, anywhere in the decision. Accepted: user ruling, and adversarial-only: ordinary work stays far below every limit (see Measurements), so reaching one takes a crafted change. Load reaches the wall timer first only past the slowdowns under Measurements. In a lead session that growth also enters the baseline unreported.
  • The override check's worst case is bounded by its pin mapping's, contest's and matching's own limits; when one runs out, the change is allowed with a note naming that part, even if it adds an invalid override. Accepted: user ruling; only a crafted file far over the 12-pin cap gets near a limit: the costliest contest shape decides up to 1,200 padded originals and stops from 1,250, the mapping shapes stop from 625 and 220 pins, and the largest ordinary spend in the override check is 19,800 steps.
  • Hundreds of override-carrying pins under one heading make the pin mapping's work grow with the square of their number; the mapping's own limit stops it, and the change is allowed with a note naming the pin mapping. Accepted: user ruling, adversarial-only, bounded by that limit, which allows; a real file holds at most 12 pins.
  • An Edit or a Write over a CLAUDE.md that cannot be read, including one that adds pins. Accepted: user ruling, for the Write and for the Edit; the Edit cannot be simulated, and main allows it too.

Allowed with no note (the gate reads the change the same as an ordinary one it must allow)

  • Ten edit shapes that add pins at the cap in the same edit that changes a ### line inside a code example, or a stray fence line. Accepted: signed off by the user. One line each:
    • deleting a fenced block that holds ### lines, plus as many pins added;
    • deleting a ### line from a fenced block that stays, plus a pin added;
    • turning a fenced ### X line into a real pin, plus pins added;
    • moving a fenced block that holds ### lines out of the Pinned section, plus as many pins added;
    • closing an unclosed snippet after its ### lines, plus up to as many pins;
    • renaming PACT's ## Working Memory heading while deleting its entries and adding pins;
    • moving and editing a fenced block that holds ### lines, plus as many pins added;
    • moving a stray fence line so a pin it still hides sits in a reshaped block, plus a pin added;
    • adding a pin inside a region a stray fence pair hides;
    • moving a fenced ### X line that has the same text as a pin, or as a ### line in another fenced block, plus pins added.
  • A # or ## line inside the Pinned section ends it, so pins added below it are not counted. Accepted: signed off by the user; unchanged from main.
  • Merging a user's section into Pinned by deleting or demoting its heading: its ### lines count as already there. The lead is told at the next prompt. Accepted: user ruling.
  • A heading un-indented, or the end marker moved, revealing a pin another route put there (an editor, another agent, an earlier session, Bash), or an indented ### line replaced in place by a new heading, including a new pin in the same gap as a rewritten pin whose indented heading went (273 of 7,500 random edits on 12-pin files built to repeat lines; main refuses all). The lead is told at the next prompt. Accepted: user ruling; neither can be told apart from an un-indent and retitle, and refusing them would refuse that fix.
  • Moving the end marker and un-indenting, in the same Edit, an indented ### line this agent put below it reveals that line. The lead is told at the next prompt. Accepted: user ruling.
  • Reveals without the exclusion of this agent's own lines, for a teammate whose PostToolUse frame lacks its agent_id, and for a line the record has dropped (after 32 later states, or past 64 such lines in one change). The lead is told at the next prompt. Accepted: user ruling; the bounds keep the record small.
  • Two parallel Edits of one agent spending one move credit can admit one pin past the chain's start. The lead is told at the next prompt. Accepted: user ruling; it is the per-call race any per-call gate has, which also lets two parallel adds at 11 pins reach 13.
  • A pin removed and a different one added, by two Edits of the same agent with nothing else changing the file in between, in an over-cap file. Accepted: user ruling; one Edit doing both is allowed, and the count never ends above where it started.
  • A different oversize pin added after an oversize pin no smaller was removed earlier in that chain. Accepted: as the user's ruling on replacement in one Edit.
  • A forged move-credit record, written into PACT's session folder to name the current file's text, grants credit and silences the next-prompt report. Accepted: user ruling, adversarial-only; it needs a deliberate write into PACT's own state through a route PACT only reports.
  • A compound restructure in one Edit (demote a user's ## heading, insert a new ## heading before its last ### subsection, and add one pin) frees a slot and allows a pin past the cap. The lead is told at the next prompt. Accepted: user ruling, adversarial-only.
  • Renaming a ### line inside an example that holds a fence-shaped line which cannot close it, while a fence elsewhere in the file is unclosed, plus a pin added. Accepted: signed off by the user.
  • Closing an unclosed fence in the Pinned section when the user's notes below PACT's block hold a second copy of the memory-end marker inside a fenced code block, plus pins added. Accepted: signed off by the user. A bare second copy instead makes the Pinned section impossible to locate, and the change is allowed with that note.
  • In the same edit that closes an unclosed fence, each pin's size is measured against the stretch of old text it came from, and a brand-new pin is not size-checked. Accepted: signed off by the user.
  • Fencing a pin whose body already holds a code example fenced with the same characters, plus a pin added. Accepted: signed off by the user.
  • An end marker indented 1-3 spaces ends the counted span. It needs a hand edit. Accepted: signed off by the user.
  • A pin hidden by a stray fence frees its slot when it leaves. Accepted: signed off by the user.
  • A pure split of an oversize pin into parts each still over 1,500 characters, with no text added. Accepted: user ruling; no oversize text appears.
  • Replacing an oversize pin with different text of no greater size. Accepted: user ruling; it cannot be told apart from a rename plus rewording, and the oversize text does not grow.
  • Moving an oversize pin's text into a new pin with text added, while the group's total oversize text does not grow. Accepted: user ruling (as for replacement); the total oversize text cannot grow.
  • In a change that moves text between two oversize pins, one may grow by more than it received while the other shrinks to match. Accepted: user ruling; the pair's total oversize text and its largest pin cannot grow, and refusing it would refuse an ordinary paragraph move.
  • Text inside a pin's date comment (<!-- pinned: ... -->) does not count toward the pin's 1,500 characters, so the comment can hold any amount of text. Accepted: user ruling; main does the same, and it takes a deliberate edit.
  • A fenced size override copied onto a new pin skips the invalid-override message. Accepted: grants no size; the cap is enforced as if the pin had no override.
  • A rationale over 120 characters only by trailing punctuation reads at the cap when a reconfirmation follows it (121 characters ending in punctuation read as 120), so its override counts as valid and exempts the pin from the size cap. A pin can then grow past 1,500 characters in three ways: reconfirm in one edit and grow in a later one; grow a pin whose untouched override already reads at the cap; or edit the trailing punctuation in the same change as the reconfirmation. The second form also covers two pins sharing one such override, where the untouched one grows. Accepted: user ruling; the excess is trailing punctuation only, and closing it would bring back refusals of ordinary reconfirm edits.
  • A pin the mapping cannot place (its heading changed and it keeps less than half of the old pin's text) has its override row matched across pins, as before pins were mapped; an exemption carries only between pins that continue each other, by the mapping or the contest. Accepted: user ruling; it keeps a heavily rewritten pin matched to its old override.
  • The original pin renamed, rewritten and reconfirmed in one change, plus a new pin carrying a whitespace-padded copy of its override: the copy can take the unplaced original's old row, and a new oversize pin headed like the original is admitted without a valid override (measured at d78cb13 and again at c1ae93c). Accepted: user ruling, adversarial-only; it needs a copy padded past 120 characters with that three-part edit, and a good-faith copy is exact, which is valid and allowed.
  • Genuine ties continue neither pin, and the identity pass decides which row keeps the old one: texts that read the same once whitespace is collapsed (including a duplicate inserted above its original with renumbered positional headings), two halves under the old heading at equal shares, and two halves under two new headings, neither of them the old one. A padded copy on one is not checked as a new invalid override when the original's own override is edited in the same change; with it untouched, the copy is refused. Accepted: user ruling; nothing in the text says which pin continues, and a different tie rule would refuse an ordinary edit of two exact duplicates.
  • The continuation's edited override row keeps the old row, so a whitespace pad or a moved or removed reconfirmation on it is not re-checked, even beside an exact copy. Which pin continues follows "Which pin keeps an old override": its own mapped row first, then the larger share among the rows standing for the old row, a merged pin included; the mapping on an equal share; identical texts tie. A lone row is never contested, so a lone padded copy on a new over-cap pin stays under the size rule; a copy elsewhere is checked on its own. Accepted: user ruling; it is the edit allowed on a single pin, and a split that keeps the override on the original is an ordinary edit.
  • An over-cap pin that keeps an old row through the contest, or a merged pin that takes one, keeps its size: at the same size it is allowed, grown it is refused on size. A merged pin carries the old row's validity only if it keeps at least half of that old pin's word pairs, so reconfirming an invalid override on it grants no exemption, and a pin keeping none of the old text gains no carry. Accepted: user ruling; the size carry covers a pair the contest decides as it covers a mapped pair, with the share the mapping asks of a continuation.
  • A padded or cut form of an override that was valid before, holding an old row it stands for, is allowed, even where main refuses it. Accepted: no edit mints an override, since the row stood valid before and keeps the old row it stands for; main refuses these only because it has no continuation rule.
  • A new pin keeping all of an old pin's text, with a whitespace-padded copy of that pin's valid override, is allowed, beside the original or after it is rewritten. Accepted: it grants nothing an exact copy does not.
  • A whitespace edit that pads one of two identical valid overrides past 120 characters is not re-checked; if that pin also grows, it is refused. Accepted: it is the single-copy whitespace edit, and grants no exemption.
  • A whitespace-padded copy of a valid override, as the only override row, on a pin keeping less of the old text while the pin keeping most of it carries none, is allowed. Accepted: it is the single-copy whitespace rule, and grants nothing an exact valid copy does not.
  • The reconfirmation's own text does not count toward the 120-character rationale limit, and , reconfirmed: <date> typed in the middle of a rationale cuts it there, so a longer rationale passes the length check. Accepted: user ruling, adversarial-only.
  • The staleness gate does not count an add while the Pinned section is uncertain on either side, including a real pin added below a never-closed opener above Pinned. Accepted: the pin-cap gate still answers the same edit (with its note, or by counting it). This gate does not arm in real sessions at all, on main either (pin_staleness_gate never arms: its marker is written before the session dir is known #1651).
  • In an unmigrated file whose never-closed <!-- covers a fence, everything below is uncertain: writers refuse, and the staleness gate allows adds. Accepted: the pin-cap gate already gives its note on unmigrated files, and 0 of 112 real files had this shape when measured.
  • Blanking the file outside any tool and then writing the stale pins back passes the staleness gate. Accepted: the pins return to where they were, the pin-cap gate still counts them, and the next session's staleness check marks them again.
  • A never-closed comment cannot make the Pinned section uncertain. Accepted: a comment ends at the first -->, and every PACT marker comment contains one, so the next marker closes a stray <!-- above it.

Reported, never refused (signed off by the user)

  • Pins added through a Bash call, successful or failed: the agent is told right after the call, and the lead at the next prompt.
  • A background Bash call, git outside a Bash call, another session, the user's editor, the Monitor tool, typing through computer use, and a saved Artifact download: the lead is told at the next prompt.
  • Sessions where PACT is inert (no PACT role, or a non-PACT --agent) are not gated; the lead is told at the next prompt.
  • A report can be lost, narrowly: a hook run cancelled after it stores its record but before its output is delivered loses that one per-Bash report. The lead's next prompt check still reports the growth.
  • A credited move whose record is not confirmed, because its lock was held through PostToolUse, is reported at the next prompt as growth: a false report, never a refusal.

Refusals kept on purpose

  • Where PACT cannot tell its own block from an example, its writers stop updating that part of the file, and the lead is told at every launch. Accepted: signed off by the user.
  • A writer's change that would add unreadable lines is refused, naming the line: the memory syncs, the stale-pin markings, the pin archive, the pin-marker writer and the kernel strip. Accepted: a refusal leaves the file as the user left it.
  • The Current Session writer refuses to append a new block inside an HTML block the file never closes, naming the line that opens it. Accepted: a block written there would sit inside text every reader treats as HTML, so the writer skips the write; it refuses a PACT write, never a user edit.
  • A first Write is counted by its own pins, so writing back a deleted CLAUDE.md that was already over the cap is refused. Accepted: signed off by the user.
  • Per-pin size. A code example inside a pin counts toward its 1,500 characters. Growing a pin that ends over 1,500, or adding a new pin over 1,500, without a valid size override is refused; a size override admits a deliberate long pin. On real files, 25 edits newly refused are of this kind (see Measurements). Accepted: user ruling.
  • A split that adds oversize text is refused. Accepted: user ruling.
  • A copy on a pin ranked below the row that keeps an old row it stands for is refused as a new invalid override when it is padded with whitespace past 120 characters, or is an exact or reconfirmation-cut copy that reads invalid on its own: for example a same-heading copy beside a rewritten original, a copy on a short fragment of the old text, a fragment copy beside an original that grew, or a padded copy left on an old pin beside a merged pin's valid row. Accepted: user ruling; the copy is a new override over the limit, and an exact copy of a valid override is still allowed. main and 60d17d5 refuse most of these; where either allowed one, refusing it is the user's ruling.
  • Moving text between two oversize pins with a bullet added to each moved line is refused as growth, and so is adding a blank line inside an oversize pin. Accepted: user ruling, within the per-pin size ruling; allowing it would need a size measure that ignores formatting, which would let any oversize pin grow by formatting.
  • A reconfirmation date not in YYYY-MM-DD shape (for example 2026-1-6) is not a reconfirmation to any reader; it is read as rationale and refused once the rationale passes 120 characters. Accepted: user ruling; /PACT:prune-memory gives the YYYY-MM-DD shape, so an agent following it does not hit this.
  • A rationale that reads as empty before a reconfirmation (a lone /, : or |, or separator text such as --) is refused, and so is a rationale made only of symbols that is changed while being reconfirmed (one emoji grown to 200). An unchanged symbol-only rationale with a reconfirmation is still allowed. Accepted: user ruling; when either rationale reads empty once trailing punctuation is set aside, the two are compared exactly, and a lone ; was already read this way.
  • An underscore used as a separator before a reconfirmation stays in the rationale. Accepted: user ruling; _ is a word character, so the reader cannot tell it from the rationale's own text, and no template or /PACT:prune-memory flow writes one there.
  • A two-Edit move that adds first, spans sessions, or straddles another change to the file is refused at the add, as on main. Accepted: user ruling; an add made before the removal cannot be known to be part of a move, and allowing it would let a pin in with no removal ever following; across sessions or another change, PACT cannot tie the removal to this agent's chain; /PACT:pin-memory tells agents to remove first.
  • A pin moved by two Edits in a file past about 50 pins gets no move credit and is refused at the add, as on main. Accepted: user ruling; the record step's work is bounded, and a real file holds at most 12 pins.
  • Deleting a stray heading while changing both the title and the only body line of an undated pin it hid is refused, as on main. Accepted: user ruling; it cannot be told apart from replacing the hidden pin by a new one, which the ruling refuses.
  • One Edit that un-indents and retitles a heading while a neighbouring pin moves is refused in some alignments, which same-date pin markers make ordinary: 8 and 7 of 1,500 on 12-pin files built to repeat lines (main 1,500; a plain un-indent 0). Accepted: user ruling; main refuses all of them, and each passes as two Edits, un-indent then retitle.

In messages

  • The size refusal numbers pins from 1, while /PACT:prune-memory's listing numbers them from 0, so the refusal's pin 2 is the listing's Pin 1. Accepted: the quoted heading excerpt tells them apart.

Not changed here, and known elsewhere

Testing

  • The merge gate, per commit: up to a9484e6, the full suite from pact-plugin with no path argument (python3 -m pytest -q), on a snapshot at each commit; the last, at a9484e6, read 20,381 passed. After a9484e6, CI's full suite on all three Pythons gated each pushed commit, with the touched test files run locally on each tree. Each commit after 47778d6 also ran the full local suite at its own tree before it was committed: 7ddf18b 20,722 passed, 9d8d391 20,725, a28068e 20,728, 43b99ed 20,729 and 6ff21d8 20,729, each with 21 skipped and none failed.
  • CI at a9484e6, python -m pytest -ra from pact-plugin: 20,376, 20,375 and 20,375 passed, with 26, 27 and 27 skipped, on Python 3.9.25, 3.13.15 and 3.14.8; the import-hygiene lint passes in each cell.
  • CI at 58e4381, ad7e4f8, abc8b6b, b8ef66c and c1ae93c: every cell green, and the automated PR review found nothing at c1ae93c.
  • CI at 8942d22 and at 31d5ce4: 3.9 20,614, 3.13 20,613 and 3.14 20,613 passed at each; Bugbot found no issues at 31d5ce4.
  • CI at 47778d6: 3.9 20,717, 3.13 20,716 and 3.14 20,716 passed, 0 failed; Bugbot found no issues.
  • CI at a28068e and 43b99ed: 3.13 and 3.14 passed; 3.9 failed the two load-time read checks on their control count, which 6ff21d8 fixes. Bugbot found no issues at 43b99ed.
  • CI at the final head, 6ff21d8: 3.9 20,724, 3.13 20,723 and 3.14 20,723 passed, 0 failed; Bugbot found no issues.
  • The certification: CI runs the full sweep. It fails on any ordinary edit refused and on any growth past the cap that falls outside a signed-off limit.
  • Mutants: the growth rule has 35 committed mutants and the per-pin size rule 12, and each must turn its own rows red. Each growth-rule mutant runs after an unmutated control and must return the opposite verdict as a normal decision. For every other change, mutants were run in scratch copies with each prediction written down first; where one survived at first, a row or a structural check was added that kills it.
  • The census: shipped code must look up PACT markers and headings through the parser, and must parse the whole text where a slice would read differently. The exceptions are named in the test, each with its reason.
  • The oracle and the property test: the parser matches the independent oracle on all 132 corpus files and on generated documents from two fixed seeds, plus a hypothesis variant in CI. Floors on how often the generator reaches each case keep the property test from passing vacuously.
  • The corpus runs through the parser, the readers and writers, both memory syncs and the migration. Each caller must refuse where the file's state says it should. A write must read back as intended without changing how any other line reads, and the session writer, the Retrieved Context sync and the migration must change nothing on a second run.
  • The all-writers check runs the session block, the migration, the kernel strip, the pin markers, the staleness markings, both syncs and the archive over the corpus and a sample of the sweep. No write may add unreadable lines, and each checked writer must both write and refuse somewhere.
  • Also: determinism digests under two hash seeds. pyright over the whole plugin with its own config, at 47778d6, reports 977 errors against 989 on main; the commits after it leave the count unchanged in every file they touch. In shipped code the branch has 5 fewer than main: hooks/pin_staleness_gate.py, hooks/session_init.py and hooks/shared/session_resume.py report 0 against 5, and every other shipped file reports the same count as on main (for example hooks/missed_wake_scan.py 4, hooks/staleness.py 1, hooks/pin_caps_gate.py 0). The test files report 860 against 867.

…ndent oracle and a locator census

PACT finds its marker comments and section headings in a CLAUDE.md with
over a hundred separate substring and regex searches. None of them knows
about fenced code, so a fenced example of a PACT block is read, and
rewritten, as if it were the real one.

hooks/shared/claude_md_markers.py is one parser for all of them. It is
stdlib-only, so every hook can import it. It splits a file into rows and
classifies fenced code, HTML blocks, and an uncertainty boundary below
which nothing is trusted. Lookups return an explicit state (FOUND,
ABSENT, DUPLICATE, MALFORMED or UNKNOWN) instead of the first match, so
a caller can refuse to write rather than guess.

- A marker is an exact comment, or a prefix whose line closes the
  comment, which covers the legacy kernel start and the section-ending
  prefixes.
- A marker that appears anywhere but on its own line is MALFORMED, so
  writers refuse instead of adding a second block.
- find_section locates a section by heading and terminator. A heading
  that appears only inside a comment reads UNKNOWN, never ABSENT, so no
  writer adds a duplicate section.

Nothing calls the parser yet. Later commits move each reader and writer
onto it.

Tests:
- unit rows for every rule, with a mutant for each rule that the rows
  kill;
- an independent oracle written from the specification, compared with
  the parser on 118 hand-checked corpus files and 2,000 generated files
  under random scopes;
- a census that lists the 107 existing marker searches outside the
  parser in per-commit allowlists, fails on any new search that bypasses
  the parser, and empties as later commits migrate.
…stays cheap

Line and Located were frozen dataclasses. Importing dataclasses pulls in
inspect, dis and tokenize, which adds about 8-10 ms to every process
that imports the parser, and every hook that later migrates onto it
would pay that cost. None of those hooks loads dataclasses today, and
all of them already load typing.

Line and Located are now typing.NamedTuple classes with the same fields,
order and default. Nothing compares them to anything but their own
attributes, so tuple equality and unpacking change no result.

A new test imports the parser in an isolated interpreter and asserts
that dataclasses is not loaded. Another pins the field order, the
in_html default, immutability and hashing.

The test loads the parser from its file with importlib, so it adds no
sys.path change to the test suite.
The census treated any name bound from a finder call as a marker alias,
and any comparison that mentioned a marker anywhere as a search. So
`loc.state == State.FOUND`, a span comparison, and re-parsing to check a
write all failed the census, although each only reads what the parser
already located. The code every later migration has to write could not
pass without growing the allowlists.

A finder call (find_block, find_marker, find_section, find_lines, inner,
offsets, scope_known) is now opaque to the census. A name bound from one
is not a marker alias, a marker passed only as its argument marks
nothing outside the call, and a helper whose parameter only reaches a
finder call is not a locator. Searching raw text for a marker still
counts, including a search nested inside a finder call's arguments.

Seeded rows cover each false site and each real search that must still
be caught; the census over the shipped code is unchanged.
…e-aware parser

The pin-marker writer found the managed block, the memory block, the
Pinned section and its markers with first-match substring and regex
searches. A fenced example could be marked instead of the real section,
and a fenced copy of the marker lines blocked the write. The pact-memory
script kept its own copies of the same locators.

The writer now resolves the managed block, then the memory block, then
the Pinned section with one find_section call, then the marker pair with
find_block, all through the parser. When a lookup is uncertain,
duplicated or malformed, it refuses, leaves the file byte-identical, and
records the parser's reason, which names the line. The pre-write check
compares the two marker lines by position. The refusal for pin bodies
that contain a code fence is gone: the section scan now sees fences, so
markers land at the true end of the section.

working_memory.py imports the parser in place of its copies. Its write
window declines on any uncertain lookup, and an anchor heading that
exists only inside an HTML comment returns SyncResult.UNCERTAIN.

Behaviour that changes on purpose, each pinned by a test: a marker
mentioned mid-line outside backticks is refused; a marker line outside
the memory block is ignored; a marker indented four or more spaces is
refused; a managed start marker with no end makes the sync decline.
Tests of the removed copies are deleted or re-pointed at the planner.

The writer only adds marker lines, and the staleness reader takes the
earlier of the declared and inferred section ends, so no counted Pinned
span lengthens and no pin count rises. This change's census allowlist
is now empty.
…re parser

The session-start writer, the stale-session warning, the resume check
and the bootstrap prompt gate found the Current Session block with
first-match substring searches. A fenced example of the block could be
rewritten instead of the real one, its Resume line read as the previous
session, or its markers taken as a stale block.

Readers now locate the block with the parser and take fields only from
inside a found block. The writer splices a found block in place, then
re-parses the result and refuses unless the block reads back found at
the expected position. When the block is duplicated, malformed or
uncertain, the writer refuses, leaves the file byte-identical, and
reports the parser's reason, which names the line. One builder produces
the block's text for both the update and the create path.

A file with a memory block but no managed region now gets a new block
just before the memory block. Before, it landed inside the memory block
when a Retrieved Context heading was there, and at the end of the file
when it was not. The Retrieved Context anchor is kept only for files
with no memory block at all.

shared/__init__.py imports session_resume, so the parser is imported
inside the functions that use it. Importing the shared package, or any
hook that does, still does not load the parser, and the fail-closed
module load of the dispatch gate is unchanged. A test pins this.

The stale-session detector imports the parser inside its own error
guard, so a parser that fails to load means no warning rather than a
blocked hook. This change's census allowlist is now empty.
The seam classifier's closure for each hook names the helper modules
whose code the hook can call, so that an edit to a helper marks the
hooks that depend on it. The derivation did not follow a function
imported from the shared package itself: `from shared import
check_pr_state` calls into gh_helpers, but gh_helpers appeared in no
hook's closure. An edit to gh_helpers therefore did not mark
session_end, session_init or bootstrap_marker_writer.

The derivation now maps a name the package re-exports to the module
shared/__init__.py imports it from, for `from shared import X`, a
`shared.X` read and `from . import X`. The package initialiser's other
imports stay out of the closure: following them would put its whole
closure into every hook that imports from shared and erase the per-hook
signal. The closure literal gains gh_helpers for the three hooks, and
the classifier's docstring states what a closure does and does not name.
…are parser

The legacy kernel strip, the managed-region reader and the migration to
the managed layout found PACT's markers with first-match substring
searches. A kernel marker in inline code deleted the prose between two
mentions, and any mention of the managed start marker, even a fenced
example or a start with no end, made the migration a silent, permanent
no-op.

All three now locate through the parser. A file whose only mentions are
fenced or in inline code reads as having no block: the kernel strip
leaves it alone, and the migration runs. When a marker pair is
duplicated, malformed or uncertain, the strip and the migration refuse,
leave the file byte-identical, and report "Migration skipped" with the
line that caused it, on each lead session start until it is fixed. One
planner serves the migration and its non-UTF-8 skip path, and the
migration re-reads its result and writes nothing unless the expected
marker pairs are found exactly once.

The migration of a file with a memory block but no managed region wrote
two memory blocks, which then read as malformed, so the syncs declined
and the pin cap was off for that file. The memory block's own marker
lines are now dropped before its lines are classified, and the session
block is still taken out first, so it lands between the title and the
memory block.

The migration classifies lines from the parsed document: a heading
inside a fenced example or an HTML comment is not a section boundary,
and a heading comment that is only quoted gets the real comment added.
The parser is imported inside the functions that use it, so importing
the shared package still does not load it. This change's census
allowlist is now empty.
…are parser

The two section syncs found their sections with a regex over a text
window and assumed the managed region held no user code fences. A
fenced copy of a section heading could be taken for the real one, and a
fenced dated line split an entry in two. A section that was missing was
appended at the end of the file, outside the memory block; the next
sync searched only inside the block, missed it, and appended another
copy every time.

Both syncs now locate their section with one parser lookup inside the
resolved memory or managed block, and read entries from the section's
own rows. A missing section is written inside the block, at the start
of its closing marker line, in the same shape a found section is
rewritten in, so a repeat sync leaves the file byte-identical. Only a
file with no managed block still gets the section at the end. When the
only matching heading is inside an HTML comment, the sync refuses,
writes nothing and reports "uncertain"; before, that raised an
unpacking error and was reported as a failure.

The working-memory copy of the terminator scanner and the old window
resolver are gone; their test callers now use the row-based resolver.
memory_api lists "uncertain" among the sync results it can report. This
change's census allowlist is now empty.
A file with an unclosed code fence makes every line after the fence
uncertain, so the legacy kernel lookup reads as uncertain and the strip
refused with a "Migration skipped" notice on every lead session start.
That fired on a global CLAUDE.md with an unclosed fence and no kernel
text at all.

The parser gains Document.may_hold, which says whether a literal
appears on any uncertain line. When the kernel lookup is uncertain and
neither kernel marker appears on an uncertain line, there is no kernel
block to strip, so the strip returns without a notice and leaves the
file unchanged. Kernel text past an unclosed fence still gets the
notice, and the migration and the session block writer still refuse on
an uncertain file, because acting there could lose pins or write a
block later runs cannot read.
The Retrieved Context sync prepended the newest retrieval and never
compared ids, so retrieving a memory twice left two copies of it in the
three-entry section. The section loads into every session under a token
budget, so a repeat pushed out a different memory and added nothing.

Before it prepends, the sync now drops each existing entry whose Memory
ID line matches the new entry's, so a repeat moves to the top with its
new time and query. Entries are compared by id, not by text, so the
same memory found by a different query is still one entry. A new entry
without an id drops nothing, and existing entries without one are kept.
…ased growth rule

The pin-cap gate counted `### ` lines with a fence-blind scan and refused
any edit whose count went up past 12. A heading inside a code example
counted as a pin, so honest edits that added or moved an example were
refused; a fenced copy of the Pinned heading could be read as the real
one; and the check for a heading smuggled into a pin body also refused
undated renames, swaps and moves. Its module load and parse failures
refused every Edit and Write.

Readers. One Pinned locator in staleness finds the section inside the
memory block through the parser, and every pin reader goes through it:
pin_caps, staleness, archive_pin, check_pin_caps, pin_staleness_gate and
the session-start slot count. A `### ` line inside a code block is body
text, not a pin. Readers stay silent when the section cannot be read
reliably (an unclosed fence, a duplicated or broken marker pair) instead
of guessing. A pin's size counts its code examples, as before for prose,
but ignores trailing spaces and tabs and counts each line break as one
character, so a whitespace-only rewrite is never refused on size.

Rule. pin_growth decides whether an edit adds pins by aligning the memory
block before and after the edit and counting what the alignment says was
added, crediting moved, re-fenced and edited-in-place lines. It runs
under a counted step budget and a timer; hitting either allows with an
advisory. When the section before cannot be located, each pin is
measured against the stretch of old text its kept lines came from.

Gate. pin_caps_gate builds the text after the edit and decides with
pin_cap_decision. It refuses only on that decision or on an invalid size
override on a new or changed pin. A failure to load, a parse failure or
any exception allows and is logged; the one remaining refusing failure is
a Write over an existing file that cannot be read. The embedded-pin
check and its parse-failure route are removed.

Certification. Fixed rows, generated populations (including commented-out
old Pinned sections and notes outside the managed block) and clause-level
mutants show no refusal of an honest edit, and every pin addition the
rule lets past the cap falls in a residual family the user signed off.
This change's census allowlist is now empty; the rule's one deliberately
fence-blind function is exempted by name.
The pin-cap gate decided whether to check an edit by matching its path
against the project CLAUDE.md that existed before the edit. A Write
that created the file was never checked, and a Write that created
.claude/CLAUDE.md while ./CLAUDE.md was the project file was compared
with nothing.

The gate now asks the project resolver which file the edit lands on,
counting the target as if it already existed, and checks the edit when
the resolver picks that file. It compares the new text with the file
the resolver picked before the edit, or with nothing when no project
CLAUDE.md existed yet. So a first Write is checked, and moving an
over-cap ./CLAUDE.md into .claude/ is compared with the file it
replaces rather than refused as new. Paths are compared by file
identity, case-folded on a case-insensitive volume. A path the gate
cannot place, such as a relative one or one the resolver does not pick,
is not checked.

The gate tests now point the resolver at their project the way a real
session does, through CLAUDE_PROJECT_DIR, instead of patching a lookup
the gate no longer calls.
pin_caps.check_add_allowed decided whether a pin could be added before
the edit, and it carried the embedded-pin check. The gate now decides on
the file as it would be after the edit, so nothing called the function;
only its own tests kept it and the retired embedded-pin check alive.

The function goes, with the embedded_pin violation kind that only it
constructed and the prose that described either. Two properties its
tests pinned still matter and are now pinned against the live decision:
every pin takes a count slot whatever it carries, an override or a
STALE mark, and an override on the added pin does not lift the count
cap. Two parser facts stay pinned too: a #### line is not a pin, and
prose that mentions a pin comment is not one. The import-identity
baseline drops the names the retired check left behind.
The pin-cap gate checked only the lead's edits. A teammate, an Agent
subagent or a specialist session could add pins past the caps through
Edit or Write, and the gate let it through.

The gate now checks an edit when the caller is the lead, a PACT
specialist, or any frame whose session belongs to a PACT team, which
covers in-process teammates and subagents through the lead's session.
Plain sessions and non-PACT --agent sessions stay unchecked. A failed
team lookup counts as not in a team, so it never refuses an edit on its
own. A checked caller gets the lead's verdict, decided on the same two
texts. On a count refusal, a team member is told to ask the team-lead
to free a pin slot instead of being sent to a prune command it cannot
run; a specialist with no team keeps the lead's text.

The membership check runs after the file-name filter, so no other Edit
or Write pays for it. The gate joins the hooks that need a real
integration test, and new tests drive the real hook for each kind of
caller. The tests that asserted a teammate bypasses the gate now assert
that it is checked; one of them never applied its edit, so it is
rewritten to apply one. The count-axis matrix rows that were skipped
under the retired embedded-pin rule are live rows of the current rule.
The pin-cap gate decided which callers it checks inside its own hook,
which other hooks cannot import. That check now lives in the shared
CLAUDE.md manager as gate_frame, beside gate_target, together with the
instruction a team member gets on a count refusal, and the gate calls
it. The gate's decisions and texts are unchanged. The role and team
lookups are imported inside the function, so loading the shared manager
loads nothing new.

The hook classifier's dependency lists for session_init and
bootstrap_marker_writer grow by the helpers the function can reach, and
the lead-check invariant now follows the gate's call into gate_frame. A
type error in the caller tests is fixed.
The pin-cap gate read the file as it was before the edit while holding
the writers' file lock. A writer holding that lock made the gate wait
out the lock's timeout, and a timeout counted as a read failure, which
refuses a Write. The gate only reads, and the writers replace the file
whole, so a read without the lock sees one version or the other.

The gate now reads the file without the lock. A new test holds the
writers' lock from another process and checks that the gate still
allows a Write that renames a pin at the cap and refuses one that adds
a pin, well inside the lock's timeout.
The pin-cap gate checks Edit and Write. A Bash command, a script or any
other program could still grow the project CLAUDE.md past the pin caps,
and nothing said so.

After each Bash call, including one that fails, PACT now compares the
project CLAUDE.md with the last version it saw in that session. When the
change since then is one the gate would refuse, the agent that ran the
call is told which cap was crossed and the pin counts before and after.
The file is left as it is and nothing is refused. A team member is told
to ask the team-lead to free a slot; the lead or a specialist with no
team is pointed at the prune command. At each prompt and session start,
except after a compaction, the lead is told once when the file holds
more pins than the cap and more than at the last check, whatever made
the change, and once when PACT's memory markers are missing so the pins
cannot be counted.

The reports cover the same callers as the gate, so a session with no
PACT role writes no record and gets no report. The records live only
under the PACT sessions directory, with private permissions. A failed
Bash call now also clears the staleness marker, as a successful one
does. The pin decision carries its size reason, so a report can say
which cap was crossed.
pin-memory.md and prune-memory.md described the pin-cap gate as it was
before the fence-aware parser: a refusal for pin structure inside a
body that no longer exists, and an allow explained by a pin-count
comparison the gate no longer makes.

pin-memory.md now says which lines are pins, a ### line outside a
fenced block including one inside another pin's body, and that a fenced
example counts toward a pin's size while trailing whitespace does not.
At the cap a change may rename, move, reorder or rewrite pins but not
add one, and the size cap still applies to every body. A change made
through Bash is reported rather than refused, so pin changes go through
Edit. An allow with a note that the cap was not checked means it was
not enforced, and the file needs repairing. A Pinned Context section
belongs inside PACT's memory block, where the gate counts it.

prune-memory.md explains the allow by the change adding no pin. pause.md
and wrap-up.md no longer say that pin-memory with no arguments prunes
stale pins; it only adds them. The tests that pin this wording follow
it.
A team member whose CLAUDE.md edit was refused on the pin count was told
to ask the team-lead to free a pin slot, and the pin-memory command told
the lead to free one and have the teammate retry. Either way the
teammate ended up writing CLAUDE.md, which a teammate must not do by any
route.

The instruction a team member gets, on a count refusal and in the report
after a Bash call grows the file, now reads "Do not change CLAUDE.md
yourself, by any route; tell the team-lead." It assumes nothing about who
caused the growth, and it rules out a write through a script as well as
an edit. pin-memory.md tells a teammate to send the pin's text to the
team-lead instead of pruning or retrying, and tells the lead to free a
slot with the user and add the pin itself; prune-memory.md matches. The
count refusal's label shows the count after the change.
check_pin_caps and archive_pin load pin_caps and staleness from the
hooks directory by path, and the loader always ran the file again and
replaced the loaded module. Each script runs in its own process, so in
use there was one copy. In the test process, importing a script after
those modules were loaded left two copies: code that had bound names
from the first kept using it, so a test that patched pin_caps could miss
the copy the pin decision read, and the staleness functions bound in
session_init went stale.

The loader now returns the module already loaded under that name when it
came from the same hook file, and loads by path otherwise. Running a
script on its own is unchanged. The counter-test patches pin_caps
directly again, and the test fixture that rebound session_init to the
newest staleness is removed, because nothing replaces staleness any
more. A new test checks that a script reuses the loaded modules and does
not reuse a different file loaded under the same name.
When the project CLAUDE.md holds a block or section the parser cannot
place with certainty (two copies, a marker that is unpaired, nested or
not on its own line, a marker or heading after an unclosed code fence,
or a heading that is commented out), PACT's writers leave it alone.
The lead's session start now prints one line naming the file, the
earliest reason, and every block or section that will not be updated
until the file is fixed. Two real Pinned Context headings add that the
pin cap is not checked. A missing section is not a refusal. A
compaction, a teammate, and a file that is missing, unreadable or not
UTF-8 print nothing.

Every shipped site that located a PACT marker by string comparison now
goes through the fence-aware finder, so the census test asserts there
are none and the per-commit allowlists are deleted.

Bump the plugin version to 4.8.3.
…ords safely

The frame rows for an in-process teammate ran only on a frame built by
hand, because no real one had been captured. A live probe captured the
PreToolUse Edit frame an in-process teammate sends: it carries an
agent_id with no "@" and an agent_type that is the member's name, with
no agent_name. The gate's in-process rows now run on that frame as well
as the built one, and the hook stdin reference records it, along with
the keys of a failed Bash call's frame, which carries an error rather
than a tool response. The drift tests build their failed-call frames
from those keys.

The drift records are now named so that only the lead's is ever
"lead". A frame with a valid agent_id names its own record, and a frame
alone in its own session, a separate-process teammate or a specialist
with no team, uses "session". A teammate frame in the lead's session
without an agent_id keeps no record and gets no report, rather than
writing over the lead's; the lead's next prompt still reports that
growth. The per-Bash report says "1 pin" rather than "1 pins".
Every planned test scenario is now mapped to a test whose assertion
checks it. The gaps that mapping found are closed here, each new test
confirmed live by a mutant that breaks the code it guards:

- a drift record never keys on an agent id of "lead" or "session";
- every reader and writer, not only the finder, runs over the whole
  CLAUDE.md corpus, with expectations taken from each file's block
  states and checked by read-back, unchanged user rows and idempotence;
- the finder-oracle property has a hypothesis variant;
- the not-found advisory names the line;
- the fixed rows the old gate decided differently keep the opposite
  verdict, each classified as a correction or a signed-off residual;
- every rule row goes through the gate's own Edit simulation as a
  Write, an Edit and a replace_all Edit;
- the counted matcher agrees with difflib on every population slice;
- a non-UTF-8 byte in a pin body leaves faithful decisions unchanged;
- the per-launch report names a memory block missing only its end
  marker;
- the syncs are correct beside a pin snippet that quotes a heading.
Run both memory syncs, Working Memory and Retrieved Context, over every
file in the CLAUDE.md corpus, 238 cases in all, with expectations taken
from each file's block states. Where the managed block, or the memory
block inside it, is uncertain, the file is left byte for byte. Where a
sync writes, its window keeps exactly one real section heading, no other
row changes kind or hidden flag, and a repeated Retrieved Context sync
changes nothing.

Add a drift row for a prompt on an unchanged CLAUDE.md: the check reads
and hashes the file and stops, loading neither the pin-growth rule nor
difflib and leaving the recorded baseline as it was.
The migration stripped the whole user section. That took the first kept
line's indentation, which can turn a fence or heading into something
else, and moved a leading byte-order mark into the middle of the file.
It now trims only whitespace-only lines at the two edges, keeps every
other byte, and keeps the mark at byte 0.

The read-back after the rebuild refuses, with a named reason and the
file untouched, unless the managed and memory blocks each read found,
the session block reads found exactly when one was carried over, no
uncertain region appears that the original did not have, and the pinned
markers, the legacy kernel markers and the routing block read the same
as in the original. Markers are compared by state and cause, never by
the reason text, whose line numbers move legitimately.

A managed-region test that expected an indented memory heading moved to
column 0 asserted the bug; it now expects the indentation kept.
The per-launch report said the pin cap is not checked only when the
file had two Pinned Context headings. A memory block with duplicated,
unpaired or nested markers, a commented-out Pinned heading, a code
fence left open above it, or an unpaired pinned marker also leaves the
pin-cap gate unable to locate the section, and the gate then allows
every pin edit with an advisory. The report now asks the gate's own
Pinned lookup and adds the clause whenever that lookup fails this way.
A missing memory block or Pinned section still adds nothing.
…read-back

The migration refuses a rebuilt file unless the managed, memory and
session blocks and every carried marker read back as they must. A
marker literal added later and named in none of those checks would pass
with nothing red. A test now scans the shipped code for marker literals
and fails on any that is neither read back nor excluded with a reason.
The read-back compared each carried marker's state and cause, so a
marker that already appeared twice still read as duplicated when the
rebuild made a fenced third copy live. It now also compares how many
marker lines or blocks the finder returns, and checks each routing
marker on its own, which counts a copy even when the routing block reads
as unpaired.
When a CLAUDE.md holds marker text that is not on a marker line of its
own (indented, quoted, list-prefixed or sharing its line), the finder
read that marker as malformed and returned no lines. The migration's
read-back compares how each marker reads before and after the rebuild,
so in a file that already read this way it could not see a further
copy of the marker going live, for example a fenced example that the
rebuild un-fenced.

Such a result now lists the marker's real marker lines, so the
read-back's line count sees the new one and the migration refuses.
The test oracle, the corpus table and the rows change with it. One
real case the rows now pin: a Pinned section that ends inside an HTML
block that never closes, moved above the user's notes, un-fences a
kernel marker example there. That file used to be written; it is now
refused.
The optional property test imported hypothesis at module level behind a
try and a skip marker, so a type checker without hypothesis installed
reported the import and every name from it. The test now takes
hypothesis and its strategies through pytest.importorskip inside the
test itself: it still skips with the same reason when hypothesis is
missing, still runs where it is installed, and type-checks clean.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread pact-plugin/hooks/pin_caps_gate.py
The pin each override row continues was the first old pin, in document
order, holding half of its word pairs. So a new pin above the original
that repeated or overlapped the original's body took the original's
pin, and a padded invalid copy rode on it while the original was only
reconfirmed. Every pairing is now scored: heading and content together,
then content by share, then heading. Each old pin goes to its best new
pin and each new pin keeps its best; a tie gives neither, and those
pins match cross-pin as any rows may.

The identity pass handed identical old rows out by a document-order
cursor, so an untouched pin took the old row of an edited pin whose
override read the same, and the edited pin lost its carry: refused for
a faithful reconfirm, or let grow on an override that never exempted
it. A row now takes its own pin's identical old row first, and the
cursor skips rows already taken.

A pin holding the share alone is compared only with old pins no
heading partner holds the share of, and the step budget counts each
word pair compared and each index entry added.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread pact-plugin/hooks/pin_caps_gate.py
Heading-only matches all scored the same, so a new pin taking the
original's heading with a padded invalid copy tied the rewritten
original, neither got the old pin, and the copy claimed the old row
cross-pin. Heading-only matches now rank by share, as content matches
did, and only an equal share still ties.

A share was the fraction of the new pin's word pairs the old pin held,
so a short fragment of the old body scored as a whole continuation and
beat the rewritten original. It is now the fraction of the old pin's
word pairs the new pin keeps: the pin keeping more of the old text
continues it, however much it adds, and a fragment does not.
An exact copy of an old override on a new pin took the old row in the
identity pass, ahead of the original's own row, so the original's
whitespace pad, or its moved or removed reconfirmation, was checked on
its own and refused. The pin keeping more of the old text now takes its
own old row first whenever every pin carrying an exact copy keeps
strictly less of that text. The copy is then checked on its own: a copy
of a valid override is allowed, a copy of an invalid one refused.

Where a copy's pin keeps as much of the old text, the identity pass
decides as before, so an untouched original renumbered below a new pin
keeps its own row.
An exact copy on a pin keeping as much of the old text as the edited
original took the old row, because the contest asked every rival to keep
strictly less, while the pin mapping had already named the original the
continuation by its heading. The original's own row now takes the old
row ahead of a copy on a pin keeping no more of the old text, unless
that pin's body is the original's own: identical bodies tie whatever
their headings say. A copy on a pin keeping more still takes the old
row.

The mapping and the contest now measure share with one helper.
The contest gave an old override row to the pin the mapping named, and
treated two bodies with the same word pairs as a tie. So a copy whose
sentences were swapped tied with the edited original, and a near-copy
taking the original's positional heading kept the old row with its
exact copy, while the original, keeping more, had its padded row
refused.

Now the pins whose rows stand for an old row compete: the one keeping
the most of the old text continues it, the mapping's choice breaks an
equal share, and bodies reading the same once whitespace is collapsed
tie. That continuation's edited row takes the old row ahead of every
exact copy, and the size carry applies to the pair as to a mapped one,
so the original keeps its size but may not grow.
The contest ran only when a new row copied the old row exactly, so an
unrelated copy decided whether the pin keeping the most of the old text
kept its row: with a copy the continuation's edited row won, without
one the mapped pin's row did. The contest now runs whenever two or more
rows stand for an old row, and a lone row is never contested. With no
copy, only a continuation other than the mapped pin needs a place
ahead, and an old row whose mapped pin keeps all of its text is not
contested.
The contest dropped any pin the mapping assigns to another old pin, so a
pin merged from two never continued the second. Its padded copy of that
pin's override was checked as new and refused, while the same edit on
any other continuation is allowed. A pin mapped elsewhere now continues
an old pin when it alone keeps the most of that pin's text; where no pin
keeps any, it still continues none. The mirror case, a padded copy left
on the old pin beside the merged pin's valid row, is now refused as a
copy on a pin keeping less.

The size carry follows the same pairs. A merged pin that takes an old
pin's row, by the contest or as its only claimant, carries that row's
validity when it keeps some of the old pin's text, so reconfirming an
invalid override on a merged pin grants it no exemption to grow past the
cap. A pin keeping none of the old text gains no carry, so an over-cap
pin whose override moves to it still loses its exemption.

Also corrects a docstring that still said the contest runs only beside
an exact copy.
A large CLAUDE.md could keep the pin-cap decision working until its
30-second timer fired, and the answer then depended on how busy the
machine was: a refusal made at 250 pins became an allow at 500 pins on
a loaded machine, and the stop note named the growth rule whichever
part had stopped.

Every part now counts its work in a budget named for the part and
stops at its own limit, well inside the timer, so a large file gets
the same answer on a fast or a slow machine:

- _Budget takes a part name and, given no limit, uses that part's
  limit in LIMITS. Its stop note names the part, for example "the
  pin-size rule passed its step budget of 1400000".
- The limits are sized by what one step of each part costs, so that
  together they take about 7 s on the slowest CI runner, under a
  quarter of the timer: 12M steps for the pin-growth rule, 1.4M for
  the pin-size rule, 1.5M each for the override mapping and contest,
  and 1M for the override matching.
- The pin-size rule counts its work, in one budget shared by its two
  calls. Its comparisons of every pin pair now go through exact
  indexes of word pairs and lines, which reach the same pins in the
  same order, so it decides as before and grows linearly when pins
  share no text.
- The pin-growth rule also counts its re-fence comparisons, its scan
  past stray headings and its row passes, and the size pins'
  stretches are found in one pass instead of one per pin.
- A text pair over 1,000,000 characters stops before it is parsed,
  with "the file is too large for the pin cap check".

No ordinary decision changes. Over the full suite, the populations at
CI scale, ordinary edits replayed on 83 real CLAUDE.md files and the
rig rows, the largest spend is 7,481 steps in the pin-growth rule,
32,172 in the pin-size rule and 19,800 in the override check, and the
largest text pair is 57,010 characters.

The CI timing test now asserts that a budget stops the costliest shape,
never the timer, and reports every part's steps against its limit.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread pact-plugin/hooks/shared/pin_growth.py
Each budget the override check makes now names its part, the pin
mapping, the override contest or the override matching, and draws that
part's own limit, so a stop note says which part stopped.

Two kinds of work ran outside every budget. The trimmed read walked a
row's trailing punctuation one regex match per character, several
times per row; it is now one match of the reversed read, computed once
when the row is read and kept on it. The merged-pin carry's comparison
of the two pins' word pairs now spends from the matching's budget,
which the check creates and hands to the matching.

The contest searched every new row for rows standing for an old row
when no pin kept the old text, and for a second such row beside a lone
claim. A row stands only for an old row of its own group, so new rows
are now grouped once and each search reads only the old row's group.
With a different override on each pin the contest's steps stay linear
in the pins. Its result is unchanged: every row the old search found is
in the group, and the same test picks them in the same order.

The gate now runs the pin cap's size check first, so texts too large
for the check stop it before the override check parses them.
A merged pin's reconfirmed row can read like another pin's old override
once the reconfirmation cuts its trailing punctuation. The contest then
placed it ahead for that other pin, its own old row went unclaimed, and
the other pin's whitespace edit of its own row was refused as new. A row
that stands for the old row of the pin it is mapped to now takes that
row and competes for no other old row; the contest ranks the rows left.

A row counts as standing for its own pin's old row only when its pin
keeps some of that old pin's word pairs; a mapping by heading alone does
not count. A row never loses its own mapped old row to an exact copy on
a pin that keeps strictly less of that old pin. On an equal share the
identity pass decides as before, so an untouched original keeping all
of its text still keeps its row against a new pin given its heading.

A row leading more than one old row was placed for the first in the
order of the text before, so reordering pins could change the verdict.
It is now placed for the old row whose pin it keeps the most of, and on
an equal share for the one whose pin's content (heading, body, override)
sorts first, so the order of pins no longer decides a verdict.

The size carry for a pin mapped elsewhere now asks it to keep at least
half of the old pin's word pairs, the share the mapping itself asks of a
continuation; one shared word pair no longer earns it.
Two old pins alike in every way the contest compares, such as twins
differing only in their pinned date, their heading's case or spacing,
or their comment row's spacing, still tied, and the first in the text
before won. The verdict never moved, but which twin a row held, where
the contest placed it and the carry did.

The contest's tie key now goes on, after the content it compared, to
each old pin's heading row, comment row and body exactly as written.
The matching looks at old rows in that same order wherever a row could
take one of several, so a lone row standing for two such twins holds
the same one whichever comes first. Pins identical byte for byte are
interchangeable, so choosing either decides nothing.
The pin-cap check's 30 s backstop counted wall time, so a busy machine
could fire it on an input whose work fits easily. A 300-pin file that
adds a pin uses about 1.1 s of processor time and is refused for its
pin count. Under a 100-process CPU hog it ran 30 to 46 times slower
than its processor time, the timer fired first, and the change was
allowed with a stop note in 3 runs of 3. Load, not the work, decided
the answer.

The check now runs under two timers:

- 30 s of this process's own processor time (ITIMER_PROF, SIGPROF),
  which other work on the machine cannot use up. It stops runaway work
  the step budgets miss, with "the pin cap check used more than 30 s
  of processor time".
- 540 s of wall time (ITIMER_REAL, SIGALRM), which ends a wait that
  uses no processor time, with "the pin cap check ran for more than
  540 s".

Either stop still allows with the stop-early note. A stop disarms both
timers before it raises, and both previous handlers are restored.

Under the same hog the 300-pin file is now refused in 3 runs of 3, at
up to 79 s of wall time, and the heaviest input the budgets allow
stops on its step budget, with the note it gets on an idle machine,
where the old timer stopped it first.

hooks.json now gives the gate an explicit 600 s timeout. The platform
cancels a PreToolUse command hook at its timeout, discards its output
and lets the call through; its default is 600 s
(code.claude.com/docs/en/hooks). Keeping the timeout 60 s above the
wall backstop keeps the gate's own note ahead of a silent cancel, and
a test holds that margin. The wall backstop can change an answer only
once the check runs about 480 times slower than its processor time
for the 300-pin file, and about 140 times for any input the budgets
allow (at most about 3.9 s of processor time on this machine).

The tests check that a sleep past both timers ends at the wall
backstop, not the processor-time timer; that a busy loop ends at the
processor-time timer; and that both handlers come back on every path.
They catch five planted mutants: the wall-only timer, the
processor-time timer moved to wall time, no wall backstop, a stop that
leaves a timer armed, and a handler not restored.
override_rationale_text dropped the non-word run before a
reconfirmation by stepping back one regex call per character. That
loop ran outside every work limit, bounded only by the size cap. It
now takes one match of \W* on the reversed text, as the gate's
_trimmed already did. A forward search for \W*\Z would read the same
but retries from every start: on a 100,000-character run before a
word character it took 173 s, where the reversed match takes under a
millisecond.

The two copies become one. pin_caps owns _NON_WORD_RUN and _trimmed,
and the gate and its tests import them from there, so the gate no
longer imports re. The gate still imports pin_caps only inside its
guarded imports or inside a function; a new test runs it beside
shared/ with pin_caps missing or raising on import, and it allows
the call and names the failure.

No output changes. Compared with the per-character walk, 2,428,224
raw strings (every code point, alone and after a letter) and 103,041
parsed override rows (Unicode, combining marks, symbol-only and empty
reads, runs up to 10,000 characters) trim and read the same, and two
planted mutants are caught. The gate's decisions on 35,342 captured
inputs and 40,000 generated cases, and the pin-growth and size
fixture verdicts, are byte-identical.

On 500 pins whose rationales end in runs of up to 500 characters,
reading the rationales takes about a fifth of the time it did.
Two override-gate tests read the text simulate() returns without first
checking it is not None, so pyright reported three errors: an `in`
and a `not in` on an Optional, and `.count` on one. Each test now
asserts the edit simulated before it reads the result. No test
outcome changes.
Several tests added or changed on this branch measured wall time, which
a busy machine stretches, so they failed on correct code under load.
Each claim is now checked in a way load cannot move.

The writer-lock test no longer times two gate runs against 5 s. It runs
the gate under a Python audit hook and checks that the gate reads
CLAUDE.md and never opens or locks the writers' lock file, so it cannot
wait on it. Reading CLAUDE.md shows the audit hook ran.

The comment-openers test no longer times the decision against 1 s. A
spy on the strike's pattern checks that it is offered a row only up to
the row's last `-->`, and nothing when the row holds none.

Four tests that bound what a call costs now measure this process's own
processor time, with the same bounds: the row of repeated override
fields, the parser's two termination tests and the date field's
whitespace-run budget.

The timeouts that turn a hang into a failure are raised to 600 s: the
subprocess runs, the concurrent gate calls' barrier and joins, the lock
holder's exit and the spin deadline in the processor-timer test. A 60 s
timeout on `git init` tripped on a correct run here while the load
average was between 220 and 320. The drift test's lock holder now holds
until it is killed rather than for 60 s, so the lock stays held however
long the test takes.
Three ordinary edits of a CLAUDE.md at the pin cap were refused as if
they added pins: un-indenting a `### ` line so it becomes a pin,
moving PACT's pinned end marker down past pins, and moving a pin with
two Edits in a file over the cap. The pin-growth rule now counts the
first two as pins that were already there, and the decision takes a
credit for the third.

- clause_unindented_heads pairs a new heading with a line of the
  Pinned section before that becomes it once its indentation goes:
  by the same text, or in the same gap of the alignment, which reads
  an un-indent and a retitle in one change.
- clause_past_end_marker extends the region before over the rows
  below the end marker, and then past stray headings to the memory
  block's end.
- pin_cap_decision takes `credit` (count, removed, exclude): pins
  this agent's earlier allowed changes along one chain removed, and
  lines they added. The count axis refuses only growth the credit
  does not cover. The size axis also reads the removed pins that no
  pin before continues (pin_caps.continues) as pins before, so a pin
  re-added unchanged, or a different one no larger, is allowed, while
  a copy of one already back is still new. Credit only ever turns a
  refusal into an allow.
- reveal_candidates gives the keys of the lines a reveal can turn
  into pins, and `detail` returns what the decision read, so the
  caller that keeps the chain can record without parsing again.

No credit and no exclusion reach the rule yet: until the gate passes
them, the reveals allow lines whichever route put them there, and a
two-Edit move is still refused at the cap.

Three refinements close under-blocks the design left open, each with
a fixture row and a mutant that flips it:

- The stray-heading reveal, and both new reveals, need a non-blank
  row to be aligned before they extend the region; a blank line
  carries no identity. Deleting a stray heading and replacing the pin
  it hid by a new one, with blank lines kept, was allowed past the
  cap and is refused now; so is the same replacement below the end
  marker.
- The end-marker reveal takes the rows below the marker only through
  the last one still in the Pinned section after the change, so a
  pin the marker still stands above cannot pay for a new pin: moving
  the marker past one of two pins and adding a pin is refused.
- An excluded indented line earns no pairing at all, so un-indenting
  and retitling it in place counts it as new.

The count refusal names how many pins to demote ("demote 3 pins"),
counting the credit, so demoting that many and retrying passes. Its
first sentence is unchanged.

No population verdict moves at either scale, and every existing
fixed, reveal and size row decides as before. The new reveal rows
and their controls run through the populations and the clause
mutants.
A pin removed by one Edit and added back by a later Edit of the same
agent was refused in a file over the cap, though one Edit doing both is
allowed. The gate now keeps, per agent, a record of the pins its
allowed changes removed, in the session directory, keyed by the hash of
each file text such a change produced (hooks/shared/pin_credit.py). The
next change reads the credit for the file as it is, so a change from
any other route breaks the chain and gets the plain verdict.

pin_caps_gate reads the credit before it decides and, after an ALLOW,
records the state for the text the change produces. A missing,
unreadable or malformed record gives no credit, and a record it cannot
write records nothing, so no failure here refuses a change the gate
allows without it.

claude_md_drift confirms each write that lands in the record, so the
heading lines this agent's own changes added earn no reveal credit. A
move the gate credited moves the baseline rather than being reported as
growth at the next prompt.

The staleness gate re-checks an add against the pin-cap rule and the
credit, so a revealed pin or a moved one is not an add while the
stale-pins marker is set. A failure there keeps the count's answer.

state_file.locked_update takes an optional lock timeout; the credit
record waits at most 1 s for its lock.

pin-memory.md says to remove a pin before adding it back when moving it
with two Edits, and both commands say to demote the number of pins the
count refusal names.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 01f8c93. Configure here.

Comment thread pact-plugin/hooks/shared/pin_credit.py Outdated
The remaining credit was updated from the rule's growth when the cap
decision ran the rule, and from the change in located pins when it did
not. A reveal (an indented heading un-indented, a fenced one unfenced)
is growth 0 but one more located pin, so at or under the cap it spent a
credit: remove a pin at 12, un-indent a heading, and adding the pin
back was refused, though one Edit doing all three is allowed. A fenced
heading deleted is growth -1 but no fewer located pins, so it freed no
slot, though one Edit deleting it and adding a pin is allowed.

The decision now measures the rule's growth for the credit record
whenever both sections are located, even where it does not need it
itself. It does so after its verdict, apart from it: the verdict, its
counts and its reason are unchanged, and a stop or a failure in that
measure leaves the growth unknown. The record moves the credit by that
growth only, and records nothing when it is unknown, which ends the
chain.
A pin rewrite that dropped an indented `### ` line could pay for a
new pin. The rewritten pin's own renamed heading paired with the
dropped line as an un-indent in place, so the new pin went uncounted
at the cap.

In-place pairing now reads each unaligned heading of the text before
as renamed to a remaining heading in its gap first, so a gap pairs at
most its remaining headings less the headings it lost. A heading that
left one place and is back with the same text in another is a move
and counts on neither side; without that, moving a pin beside an
in-place un-indent refused a faithful edit, and pin markers that share
a date let one unchanged heading count as lost in one gap and as new
in the next.

Un-indenting by 2 spaces, 4 spaces or a tab, retitling in place, and
either of them beside a renamed or rewritten pin stay allowed. A new
pin in the same gap as a rewritten pin whose indented heading went
also stays allowed: it cannot be told from that heading un-indented
and retitled beside the pin, and its test says so.
The timing waits in the tests this branch added or changed were made
load-independent earlier, but the same files still held older waits a
busy machine can trip on correct code. They now follow the same rule.

A hang guard, a subprocess timeout or a thread, Event or process wait
whose only job is to turn a hang into a failure, goes to 600 s. The two
process-join loops share one 600 s budget, so a deadlock fails within
600 s however many workers hang. The unterminated-openers budget
measures processor time, as its sibling in the same class does.

In the file-lock test where a waiter blocks until the holder lets go,
the lock's own timeout goes to 600 s: the test asserts the blocking,
and a busy machine could reach the 5 s default first. The two tests of
that timeout keep their 0.3 s, which is what they test, and the archive
test that simulates a TimeoutExpired is unchanged.
An exception raised while the gate reads a CLAUDE.md can carry text
from it, and three places repeated that text: the advisory when the
decision fails, the failure-log record when the gate raises, and the
message when the hook cannot load. Each now names the exception's type
only, in PACT's own wording.

The load failure still prints the exception's text on stderr, where a
broken install needs it. It happens while the module loads, and
loading reads no CLAUDE.md, which a test now checks.
When the project directory holds no CLAUDE.md, the resolver falls back
to the git root of the hook's working directory, then to that
directory itself. A Write or an Edit that creates the project's file
was compared with whatever file that fallback found, which can belong
to another repository, so a first Write of 13 pins beside another
repository's 13-pin file was allowed.

The gate now keeps the resolver's file as the text before only when it
sits under the target's own base, or is the main checkout's file for
a worktree that has none. Otherwise the text before is the target's
own content, empty when it does not exist. A Write that moves
./CLAUDE.md to .claude/CLAUDE.md under one base is still compared with
the old file.
The staleness gate reports each fail-open on stderr. All three places
that report one run after the gate has read CLAUDE.md, and the report
repeated the exception's text, which can carry text from that file.
It now names the exception's type only, as the pin-cap gate does.

The load-failure refusal keeps the exception's text. It runs while the
module loads, and loading reads no CLAUDE.md, which a test now checks.
Each gate has a row showing that loading its module opens no
CLAUDE.md, with one read afterwards as a control that the audit hook
sees opens. The row expected exactly one event for that read. On
Python 3.9, Path.read_text passes io.open an opener that calls
os.open, and both report an open, so the control counted two and the
row failed there.

The rows now require no open while loading and at least one for the
control. A read added at module load still fails each row.
@michael-wojcik
michael-wojcik merged commit 2046d1d into main Oct 14, 2026
4 checks passed
@michael-wojcik
michael-wojcik deleted the feature/fence-aware-claude-md-markers branch October 14, 2026 04:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant