Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .codearbiter/gate-events.log
Original file line number Diff line number Diff line change
Expand Up @@ -974,3 +974,12 @@ Claude-Session: https://claude.ai/code/session_015ZDVU1BzUqnnPVHbZ397bX') stages
[2026-07-20T05:54:34Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only.
[2026-07-20T05:54:34Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only.
[2026-07-20T05:54:34Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only.
[2026-07-26T13:31:49Z] BLOCK [H-03] hook=pre-bash.py | Wildcard staging is prohibited — a directory ('\') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill).
[2026-07-26T13:32:16Z] BLOCK [H-03] hook=pre-bash.py | Wildcard staging is prohibited — a directory ('\') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill).
[2026-07-26T13:35:19Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-26T13:36:07Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-26T13:39:29Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-26T13:39:44Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-26T13:49:34Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-26T13:49:39Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-26T13:49:48Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
17 changes: 7 additions & 10 deletions .github/scripts/check_site_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,13 @@
scope by being written into a tracked directory: if it is not committed, it is
not audited.

KNOWN GAP, stated rather than discovered later. The detector is line-based: it
requires word characters on both sides of the dash ON THE SAME LINE. A separator
dash that lands at a line-wrap boundary -
...it holds in one of three states —
listed below.
- is therefore invisible to it, and three such dashes were found by hand in
`codearbiter-directory.md` while fixing the flagged ones. They were fixed too,
but the gate did not catch them and would not catch a new one. Closing that
needs paragraph-level analysis rather than a per-line scan, which is a larger
change than this gate; it is filed separately rather than implied to be handled.
The line-wrap gap this gate shipped with is closed (#484). The detector used to
require word characters on both sides of the dash ON THE SAME LINE, so a
separator that landed at a soft-wrap boundary was invisible to it, and three
such dashes had to be found by hand in `codearbiter-directory.md`. It now scans
a paragraph at a time and attributes each finding to the line holding its dash,
with joining stopped at every block boundary (blank line, list item, heading,
table row, thematic break, fence) so a wrap is never confused for one.

Usage:
python .github/scripts/check_site_voice.py # audit, exit 1 on findings
Expand Down
23 changes: 23 additions & 0 deletions .github/scripts/test_hook_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,29 @@ def main():
expect_block(fx, "git commit -m 'add hashing'", "H-09b",
"H-09b block: crypto commit with no recorded pass")

# 6a-bis (#485): object-database maintenance is not a commit. In the
# exact state 6a just proved blocks a real commit, `git commit-graph`
# must pass -- it writes no objects, creates no commit, and can
# introduce neither a crypto nor a secret change. `commit\b` matched it
# anyway, because a word boundary sits between `commit` and `-`. The
# cost was not the annoyance: the gate told the operator to run the
# crypto-compliance gate for a command that touches no content, leaving
# only a pass that certifies nothing or an /ca:override invented to
# cover a coverage hole -- the habit ADR-0022 and #308 exist to prevent.
# It also bites at the worst moment, since a stale graph is what a batch
# of --delete-branch merges leaves behind.
expect_allow(fx, "git commit-graph write --reachable",
"#485 allow: git commit-graph is not git commit")
expect_allow(fx, "git commit-graph verify",
"#485 allow: commit-graph verify is read-only")
# The exclusion is exactly one proven-safe verb, so anything else in the
# `commit-*` space stays gated rather than failing open on a name this
# matcher has never seen.
expect_block(fx, "git commit-tree HEAD^{tree} -p HEAD", "H-09b",
"#485 block: commit-tree does create a commit object")
expect_block(fx, "git commit-graphish --sneak", "H-09b",
"#485 block: an unknown commit-* verb is still gated")

# 6b. legacy empty marker (the old `touch`) -> binding block.
# An empty marker proves only that *something* passed recently; it
# covers no lines, so the diff-bound check must reject it.
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,26 @@ predate the plugin rewrite and are grouped by date.

### Fixed

- `git commit-graph` is no longer gated as though it were `git commit` (#485).
A word boundary sits between `commit` and `-`, so the H-09b/H-10b matcher took
every `commit-*` verb. The cost was not the annoyance: the gate told the
operator to run the crypto-compliance gate for a command that writes no
objects and creates no commit, leaving only a pass that certifies nothing or
an `/ca:override` invented to cover a coverage hole. It bit at the worst
moment too, since a stale commit-graph is exactly what a batch of
`--delete-branch` merges leaves behind. `commit-tree` stays in scope
deliberately: it creates a commit object, and a crafted commit plus
`update-ref` is a real path around H-01. Anything else in the `commit-*` space
is still gated, so an unfamiliar verb fails closed rather than open.
- The prose-separator-dash detector reads a paragraph at a time instead of a
line at a time (#484). It required word characters on both sides of the dash
ON THE SAME LINE, so a separator that landed at a soft-wrap boundary scored
zero in both directions - the right-hand span on the next line, or the
left-hand span on the previous one. Three real violations in the site's own
pages had to be found by hand for that reason. Joining stops at every block
boundary (blank line, list item, heading, table row, thematic break, fence),
and each finding still carries the line of its own dash.

- A secret in a farm plan no longer persists in the run's permanent receipt.
`plan.meta` was serialized verbatim into `.farm/runs/<runId>/farm-report.json`
and its Markdown sibling, and the run-scoped change made that permanent: the
Expand Down
35 changes: 34 additions & 1 deletion core/pysrc/_bashguardlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,42 @@ def _commit_no_verify_in_cluster(args):
# `/ca:checkpoint)` made `git diff HEAD -- …` fatal ("outside repository"),
# failing the H-09b scan CLOSED on a clean commit.
ARGS = r"(?P<args>(?:\"[^\"]*\"|'[^']*'|[^|;&])*)"
COMMIT_RE = re.compile(GIT + r"\s+commit\b" + ARGS)
# The `commit` SUBCOMMAND, not every verb starting with it (#485). A word
# boundary sits between `commit` and `-`, so a bare `commit\b` matched
# `git commit-graph write` and gated object-database maintenance as though it
# were a commit — pointing the operator at the crypto-compliance gate for a
# command that writes no objects and creates no commit, whose only escapes were
# a pass certifying nothing or an override covering a coverage hole (the habit
# ADR-0022 and #308 exist to prevent). It bit at the worst moment too: a stale
# graph is what a batch of `--delete-branch` merges leaves behind.
#
# The exclusion is an explicit ALLOW of one proven-safe verb rather than an
# allowlist of gated ones, so the failure direction stays closed — a `commit-*`
# name this matcher has never seen is still gated. `commit-tree` deliberately
# stays in scope: it creates a commit object, and a crafted commit plus
# `update-ref` is a real path around H-01.
COMMIT_SUBCOMMAND = r"commit(?:-(?!graph(?![-\w]))[\w-]+)?(?![-\w])"
COMMIT_RE = re.compile(GIT + r"\s+" + COMMIT_SUBCOMMAND + ARGS)
PUSH_RE = re.compile(GIT + r"\s+push\b" + ARGS)
ADD_RE = re.compile(GIT + r"\s+add\b" + ARGS)
# #485 AC-4, the audit of the sibling matchers. Neither needs the same
# treatment, and both were left alone deliberately:
#
# push — git has no `push-*` subcommand, so `push\b` has nothing to
# over-match onto. If one ever ships, it is gated until reviewed.
# add — `git add--interactive` is a real plumbing helper and it DOES stage,
# so `add\b` reaching it is correct, not a false positive.
#
# What the audit DID surface is a different shape, and it is knowingly not
# fixed here: these matchers scan the command TEXT, so a git command quoted
# inside another command's arguments is gated as if it were being run — a
# `grep "git add"` trips H-03. Skipping quoted occurrences would be wrong, not
# merely conservative: `sh -c "git add ."` executes what it quotes, so the
# exemption would be a real bypass of the staging gate. Distinguishing the two
# needs actual shell tokenization, which is a larger change with a worse
# failure direction; over-blocking a mention is the fail-CLOSED side of that
# trade (ORCHESTRATOR §2: security before velocity). Tracked separately rather
# than half-fixed.
# reliability-004 (#190): GIT_C_DIR_RE previously matched `-C` ONLY as the
# FIRST token after `git`, so a global option in front of it (`--no-pager`,
# `-c k=v`, `--git-dir=…`) hid the -C target entirely and git_cwd() fell back
Expand Down
121 changes: 113 additions & 8 deletions core/pysrc/_sloplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,73 @@ def _prose_only(line):
def _segment_separates(seg):
"""True if `seg` contains an em/en dash with word characters on BOTH sides —
i.e. it joins two text spans (a prose separator), not a lone filler dash."""
return _separating_dash_in(seg)


# Block openers: a line matching any of these starts a new markdown block, so
# the line before it is NOT soft-wrapped into it (#484 AC-2). Joining across one
# of these would invent findings a reader never sees as a single sentence.
_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s")
_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s")
_BLOCKQUOTE_RE = re.compile(r"^\s*>")
_THEMATIC_RE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$")
# A range split BY the wrap: `12–` / `18`. _RANGE_RE only sees one line, so the
# same-line exemption cannot reach it and it has to be re-checked on the join.
_RANGE_LEFT_RE = re.compile(r"\d\s*$")
_RANGE_RIGHT_RE = re.compile(r"^\s*\d")


def _starts_new_block(raw):
"""True if `raw` opens a new markdown block rather than continuing the
previous line's paragraph. A `|` anywhere makes a line un-joinable: table
rows are scanned cell-by-cell, and joining one to its neighbour would give a
lone N/A marker the word context the cell split exists to deny it."""
return bool(not raw.strip()
or "|" in raw
or _FENCE_RE.match(raw)
or _HEADING_RE.match(raw)
or _LIST_ITEM_RE.match(raw)
or _THEMATIC_RE.match(raw)
or _BLOCKQUOTE_RE.match(raw))


def _separating_dash_in(head, *, words_before=False, words_after=False,
tail_before="", head_after=""):
"""True if a dash INSIDE `head` joins two text spans, where its left context
may reach back into the preceding lines of its paragraph and its right
context forward into the following ones.

#484: `_segment_separates` required word characters on both sides of the
dash on the SAME line, so a separator at a soft-wrap boundary scored zero —
in both directions. `…three states —` / `listed below.` has its right-hand
span on the next line; `A tribunal is the heavyweight audit` / `— checkpoints
are the lean sweep.` has its left-hand span on the previous one. Both were
real, unreported VOICE.md violations in the site's own pages.

Only dashes inside `head` are considered, so attribution stays on the line
that actually holds the dash (AC-1) no matter how wide the paragraph is.

The surrounding context arrives PRE-REDUCED rather than as joined text:
`words_before`/`words_after` are booleans the caller accumulates once per
paragraph, and `tail_before`/`head_after` are only the nearest neighbours,
which is all the numeric-range adjacency check can see. Building the joined
strings here instead was quadratic in paragraph length - 14x slower at 3000
lines, measured - and this runs on every write and edit through H-13."""
for d in _DASHES:
idx = seg.find(d)
idx = head.find(d)
while idx != -1:
if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]):
return True
idx = seg.find(d, idx + 1)
left, right = head[:idx], head[idx + 1:]
if ((words_before or _WORD_RE.search(left))
and (_WORD_RE.search(right) or words_after)):
# A range split BY the wrap: the digit adjacency lives on the
# neighbouring line, so fall back to it only when this line
# contributes nothing on that side.
near_left = left if left.strip() else tail_before
near_right = right if right.strip() else head_after
if not (_RANGE_LEFT_RE.search(near_left)
and _RANGE_RIGHT_RE.search(near_right)):
return True
idx = head.find(d, idx + 1)
return False


Expand All @@ -84,15 +145,59 @@ def find_prose_separator_dashes(text):
"""
findings = []
in_fence = False
for i, raw in enumerate(text.splitlines(), start=1):
lines = text.splitlines()
# Each paragraph is scanned as a unit so a separator that lands at a
# soft-wrap boundary is visible (#484), while every finding is still
# attributed to the line holding its dash. `_prose_only` runs PER LINE and
# before the join, so inline code, URLs, ranges and the definition-list
# lead-in are stripped exactly as they were.
index = 0
while index < len(lines):
raw = lines[index]
if _FENCE_RE.match(raw):
in_fence = not in_fence
index += 1
continue
if in_fence:
index += 1
continue
prose = _prose_only(raw)
if any(_segment_separates(seg) for seg in prose.split("|")):
findings.append({"line": i, "context": raw.strip()})
# The paragraph: this line, plus every following line that continues it.
group = [index]
following = index + 1
while following < len(lines) and not _starts_new_block(lines[following]):
group.append(following)
following += 1
prose = [_prose_only(lines[i]) for i in group]
# Reduce the paragraph's context to what the dash test actually needs,
# in one pass each direction. `word_before[k]` is "some EARLIER line in
# this paragraph has a word character", `word_after[k]` the same looking
# forward. It has to accumulate over the whole group rather than peek at
# the neighbour: a line that is entirely inline code strips to
# whitespace, so the real continuation can sit two lines from the dash.
word_before = [False] * len(prose)
for k in range(1, len(prose)):
word_before[k] = word_before[k - 1] or bool(_WORD_RE.search(prose[k - 1]))
word_after = [False] * len(prose)
for k in range(len(prose) - 2, -1, -1):
word_after[k] = word_after[k + 1] or bool(_WORD_RE.search(prose[k + 1]))
for position, i in enumerate(group):
head = prose[position]
if "|" in lines[i]:
# A table row: keep the cell-by-cell scan, and never let a
# neighbouring row lend it context.
hit = any(_segment_separates(cell) for cell in head.split("|"))
else:
hit = _separating_dash_in(
head,
words_before=word_before[position],
words_after=word_after[position],
tail_before=prose[position - 1] if position else "",
head_after=(prose[position + 1]
if position + 1 < len(prose) else ""),
)
if hit:
findings.append({"line": i + 1, "context": lines[i].strip()})
index = following
return findings


Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
"version": "0.1.29",
"version": "0.1.30",
"private": true,
"license": "AGPL-3.0-only",
"engines": {
Expand Down
4 changes: 4 additions & 0 deletions plugins/ca-codex/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ All notable changes to the **ca-codex** plugin are recorded here. Format follows
clear the intent is (ADR-0022).

### Fixed
- The shared guard and anti-slop libraries pick up the `git commit-graph`
mis-gating fix (#485) and the line-wrap-boundary detector fix (#484). Both are
core changes projected to this host unchanged.

- A session started inside a linked worktree no longer repoints the main
repository's git-level enforcement at a worktree path that is pruned out from
under it. The enforcer entry is shared through the git common dir, so an
Expand Down
Loading