Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
08a7641
A portability suite runs as the eleventh backtest gate
pedrohcgs Sep 27, 2026
82a4590
Gates read git's paths verbatim, the quality gate scores every staged…
pedrohcgs Sep 27, 2026
4f05d34
The root-of-trust guard denies the Windows, Unicode and project-scope…
pedrohcgs Sep 27, 2026
9d90fc3
Hooks read the event as UTF-8 and catch Windows spellings of git, gh,…
pedrohcgs Sep 27, 2026
fcae2cc
Gates judge links by the index and renders without line endings, and …
pedrohcgs Sep 27, 2026
bc4a3c8
The portability suite runs on Python 3.9, the scorer skips files with…
pedrohcgs Sep 27, 2026
f0355b0
The root-of-trust guard reads a backslash as a separator only where i…
pedrohcgs Sep 27, 2026
042f036
A URL scheme no longer reads as a Windows drive, and the issue guard …
pedrohcgs Sep 27, 2026
8f0b633
Link gates read a heading behind a BOM and say why an untracked or wr…
pedrohcgs Sep 27, 2026
7301a3a
Windows PowerShell gap documented; the v2.6.0 inventory counts eleven…
pedrohcgs Sep 27, 2026
f49254c
Merge port/rot-2: root-of-trust guard portability fixes (Refs #171, #…
pedrohcgs Sep 27, 2026
5a07464
Merge port/hooks-2: hook and status-line portability fixes (Refs #171)
pedrohcgs Sep 27, 2026
7df442c
Merge port/gitout-2: git-output, hygiene, ledger and quality-gate fix…
pedrohcgs Sep 27, 2026
2133526
Merge port/gates-2: staleness, links, frontmatter, encoding and batte…
pedrohcgs Sep 27, 2026
6cbebe1
git-guardrails sees destructive git inside ( ), $( ) and backticks; t…
pedrohcgs Sep 27, 2026
5361fc5
Raw data keeps its bytes, existing CRLF clones know how to renormalis…
pedrohcgs Sep 27, 2026
679d5b2
Gates scan a clone at a bracketed path, the pre-commit stash survives…
pedrohcgs Sep 27, 2026
fe746d2
The portability gate passes under commit -a and in linked worktrees, …
pedrohcgs Sep 27, 2026
5632c0a
The pre-commit stash gives unstaged work back when the tree stays dir…
pedrohcgs Sep 27, 2026
8b96f0b
The code-page cases patch locale.getencoding in a locale-mode child, …
pedrohcgs Sep 27, 2026
b05fb70
Merge fix2/scripts-2: bracketed clone paths, pre-commit stash on coll…
pedrohcgs Sep 27, 2026
6134196
Merge fix2/tests-2: portability suite robust under commit -a, worktre…
pedrohcgs Sep 27, 2026
7884b8f
git-guardrails is back to main's tokenizer; #172 stays open as a disc…
pedrohcgs Sep 27, 2026
e11bac8
Address PR #173 review comments: raw index paths in check-links, NUL-…
pedrohcgs Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions .claude/hooks/claim-reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,26 @@
import sys
import time
import hashlib
import unicodedata
from pathlib import Path

WATCH = re.compile(r"(^|/)scripts/.*\.(R|r|do|py|jl)$|(^|/)output/|(^|/)scripts/.*/_outputs/")
# Matched against the path with `\` turned into `/` (see main): on Windows
# Claude Code sends `C:\...\scripts\analysis.R`, which a `/`-only pattern never
# matched, so every analysis-code and output/ edit was silent there.
# Case-insensitive, because on macOS and Windows `Scripts/x.R` is `scripts/x.R`.
WATCH = re.compile(r"(^|/)scripts/.*\.(R|do|py|jl)$|(^|/)output/|(^|/)scripts/.*/_outputs/",
re.IGNORECASE)
DISPLAY = re.compile(r"\.(tex|qmd|md|rmd|typ|ipynb)$", re.IGNORECASE)
THROTTLE_S = 300


def _fold(s: str) -> str:
"""A path spelling as a case-insensitive disk compares it: NFC, case-folded.
macOS and Windows open `slides/Lecture1.tex` for `Slides/Lecture1.tex`, and
a passport written by hand may use either; exact comparison missed both."""
return unicodedata.normalize("NFC", s).casefold()


def scan_passport(text: str, changed: str):
"""Claim ids in one passport that reference `changed`, split by role.

Expand All @@ -64,6 +77,7 @@ def scan_passport(text: str, changed: str):
"""
blocks: list[dict] = []
cur = None
changed_f = _fold(changed)
for ln in text.splitlines():
m = re.match(r"\s*-\s*id:\s*(\S+)", ln)
if m:
Expand All @@ -73,7 +87,7 @@ def scan_passport(text: str, changed: str):
if cur is None:
continue
if "source_file" in ln or "output_file" in ln:
if changed in ln:
if changed_f in _fold(ln):
cur["prov"] = True
continue
m = re.match(r"\s*-?\s*(?:path|location):\s*(.+)", ln)
Expand All @@ -87,10 +101,10 @@ def scan_passport(text: str, changed: str):
prov = [b["id"] for b in blocks if b["prov"]]
# Path-EQUALITY, not substring containment: "index.md" must not match a
# declared display of "docs/index.md", or "report.md" match "final-report.md".
cn = os.path.normpath(changed)
cn = _fold(os.path.normpath(changed))
disp = [b["id"] for b in blocks
if len(b["displays"]) > 1
and any(os.path.normpath(d) == cn for d in b["displays"])]
and any(_fold(os.path.normpath(d)) == cn for d in b["displays"])]
return prov, disp


Expand All @@ -103,14 +117,18 @@ def state_dir() -> Path:


def main() -> int:
# Bytes, decoded as UTF-8 — what Claude Code writes. sys.stdin on Windows
# decodes a pipe with the ANSI code page, which turned an accented project
# path into one this hook could not relate to CLAUDE_PROJECT_DIR.
try:
data = json.load(sys.stdin)
data = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
except (json.JSONDecodeError, EOFError):
return 0

ti = data.get("tool_input", {}) or {}
fp = ti.get("file_path", "") or ""
if not fp or not (WATCH.search(fp) or DISPLAY.search(fp)):
fp_n = fp.replace("\\", "/") # the filters are written with `/`
if not fp or not (WATCH.search(fp_n) or DISPLAY.search(fp_n)):
return 0

project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "") or data.get("cwd", "")
Expand All @@ -123,23 +141,39 @@ def main() -> int:
# Match + throttle on the project-relative PATH, not the bare basename —
# otherwise scripts/R/results.rds and scripts/stata/results.rds throttle
# each other, and clean.R spuriously matches data_clean.R.
#
# as_posix() (from PR #152): passports declare "scripts/analysis.R", and
# str() of a Windows path is "scripts\analysis.R", so the `changed in ln`
# test never matched and every provenance claim went unflagged there.
# The prefix is stripped part by part under _fold rather than with
# relative_to, which is case-sensitive on POSIX: a file_path spelled
# .../crproj/slides/... for the project .../crproj/Slides/... is the same
# file on macOS and fell back to the bare basename. Parts are compared,
# not folded strings sliced, because casefold can change a length (ß → ss).
try:
changed = str(Path(fp).resolve().relative_to(Path(project_dir).resolve()))
fparts = Path(fp).resolve().parts
pparts = Path(project_dir).resolve().parts
if len(fparts) <= len(pparts) or any(
_fold(a) != _fold(b) for a, b in zip(fparts, pparts)):
raise ValueError("outside the project")
changed = Path(*fparts[len(pparts):]).as_posix()
except Exception:
changed = Path(fp).name

# Throttle: one nudge per changed file per THROTTLE_S.
# Throttle: one nudge per changed file per THROTTLE_S — keyed on the folded
# spelling, so case variants of one file share one throttle.
key = _fold(changed)
st_path = state_dir() / "claim-reconcile-state.json"
try:
st = json.loads(st_path.read_text())
st = json.loads(st_path.read_text(encoding="utf-8"))
except Exception:
st = {}
now = time.time()
if now - st.get(changed, 0) < THROTTLE_S:
if now - st.get(key, 0) < THROTTLE_S:
return 0
st[changed] = now
st[key] = now
try:
st_path.write_text(json.dumps(st))
st_path.write_text(json.dumps(st), encoding="utf-8")
except Exception:
pass

Expand Down
18 changes: 11 additions & 7 deletions .claude/hooks/context-monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,16 @@ def read_cache() -> dict:
if not cache_file.exists():
return {}
try:
return json.loads(cache_file.read_text())
except (json.JSONDecodeError, IOError):
return json.loads(cache_file.read_text(encoding="utf-8"))
except (ValueError, OSError):
return {}


def save_cache(data: dict) -> None:
"""Save the context monitor cache."""
cache_file = get_session_dir() / "context-monitor-cache.json"
try:
cache_file.write_text(json.dumps(data, indent=2))
cache_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
except IOError:
pass

Expand Down Expand Up @@ -168,18 +168,22 @@ def emit(system_message: str, claude_context: str) -> None:

def run_context_monitor() -> int:
"""Main monitoring logic."""
# Read hook input
# Read hook input — bytes, decoded as UTF-8, which is what Claude Code
# writes. sys.stdin on Windows decodes a pipe with the ANSI code page: a
# transcript under C:\Users\José\ came out as mojibake, getsize() failed,
# and the estimate fell back to the tool-call counter (a full transcript
# read as 0%).
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, IOError):
hook_input = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
except (ValueError, OSError):
hook_input = {}

# Estimate current context usage (coarse proxy)
percentage = estimate_context_percentage(hook_input)

# Persist the latest estimate so the status line can surface it (best-effort).
try:
(get_session_dir() / "context-pct.txt").write_text(f"{percentage:.0f}")
(get_session_dir() / "context-pct.txt").write_text(f"{percentage:.0f}", encoding="utf-8")
except Exception:
pass

Expand Down
109 changes: 98 additions & 11 deletions .claude/hooks/git-guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,9 @@
keystroke", which understated it.

Write/Edit/MultiEdit — warn on hardcoded machine paths in code:
- absolute home paths (/Users/<u>, /home/<u>, C:\\Users\\) written into
.R / .qmd / .do / .py files break replication packages. Warn by
- absolute home paths (/Users/<u>, /home/<u>, C:\\Users\\<u> with either
slash, single or doubled, any case) written into .R / .qmd / .do / .py /
.Rmd files (extension in any case) break replication packages. Warn by
default; set CLAUDE_STRICT_PATHS=1 to hard-deny.

LINE CONTINUATIONS ARE SPLICED (r12), for BOTH checks, before the command line
Expand Down Expand Up @@ -332,6 +333,11 @@
rule 0, as does every other multi-segment form. So does an unresolved `-C`, and
so do the `--git-dir` / `--work-tree` / `GIT_DIR=` / `GIT_WORK_TREE=` selectors,
which the pre-r13 text wrongly implied did not exist.
- an op inside a SUBSHELL or a SUBSTITUTION — `(git reset --hard)`,
`echo $(git merge main)`, the backtick spelling, the same inside double
quotes or an unquoted heredoc body. `(git` and `--hard)` are single words to
this tokenizer, so no `git` segment is identified. Open as #172; a flat-
separator fix was tried and backed out (see _SEG_TOKEN).
- ANY SHELL FORM THAT PUTS THE OP SOMEWHERE THIS PARSER DOES NOT LOOK. The
list above is the set known on 2026-08-23; it is a report of where the
parser has been probed, not a proof of where it is complete. Round 9 found
Expand Down Expand Up @@ -553,6 +559,14 @@ def _mass_discard(args):
_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")


def _prog(word: str) -> str:
"""The program a command word names, as Windows and a case-insensitive disk
run it: the basename on EITHER separator, lower-cased, a trailing `.exe`
dropped. `git.exe`, `GIT` and 'C:\\...\\cmd\\git.exe' are all `git` (r21)."""
b = re.split(r"[\\/]", word)[-1].lower()
return b[:-4] if b.endswith(".exe") else b


def _op_is_exempt(words: list[str], start: int, tokens=GIT_OP_EXEMPT_TOKENS) -> bool:
"""True only when an ACTUAL argument token of this invocation IS one of
`tokens`. `start` is the index of the subcommand. The token set is a
Expand All @@ -573,14 +587,35 @@ def _op_is_exempt(words: list[str], start: int, tokens=GIT_OP_EXEMPT_TOKENS) ->

# Tokenizer: split a Bash command into per-command segments on shell
# separators, keeping quoted spans intact so a separator inside quotes does not
# split a segment.
# split a segment. Open (#172): `(`, `)` and backticks are NOT separators, so
# `(git reset --hard)` reads as the words `(git` … `--hard)` and a subshell or a
# substitution can carry an op past both checks. Making them flat separators was
# tried and backed out (2026-09-27): it cut comments short (false denies on
# `git pull # (daily sync)`) and split a command's own arguments at a
# substitution (a new miss). The fix needs substitutions parsed as nested commands.
_SEG_TOKEN = re.compile(
r"""(?P<sep>\|\||&&|;|&|\||\n)
| (?P<word>(?:"[^"]*"|'[^']*'|\\.|[^\s"'|;&\n])+)""",
re.VERBOSE,
)
HARDCODED_PATH = re.compile(r"(/Users/[^/\s'\")]+|/home/[^/\s'\")]+|[A-Za-z]:\\\\Users\\\\[^\\\s'\"]+)")
CODE_EXT = {".R", ".r", ".qmd", ".do", ".py", ".Rmd"}
# r21: the Windows branch used to need `C:\\Users\\` with DOUBLED backslashes —
# the escaped spelling inside an R string — so Stata's native `cd "C:\Users\me"`,
# a Python raw string `r"C:\Users\me\x.csv"` and lower-case `c:/users/me` were
# never flagged; `C:/Users/me` was, but only by luck, through the `/Users/`
# branch. Measured 2026-09-27 on ae72617 with CLAUDE_STRICT_PATHS=1: the first
# three ALLOWED. One or more of either separator counts now, and `users` in any
# case — a SCOPED `(?i:...)`, because a global `(?i)` also flags
# `https://api.github.com/users/<name>` in ordinary .py code. The extension is
# compared lower-cased for the same reason: `a.rmd` and `MASTER.DO` skipped the
# check entirely while `a.Rmd` and `master.do` denied.
# The drive letter must not follow a letter or digit. Without that the scheme's
# last letter reads as a drive: `gs://users/data.csv`, `https://users/x` and
# `{"key:/users/me": 1}` matched as `s:`, `s:` and `y:` (measured on 9d90fc3, a
# deny under CLAUDE_STRICT_PATHS=1). A real drive follows a quote, a space, `(`,
# `=`, `\\?\` or `file:///`, none of which the lookbehind refuses.
HARDCODED_PATH = re.compile(r"(/Users/[^/\s'\")]+|/home/[^/\s'\")]+"
r"|(?<![A-Za-z0-9])[A-Za-z]:[\\/]+(?i:users)[\\/]+[^\\/\s'\")]+)")
CODE_EXT = {".r", ".qmd", ".do", ".py", ".rmd"} # compared lower-cased


# ANSI-C QUOTING (`$'…'`) AND LOCALE TRANSLATION (`$"…"`) — r19.
Expand Down Expand Up @@ -910,6 +945,21 @@ def _join_continuations(cmd: str) -> str:
return _LINE_CONT.sub(r"\1", cmd)


def _drop_cr(cmd: str) -> str:
"""Remove every carriage return, as the MSYS bash of Git for Windows does
before it splits words (r21).

`_SEG_TOKEN` counts `\\r` as whitespace, so `git re<CR>set --hard` was
three words here and matched no rule, while Git Bash — the shell Claude
Code's Bash tool runs through on Windows — drops the CR and runs
`git reset --hard`. The same held for a CR inside the program word and
for a `\\<CR><LF>` continuation, which the continuation splice never saw.
Unconditional: POSIX bash keeps a CR as an ordinary WORD character, never
a separator, so dropping it can only join text the shell also treats as
one word — a false deny at worst, on a command git would reject anyway."""
return cmd.replace("\r", "")


def _segments(cmd: str) -> list[list[str]]:
"""Per-command segments as lists of unquoted words.

Expand Down Expand Up @@ -1067,10 +1117,18 @@ def _git_segment(words: list[str]) -> tuple[str | None, list[str], int, list[str
`git STATUS --porcelain` and `git Status --porcelain` both exit 128
("fatal: cannot handle STATUS as a builtin") while `git status` exits 0. A
spelling git refuses to run cannot reach the tree, so folding it would buy
no coverage and would only add false denies."""
no coverage and would only add false denies.

r21 — `.exe` AND A BACKSLASH PATH NAME THE SAME PROGRAM. On Windows, Git
Bash starts `cmd\\git.exe` for `git.exe` exactly as for `git`, and a quoted
'C:\\Program Files\\Git\\cmd\\git.exe' is one word posixpath never splits.
Measured 2026-09-27 against ae72617: `git.exe reset --hard`, `GIT.EXE
clean -fdx` and that quoted path + `reset --hard` were ALLOWED (silent)
while `git reset --hard` DENIED. `_prog` reads the program either way, on
every OS, by the same ruling as the case fold above."""
words, i = _strip_shell_head(words)
while i < len(words):
if os.path.basename(words[i]).lower() == "git":
if _prog(words[i]) == "git":
j, dash_c, selectors = _walk_git_globals(words, i + 1)
if j < len(words):
return words[j], dash_c, j, words, selectors
Expand All @@ -1085,7 +1143,8 @@ def git_deny_reason(cmd: str) -> str | None:
# Heredoc bodies are dropped FIRST (they are line-oriented: joining a body
# line that ends in `\` could hide the terminator), then continuations are
# spliced, so a multi-line command is tokenised as the one command bash runs.
for seg in _segments(_join_continuations(_strip_heredocs(cmd))):
# Carriage returns go before either, as Git Bash drops them (r21).
for seg in _segments(_join_continuations(_strip_heredocs(_drop_cr(cmd)))):
sub, _dash_c, at, words, _sel = _git_segment(seg)
if sub is None:
continue
Expand Down Expand Up @@ -1296,9 +1355,13 @@ def read_status(cwd: str | None) -> "str | _NotARepo | None":
question" the `-C` path was fixed for at r13 and this path was not. The two
causes are now distinguished, and only the benign one allows."""
try:
# UTF-8, not the locale: on Windows the default is the ANSI code page,
# which cannot decode every name git prints with core.quotePath=off,
# and a decode error here is an unanswered question — a false deny.
r = subprocess.run(list(_GIT_STATUS_CMD), cwd=cwd or None,
env=_repo_neutral_env(),
capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=_status_timeout())
except Exception:
return None if _within_git_repo(cwd) else NOT_A_REPO
Expand Down Expand Up @@ -1442,6 +1505,9 @@ def _standalone_violation(spliced: str, n_segments: int,
return None


_MSYS_DRIVE = re.compile(r"^/([A-Za-z])(?=/|$)")


def _resolve_dash_c(dash_c: list[str], default_cwd: str | None) -> str | None:
"""Fold EVERY `-C <path>` on the invocation, in order, the way git folds
them — starting from the INVOCATION's directory.
Expand Down Expand Up @@ -1513,13 +1579,28 @@ def _resolve_dash_c(dash_c: list[str], default_cwd: str | None) -> str | None:
not exist — there the resolved directory is not a repository, and the
unresolvable-`-C` branch in `dirty_tree_reason` DENIES rather than allowing.

r21: A GIT BASH DRIVE PATH IS READ AS GIT.EXE RECEIVES IT. MSYS converts
`/c/Users/me/proj` to `C:/Users/me/proj` before it starts a native git.exe,
but Windows Python reads `/c/...` as a path on the CURRENT drive (or, on
3.13+, as relative to the event cwd) — `C:\\c\\Users\\...`, which does not
exist. Measured 2026-09-27 under the ntpath simulation: `git -C /c/<repo>
merge feature` on a CLEAN repo was DENIED as "names a repository this
guard cannot resolve" while `-C C:/<repo>` allowed. A false deny, not a
hole, but it refused ordinary Git Bash usage. Only on Windows: on POSIX
`/c/...` is a real path. Other MSYS roots (`/tmp`, `/usr`) still fail
closed; mapping those needs `cygpath`.

Returns None when the fold cannot be anchored or cannot be computed; the
caller must treat that as an unanswered question, never as the event cwd."""
cur = default_cwd
for raw in dash_c:
if not raw: # `-C ""` — git leaves cwd alone
continue
p = os.path.expanduser(raw)
if os.name == "nt":
m = _MSYS_DRIVE.match(p) # `/c/x` → `C:/x`, as MSYS hands it on
if m:
p = f"{m.group(1).upper()}:{p[m.end():] or '/'}"
try:
if os.path.isabs(p):
cur = os.path.realpath(p) # absolute: replaces what came before
Expand Down Expand Up @@ -1552,7 +1633,8 @@ def read(cwd: str | None) -> "str | _NotARepo | None":
# Heredoc bodies are dropped FIRST (they are line-oriented: joining a body
# line that ends in `\` could hide the terminator), then continuations are
# spliced, so a multi-line command is tokenised as the one command bash runs.
spliced = _join_continuations(_strip_heredocs(cmd))
# Carriage returns go before either, as Git Bash drops them (r21).
spliced = _join_continuations(_strip_heredocs(_drop_cr(cmd)))
segs = _segments(spliced)
for seg in segs:
sub, dash_c, at, words, selectors = _git_segment(seg)
Expand Down Expand Up @@ -1639,8 +1721,13 @@ def read(cwd: str | None) -> "str | _NotARepo | None":


def main() -> int:
# The event is read as BYTES and decoded as UTF-8, which is what Claude Code
# writes. `json.load(sys.stdin)` decoded it with the Windows ANSI code page:
# an accented project path came out as mojibake, the event cwd named a
# directory that does not exist, and a merge over a dirty tree read as
# "not a repository" and was allowed (r21).
try:
data = json.load(sys.stdin)
data = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
except (json.JSONDecodeError, EOFError):
return 0

Expand All @@ -1660,7 +1747,7 @@ def main() -> int:

if tool in ("Write", "Edit", "MultiEdit"):
fp = ti.get("file_path", "") or ""
if Path(fp).suffix not in CODE_EXT:
if Path(fp).suffix.lower() not in CODE_EXT:
return 0
# Collect every string that becomes file content: Write.content,
# Edit.new_string, and EACH MultiEdit edit's new_string (a list).
Expand Down
Loading
Loading