diff --git a/README.md b/README.md
index 0ef05aa..5383e80 100644
--- a/README.md
+++ b/README.md
@@ -17,17 +17,15 @@ on GitHub, against commits that already exist. There is nothing to skip.
| **AI attribution** | **every commit reachable from the branch** (default) | any commit's message, author or committer carries an attribution: `Co-authored-by:` naming an assistant, a "Generated by/with β¦" banner, the π€ footer, an `noreply@anthropic.com` identity, "AI-assisted", a `Plan:` footer |
| **Format** | the commits this push/PR introduces (merges skipped) | a subject is not `type(scope)!: subject` with a conventional type |
| **Secrets** | lines this push/PR *adds*, outside test/fixture paths | a line looks like a credential (AWS key, GitHub token, private-key header, a quoted `password` assignment, β¦) |
-| **Agent artefacts** | **every path tracked in the tree at the head** | an AI agent's tooling configuration is committed: `.claude/`, `.cursor/`, `.cursorrules`, `.windsurf/`, `.codex/`, `.gemini/`, `.aider.*`, `.roo/`, `.cline/`, `.goose/`, `.amazonq/`, `.kiro/`, `.specstory/`, `.mcp.json`, β¦ |
+| **Agent artefacts** | **every path tracked in the tree at the head** | an agent's *output* is committed: `.claude/logs/`, `.claude/plans/`, `.claude/.credentials.json`, `settings.local.json`, `.aider.chat.history.md`, `.specstory/history/`, session/cache/lock files. An agent's *configuration* is source and never matches |
Default attribution patterns match the **shape of an attribution**, not the
mention of a vendor. Measured on `devcontainer-template`'s real history, a
bare `.claude/` rule matches 25 of the last 60 legitimate commits. Keyword
rules exist but are opt-in (`strict: true`).
-That caution is about **prose**, and the artefact check is the other side of
-it. A commit message naming `.claude/` is ordinary work; the string
-`.claude/settings.json` in the tree *is* the thing. Same characters, no
-ambiguity to be fair to β so paths are a default while keywords are not.
+The artefact check applies the same discipline to paths: match the shape of
+the artefact, and check it against real history before making it a default.
**Deliberately not here: lint, build, test.** Every repository's own CI
already runs those on GitHub, so `--no-verify` never bypassed them.
@@ -70,44 +68,64 @@ The gate reports and never rewrites; see [Rewriting history](#rewriting-history)
Set `history: range` to only check the commits a push/PR introduces.
-### Agent artefacts: the tree, and a cheap way out
-
-The artefact check reads the **tree at the head**, not the range. That is the
-whole point of it: the `.claude/` that prompted the rule was merged into a
-trunk long before the rule existed, and a check scoped to what a change *adds*
-would call every later pull request clean while the directory sat there. The
-gate stays red until the files are gone.
-
-It can afford that scope because gone is cheap here, and this is the one place
-the gate refuses something without demanding a rewrite:
+### Agent artefacts: configuration is source, exhaust is not
+
+An agent's configuration is **source**. `.claude/agents/`, `.claude/commands/`,
+`.claude/skills/`, `.mcp.json`, `.cursor/rules/`, `.clinerules` β someone
+authored those, reviewed them, and wants them shared with the next person to
+clone the repository. Refusing them would be refusing the work.
+
+What has no business in a repository is the **exhaust**: the session log, the
+chat transcript, the plan file, the lock, the cache, the personal override.
+Nobody reads it, nobody reviews it, it conflicts on every merge, and it carries
+whatever the session happened to touch.
+
+The line is drawn at **named runtime directories** β never at an extension,
+never at a keyword. Two measurements on the fleet say why:
+
+- `.devcontainer/images/.claude/agents/routing-table.jsonl` is tracked in **4**
+ repositories, and it is authored routing configuration. A rule reading "a
+ `.jsonl` under an agent directory is a log" would refuse source.
+- `.claude/sessions/.gitkeep` is tracked in **14**. The directory is shipped
+ empty on purpose by the devcontainer image, and the placeholder is what makes
+ it exist. `sessions/` is therefore not matched at all.
+
+One entry earns its place for a different reason. `.claude/.credentials.json`
+is Claude Code's OAuth token store, and the secrets check does not save you
+from it: that check reads only the lines a push *adds*, so a credentials file
+committed once is never looked at again β and a `sk-ant-oat01-β¦` token carries
+hyphens where the `sk-[a-zA-Z0-9]` pattern expects none. Here it stays red for
+as long as the file is tracked.
+
+**Not matched, by decision rather than by omission**: an agent's configuration
+β `.claude/agents/`, `commands/`, `skills/`, `docs/`, `scripts/`,
+`settings.json`, `.mcp.json`, `.cursorrules`, `.cursor/rules/`, `.clinerules`,
+`.aider.conf.yml` β plus the two measured cases above, editor configuration
+(`.vscode/`, `.idea/`, `.zed/`), `.devcontainer/`, and markdown instructions
+(`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`). Nothing is matched *because* it sits
+under an agent directory β the directory is legitimate; only the listed
+children are not. A log dropped inside
+`.devcontainer/images/.claude/` is still a log, though: no parent grants
+immunity, or "put it under `.devcontainer/`" becomes the way around the rule.
+
+### The tree, and a cheap way out
+
+The check reads the **tree at the head**, not the range. A check scoped to what
+a change *adds* would call every later pull request clean while the artefact sat
+in the trunk. The gate stays red until it is gone.
+
+It can afford that scope because gone is cheap, and this is the one place the
+gate refuses something without demanding a rewrite:
```sh
-git rm -r --cached .claude && echo '.claude/' >> .gitignore
-git commit -m "chore: untrack the agent configuration"
+git rm -r --cached .claude/logs && echo '.claude/logs/' >> .gitignore
+git commit -m "chore: untrack the session logs"
```
The files stay on your machine; the repository stops carrying them. Nothing
walks the ancestry, so that single commit ends it β unlike an attribution,
which lives in a commit and needs `scripts/rewrite-history.sh`.
-**Not matched, by decision rather than by omission.** Editor configuration β
-`.vscode/`, `.idea/`, `.zed/` β is untouched: an editor is not an agent, and a
-repository's editor settings long predate all of this. Neither is
-`.devcontainer/` itself, which humans and CI read too. Neither are markdown
-instructions (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`): 21 of the fleet's 55
-repositories carry one, and whether that prose belongs in a repository is an
-editorial call a gate is the wrong place to make.
-
-Read that claim precisely: nothing is matched *because* it sits under
-`.devcontainer/`. An agent directory nested there is still an agent directory β
-the fleet's own template ships one at `.devcontainer/images/.claude/` β and
-exempting a parent would make "put it under `.devcontainer/`" the way around
-every rule in the file.
-
-A repository whose purpose *is* to distribute this configuration exempts the
-exact paths it ships with `agent_files_allow`, rather than by weakening the
-list everyone else runs.
-
## Install
`.github/workflows/post-commit.yml` in the target repo (`stub/post-commit.yml` here):
@@ -144,8 +162,8 @@ future fix. Don't pin it, don't copy the logic in.
| `strict` | `false` | also apply `scripts/patterns-strict.txt` (vendor keywords, `.claude/` paths) |
| `format` | `true` | conventional-commit subjects |
| `secrets` | `true` | credential scan on added lines |
-| `agent_files` | `true` | refuse an agent's tooling configuration tracked in the tree |
-| `agent_files_allow` | *(empty)* | paths exempt from that check, space or comma separated. A bare entry exempts the whole subtree: `.claude` covers `.claude/settings.json` |
+| `agent_files` | `true` | refuse an agent's session exhaust tracked in the tree; its configuration is never matched |
+| `agent_files_allow` | *(empty)* | paths exempt from that check, space or comma separated. A bare entry exempts the whole subtree: `.claude/logs` covers everything under it |
| `token` | `github.token` | checkout token |
## Making it mandatory β and irremovable
@@ -230,7 +248,7 @@ action.yml the action (composite): checkout β resolve β g
scripts/post-commit.sh the gate
scripts/patterns.txt default forbidden patterns (attribution-shaped)
scripts/patterns-strict.txt opt-in keyword patterns
-scripts/agent-paths.txt agent tooling paths (.claude/, .cursor/, β¦); editor config kept
+scripts/agent-paths.txt agent RUNTIME paths (logs, transcripts, caches); config kept
scripts/enforce.sh fleet: stub PR + ruleset, idempotent
scripts/rewrite-history.sh history scrub (messages + identities), dry-run by default
stub/post-commit.yml the file installed in each repo
diff --git a/action.yml b/action.yml
index 6d721bc..aa2f4b0 100644
--- a/action.yml
+++ b/action.yml
@@ -5,8 +5,8 @@
name: post-commit
description: >-
Mandatory merge gate: every commit authored by an allowed account, no AI
- attribution anywhere in history, no agent configuration tracked in the tree,
- conventional commit subjects, no credentials added. Logic lives in
+ attribution anywhere in history, no agent session exhaust tracked in the
+ tree, conventional commit subjects, no credentials added. Logic lives in
kodflow/post-commit β
callers pin @main so a fix there is live everywhere on the next run.
branding:
@@ -38,19 +38,20 @@ inputs:
default: 'true'
agent_files:
description: >-
- Refuse an AI agent's tooling configuration tracked in the repository β
- .claude/, .cursor/, .windsurf/, .aider.*, .codex/, β¦ (full list:
- scripts/agent-paths.txt). The tree at the head is what is read, not the
- range, so the check stays red until the files are gone; one `git rm
- --cached` clears it, no history rewrite. Editor configuration (.vscode/,
- .idea/), .devcontainer/ and markdown instructions (CLAUDE.md, AGENTS.md)
- are never matched.
+ Refuse what an AI agent WRITES when it is tracked in the repository β
+ .claude/logs/, .claude/plans/, settings.local.json, .aider.chat.history.md,
+ .specstory/history/, session and cache directories (full list:
+ scripts/agent-paths.txt). An agent's CONFIGURATION is source and is never
+ matched: .claude/agents/, commands/, skills/, settings.json, .mcp.json,
+ .cursorrules and the rest. The tree at the head is what is read, not the
+ range, so the check stays red until the exhaust is gone; one `git rm
+ --cached` clears it, no history rewrite.
default: 'true'
agent_files_allow:
description: >-
- Space/comma separated paths exempt from the agent-artefact check, for a
- repository whose purpose is to distribute that configuration. A bare
- entry exempts the whole subtree: `.claude` covers `.claude/settings.json`.
+ Space/comma separated paths exempt from the agent-artefact check. A bare
+ entry exempts the whole subtree: `.claude/logs` covers everything under
+ it. Rarely needed now that configuration is allowed by default.
default: ''
token:
description: Token used to check out the calling repository.
diff --git a/scripts/agent-paths.txt b/scripts/agent-paths.txt
index 4219491..98faa77 100644
--- a/scripts/agent-paths.txt
+++ b/scripts/agent-paths.txt
@@ -4,102 +4,98 @@
# Single source of truth for the fleet: a rule added here is live in every
# repo pinned to kodflow/post-commit@main on its next run.
#
-# DESIGN β a tracked PATH, not a mention. `.claude/` had to stay opt-in in
-# patterns.txt for a precise reason: a commit MESSAGE naming it is ordinary
-# work ("fix: update .claude/scripts/β¦"), and 25 of devcontainer-template's
-# last 60 legitimate subjects match. A tracked path is the opposite case. The
-# string `.claude/settings.json` in the tree IS the artefact; there is nothing
-# to misread, no prose to be fair to. Same characters, no false-positive
-# problem β so this file is a default while patterns-strict.txt is not.
+# WHAT THIS REFUSES β what an agent WRITES, never what a human wrote for it.
#
-# The remedy differs too, and that is why the scope can afford to be the whole
-# tree. An attribution lives in a commit and only a history rewrite removes it.
-# A tracked file is removed by removing it:
+# An agent's configuration is source. `.claude/agents/`, `.claude/commands/`,
+# `.claude/skills/`, `.mcp.json`, `.cursor/rules/`, `.clinerules` β someone
+# authored those, reviewed them, and wants them shared with the next person to
+# clone the repository. Refusing them would be refusing the work.
#
-# git rm -r --cached .claude && echo '.claude/' >> .gitignore && commit
+# What has no business in a repository is the exhaust: the session log, the
+# chat transcript, the plan file, the lock, the cache, the personal override.
+# Nobody reads it, nobody reviews it, it conflicts on every merge and it leaks
+# whatever the session happened to touch. That is what these patterns match.
#
-# The check reads the tree at the head, never the ancestry, so that one commit
-# clears it for good.
+# The line is therefore drawn at NAMED RUNTIME DIRECTORIES, never at an
+# extension and never at a keyword. Two measurements on the fleet say why:
#
-# SCOPE β an agent's tooling configuration. Deliberately NOT matched, and by
-# decision rather than by omission:
+# Β· `.devcontainer/images/.claude/agents/routing-table.jsonl` is tracked in
+# 4 repositories. It is authored routing configuration. A rule reading
+# "a .jsonl under an agent directory is a log" would refuse source.
+# Β· `.claude/sessions/.gitkeep` is tracked in 14. The directory is shipped
+# empty on purpose by the devcontainer image, and the placeholder is what
+# makes it exist. `sessions/` is therefore NOT matched β should a real
+# session file ever land there, name it here rather than the directory.
#
-# Β· .vscode/ .idea/ .zed/ .fleet/ .helix/ β editor configuration. An editor
-# is not an agent, and a repository's editor settings long predate all of
-# this. Whatever else changes here, these stay out.
-# Β· .devcontainer/ β the environment definition. Humans and CI read it too.
-# Note the exact claim: nothing is matched BECAUSE it sits under
-# .devcontainer/. An agent directory nested there is still an agent
-# directory β the fleet's own template ships one at
-# .devcontainer/images/.claude/ β and exempting a parent would make
-# "put it under .devcontainer/" the way around every rule in this file.
-# Β· CLAUDE.md AGENTS.md GEMINI.md .github/copilot-instructions.md β markdown
-# instructions read as project documentation, and 21 fleet repositories
-# carry one. Whether that prose belongs in a repository is an editorial
-# call; a gate is the wrong place to make it.
+# Same discipline as patterns.txt, one level down: match the shape of the
+# artefact, not the mention of a vendor, and check it against real history
+# before making it a default.
#
-# A repository whose purpose IS to distribute this configuration β
-# devcontainer-template β exempts itself with the `agent_files_allow` input
-# rather than by weakening the list here.
+# DELIBERATELY NOT MATCHED, and by decision rather than by omission:
+#
+# Β· `.claude/agents/` `commands/` `skills/` `docs/` `scripts/` `templates/`
+# `workflows/` `settings.json` `.claude.json` `features.json` β authored.
+# Β· `.mcp.json`, `.cursor/`, `.cursorrules`, `.windsurfrules`, `.roomodes`,
+# `.clinerules`, `.aider.conf.yml`, `.codex/`, `.gemini/`, `.kiro/` β
+# configuration. An agent's config is as legitimate as an editor's.
+# Β· `CLAUDE.md` `AGENTS.md` `GEMINI.md` β project documentation.
+# Β· `.vscode/` `.idea/` `.zed/` `.devcontainer/` β not agent files at all.
+#
+# Nothing is matched BECAUSE it sits under an agent directory. The directory
+# is legitimate; only these specific children are not.
+#
+# The remedy is unchanged and cheap β this check reads the tree at the head,
+# never the ancestry, so one commit ends it:
+#
+# git rm -r --cached .claude/logs && echo '.claude/logs/' >> .gitignore
-# --- Claude Code ------------------------------------------------------------
-(^|/)\.claude/
-(^|/)\.claude\.json$
-(^|/)\.claudeignore$
-# The project-level MCP server list: agent tooling by definition. Bare
-# `mcp.json` is NOT matched β it is the ordinary config file name of every
-# repository that implements an MCP server, several of which are ours.
-(^|/)\.mcp\.json$
-# --- Cursor -----------------------------------------------------------------
-(^|/)\.cursor/
-(^|/)\.cursorrules$
-(^|/)\.cursorignore$
-(^|/)\.cursorindexingignore$
-# --- OpenAI Codex -----------------------------------------------------------
-(^|/)\.codex/
-# --- GitHub Copilot ---------------------------------------------------------
-(^|/)\.copilot/
-# --- Gemini CLI / Code Assist -----------------------------------------------
-(^|/)\.gemini/
-(^|/)\.aiexclude$
-# --- Windsurf / Codeium -----------------------------------------------------
-(^|/)\.windsurf/
-(^|/)\.windsurfrules$
-(^|/)\.codeium/
-(^|/)\.codeiumignore$
-# --- aider ------------------------------------------------------------------
-# One pattern for the lot: aider prefixes every dropping it leaves behind β
-# .aider.chat.history.md, .aider.input.history, .aider.tags.cache.v3/,
-# .aider.conf.yml β with the same string.
-(^|/)\.aider[.-]
-(^|/)\.aiderignore$
-# --- Cline / Roo Code / Kilo Code -------------------------------------------
-(^|/)\.cline/
-# .clinerules is a file in older versions and a directory in newer ones.
-(^|/)\.clinerules(/|$)
-(^|/)\.roo/
-(^|/)\.roomodes$
-(^|/)\.kilocode/
-# --- Continue ---------------------------------------------------------------
-(^|/)\.continue/
-# --- Goose ------------------------------------------------------------------
-(^|/)\.goose/
-(^|/)\.goosehints$
-# --- opencode / Crush / Trae / Junie / Augment ------------------------------
-(^|/)\.opencode/
-(^|/)\.crush/
-(^|/)\.trae/
-(^|/)\.junie/
-(^|/)\.augment/
-(^|/)\.augmentignore$
-# --- Amazon Q Developer / Kiro ----------------------------------------------
-(^|/)\.amazonq/
-(^|/)\.kiro/
-# --- Qodo / SpecStory / Devin / Tabnine / Plandex ---------------------------
-(^|/)\.qodo/
-(^|/)\.specstory/
-(^|/)\.devin/
-(^|/)\.tabnine/
-(^|/)\.plandex/
-# --- JetBrains AI -----------------------------------------------------------
-(^|/)\.aiignore$
+# --- Claude Code: what the tool writes into a project ------------------------
+# Observed on this fleet's own machines: logs/ and plans/ appear in every
+# working copy, scheduled_tasks.lock in the ones running scheduled work. The
+# rest are the user-level directories, refused here too because a stray copy
+# of one in a repository is the same exhaust.
+(^|/)\.claude/(logs|plans|todos|shell-snapshots|statsig|paste-cache|session-env|backups|downloads|projects|ide|chrome)/
+(^|/)\.claude/history\.jsonl$
+# The OAuth token store. Nothing in a repository has less business being there,
+# and the secrets check would not save you: it reads only the lines a push
+# ADDS, and a `sk-ant-oat01-β¦` token carries hyphens where its `sk-[a-zA-Z0-9]`
+# pattern expects none. Tracked once, this stays tracked and stays missed.
+(^|/)\.claude/\.credentials\.json$
+# Account and machine state the server pushes down β not configuration anyone
+# wrote, and meaningless in another checkout.
+(^|/)\.claude/(policy-limits|remote-settings)\.json$
+(^|/)\.claude/[^/]*\.lock$
+# A personal machine's overrides, which Claude Code itself keeps out of git.
+# Committing one imposes one developer's permissions on everybody.
+(^|/)\.claude/settings\.local\.json$
+# Timestamped backups the tool leaves beside a file it rewrote.
+(^|/)\.claude/[^/]*\.bak([-.][^/]*)?$
+# --- aider -------------------------------------------------------------------
+# The three transcripts, whose names are aider's documented defaults, and the
+# tag cache. `.aider.conf.yml`, `.aider.model.settings.yml` and
+# `.aider.model.metadata.json` are configuration and are deliberately absent.
+(^|/)\.aider\.chat\.history\.md$
+(^|/)\.aider\.input\.history$
+(^|/)\.aider\.llm\.history$
+# NO `$` HERE, ON PURPOSE. The cache directory carries the cache format
+# version β aider/repomap.py: `TAGS_CACHE_DIR = f".aider.tags.cache.v{CACHE_VERSION}"`,
+# currently v3 and v4 β so a bare `.aider.tags.cache` never exists on disk.
+# Anchoring this would match nothing at all and quietly retire the rule. A
+# test pins `.aider.tags.cache.v3/` for exactly that reason.
+(^|/)\.aider\.tags\.cache
+# --- SpecStory ---------------------------------------------------------------
+# Recording the chat into the repository is the product's whole purpose, which
+# makes it exactly the thing this rule exists to keep out.
+(^|/)\.specstory/history/
+# --- Continue ----------------------------------------------------------------
+(^|/)\.continue/(sessions|index|dev_data)/
+# --- Goose -------------------------------------------------------------------
+(^|/)\.goose/(sessions|logs)/
+# --- Amazon Q Developer / Codeium / Qodo -------------------------------------
+(^|/)\.amazonq/cache/
+(^|/)\.codeium/cache/
+(^|/)\.qodo/(cache|history)/
+# --- Cursor ------------------------------------------------------------------
+# Cursor keeps its chats out of the repository; only the local override lands
+# here, and it carries one machine's settings just like Claude Code's.
+(^|/)\.cursor/[^/]*\.local\.json$
diff --git a/scripts/post-commit.sh b/scripts/post-commit.sh
index 947ee16..e8958e6 100755
--- a/scripts/post-commit.sh
+++ b/scripts/post-commit.sh
@@ -26,13 +26,14 @@
# 4. format β conventional-commit subject on the range's non-merge
# commits (project convention, see devcontainer-template).
# 5. secrets β no credential-shaped ADDED lines in the range's diff.
-# 6. artefacts β no AI agent's tooling configuration TRACKED in the tree at
-# the head (.claude/, .cursor/, .aider.*, β¦). The tree and
-# not the range: the directory this rule exists to remove
-# was merged long before the rule existed, and a range check
-# would call every later pull request clean while it sat
-# there. Editor configuration is untouched β an editor is
-# not an agent.
+# 6. artefacts β nothing an AI agent WROTE is TRACKED in the tree at the
+# head: session logs, chat transcripts, plan files, locks,
+# caches, the credentials store, personal overrides. Its
+# CONFIGURATION is source and is never matched β
+# .claude/agents/, commands/, skills/, settings.json,
+# .mcp.json, .cursorrules. The tree and not the range: an
+# artefact merged before the rule existed would otherwise
+# leave every later pull request clean while it sat there.
#
# Deliberately NOT here: lint/build/test. Every repo's own CI already runs
# those server-side, so --no-verify never bypassed them in the first place.
@@ -273,11 +274,17 @@ if [ "$SECRETS" = "true" ] && [ -n "$RANGE" ]; then
fi
# --- 4. Agent artefacts (the tree at the head) -------------------------------
-# Scope is the tree, not the range, and that is the whole point. The `.claude/`
-# that prompted this rule was merged into a trunk months before the rule
-# existed; a range check sees only what a change adds, so every later pull
-# request would have been called clean while the directory sat there. Reading
-# the tracked paths means the gate stays red until it is actually gone.
+# What an agent WROTE, never what a human wrote for it. The configuration is
+# source β someone authored `.claude/agents/`, reviewed it, and wants the next
+# person who clones to have it. The exhaust is not: a session log, a
+# transcript, a plan, a lock, a cache, a credentials store, a personal
+# override. See agent-paths.txt for where exactly the line falls and for the
+# fleet measurements that put it there.
+#
+# Scope is the tree, not the range, and that is the point. The artefact that
+# prompted this rule was merged into a trunk long before the rule existed; a
+# range check sees only what a change adds, so every later pull request would
+# have been called clean while it sat there.
#
# Which it can afford to be, because gone is cheap here. A tainted commit
# message needs rewrite-history.sh and new SHAs for every descendant; a tracked
@@ -454,9 +461,9 @@ esc_prop() {
if [ "$AGENT_N" -gt 0 ]; then
echo "### β Agent artefacts tracked β $AGENT_N file(s)"
echo ""
- echo "An AI agent's tooling configuration does not belong in a repository."
- echo "This is the same rejection policy the attribution rules apply to commit"
- echo "messages, applied to what a change leaves on disk."
+ echo "These are files an agent WROTE β a session log, a transcript, a plan,"
+ echo "a lock, a cache, a personal override. Nobody reviews them, they conflict"
+ echo "on every merge, and they carry whatever the session happened to touch."
echo ""
shown=0
for root in ${AGENT_ROOT_ORDER[@]+"${AGENT_ROOT_ORDER[@]}"}; do
@@ -478,11 +485,11 @@ esc_prop() {
echo "> **No history rewrite is needed**: this check reads the tree at the head,"
echo "> not the ancestry, so one commit clears it."
echo ">"
- echo "> Editor configuration (\`.vscode/\`, \`.idea/\`), \`.devcontainer/\` itself and"
- echo "> markdown instructions (\`CLAUDE.md\`, \`AGENTS.md\`) are never matched β but an"
- echo "> agent directory nested inside one of them still is."
- echo "> A repository that exists to distribute this configuration exempts the"
- echo "> exact paths it ships with the \`agent_files_allow\` input."
+ echo "> The agent's **configuration is source and is never matched**:"
+ echo "> \`.claude/agents/\`, \`commands/\`, \`skills/\`, \`settings.json\`, \`.mcp.json\`,"
+ echo "> \`.cursorrules\` β and editor config and \`CLAUDE.md\` alongside them."
+ echo "> Only the listed runtime directories and filenames are refused, wherever"
+ echo "> they sit. \`agent_files_allow\` exempts a path if one is genuinely wanted."
echo ""
fi
} > "$REPORT_BODY"
diff --git a/tests/run.sh b/tests/run.sh
index 9af3da7..7f6ea04 100755
--- a/tests/run.sh
+++ b/tests/run.sh
@@ -19,6 +19,12 @@ mkrepo() {
git -C "$d" config user.name "Dev"
git -C "$d" config commit.gpgsign false
git -C "$d" config core.hooksPath /dev/null # immune to the host's global hooks
+ # β¦and to its global gitignore. This machine's carries
+ # `**/.claude/settings.local.json`, so three cases silently tested nothing:
+ # the file was created, never tracked, and the gate correctly found an empty
+ # tree. A test repo must be built from what the case writes, not from what
+ # the developer running it happens to exclude.
+ git -C "$d" config core.excludesFile /dev/null
echo seed > "$d/seed.txt"
git -C "$d" add -A
git -C "$d" commit -qm "chore: seed"
@@ -343,81 +349,201 @@ d=$(mkrepo); mkdir -p "$d/tests"; echo 'AKIAIOSFODNN7EXAMPLE' > "$d/tests/fixtur
git -C "$d" add -A; git -C "$d" commit -qm "chore: config"
check "a real path still fails even with a fixture alongside" 1 "$d"
-echo "== agent artefacts =="
-# The tree, not the range. Every case below commits with a conventional
-# subject and an allowed identity so that exit 1 can only mean this check.
-d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: add agent config"
-check "a tracked .claude/ is refused" 1 "$d"
+echo "== agent artefacts: configuration is source, exhaust is not =="
+# The rule refuses what an agent WRITES, not what a human wrote for it. These
+# first cases are the ones a repository is entitled to carry.
+d=$(mkrepo); mkdir -p "$d/.claude/agents" "$d/.claude/commands" "$d/.claude/skills"
+echo '{}' > "$d/.claude/settings.json"; echo '# a' > "$d/.claude/agents/reviewer.md"
+echo '# c' > "$d/.claude/commands/ship.md"; echo '# s' > "$d/.claude/skills/deploy.md"
+echo '{}' > "$d/.mcp.json"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: agent configuration"
+check "settings, agents, commands, skills and .mcp.json are source" 0 "$d"
+
+# Both of these are real fleet content, and both would fall to a lazier rule:
+# routing-table.jsonl to "a .jsonl under an agent directory is a log" (tracked
+# in 4 repos), .gitkeep to matching `sessions/` (tracked in 14).
+d=$(mkrepo); mkdir -p "$d/.claude/agents" "$d/.claude/sessions"
+echo '{"a":1}' > "$d/.claude/agents/routing-table.jsonl"
+: > "$d/.claude/sessions/.gitkeep"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: authored jsonl and an empty session dir"
+check "an authored .jsonl and a sessions placeholder are kept" 0 "$d"
+
+d=$(mkrepo); mkdir -p "$d/.cursor/rules"
+echo 'be nice' > "$d/.cursorrules"; echo '---' > "$d/.cursor/rules/style.mdc"
+echo 'model: gpt' > "$d/.aider.conf.yml"; echo '{}' > "$d/.roomodes"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: other tools' configuration"
+check "every other tool's configuration is kept too" 0 "$d"
-d=$(mkrepo); mkdir -p "$d/.vscode"; echo '{}' > "$d/.vscode/settings.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: editor settings"
-check ".vscode/ is editor config, not an agent" 0 "$d"
-
-d=$(mkrepo); mkdir -p "$d/.idea" "$d/.devcontainer"
-echo '' > "$d/.idea/workspace.xml"; echo '{}' > "$d/.devcontainer/devcontainer.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: editor and environment"
-check ".idea/ and .devcontainer/ are kept" 0 "$d"
-
-# The scope decision of #21: markdown instructions read as project
-# documentation and 21 fleet repos carry one, so they are deliberately out.
d=$(mkrepo); echo '# instructions' > "$d/CLAUDE.md"; echo '# agents' > "$d/AGENTS.md"
-git -C "$d" add -A; git -C "$d" commit -qm "docs: project instructions"
-check "CLAUDE.md and AGENTS.md are tolerated as documentation" 0 "$d"
+mkdir -p "$d/.vscode" "$d/.idea" "$d/.devcontainer"
+echo '{}' > "$d/.vscode/settings.json"; echo '' > "$d/.idea/workspace.xml"
+echo '{}' > "$d/.devcontainer/devcontainer.json"
+git -C "$d" add -A; git -C "$d" commit -qm "docs: instructions and editor config"
+check "documentation and editor config are untouched" 0 "$d"
+
+echo "== agent artefacts: the exhaust (expect 1) =="
+d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{"e":1}' > "$d/.claude/logs/session.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log"
+check "a session log is refused" 1 "$d"
+
+d=$(mkrepo); mkdir -p "$d/.claude/plans"; echo '# plan' > "$d/.claude/plans/thing.md"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: a plan file"
+check "a plan file is refused" 1 "$d"
+
+# One developer's permissions, imposed on everybody who clones.
+d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.local.json"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: local overrides"
+check "a personal settings.local.json is refused" 1 "$d"
+
+d=$(mkrepo); mkdir -p "$d/.claude"; : > "$d/.claude/scheduled_tasks.lock"
+echo '{}' > "$d/.claude/history.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: a lock and a history"
+check "a lock file and history.jsonl are refused" 1 "$d"
+
+# The worst thing a `.claude/` can carry, and the one the secrets check cannot
+# save you from: it reads only the lines a push ADDS, so a credentials file
+# committed once is never looked at again.
+d=$(mkrepo); mkdir -p "$d/.claude"; echo '{"t":"x"}' > "$d/.claude/.credentials.json"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: oauth token store"
+check "a committed credentials file is refused" 1 "$d"
-d=$(mkrepo); echo 'be nice' > "$d/.cursorrules"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: cursor rules"
-check "a dotfile agent config is refused too" 1 "$d"
+d=$(mkrepo); mkdir -p "$d/.claude"
+echo '{}' > "$d/.claude/policy-limits.json"; echo '{}' > "$d/.claude/remote-settings.json"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: machine state"
+check "server-pushed machine state is refused" 1 "$d"
d=$(mkrepo); echo 'chat' > "$d/.aider.chat.history.md"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: leftovers"
-check "aider drops its history with a known prefix" 1 "$d"
-
-d=$(mkrepo); mkdir -p "$d/packages/app/.claude"; echo '{}' > "$d/packages/app/.claude/x.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: nested agent config"
-check "a nested .claude/ is found, not just the root one" 1 "$d"
-
-# The whole point of reading the tree: the artefact arrives in an ancestor and
-# the range that adds it is long merged. A range-scoped check would call this
-# clean, which is exactly how the directory that prompted the rule survived.
-d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: aider transcript"
+check "aider's transcript is refused while its conf is not" 1 "$d"
+
+# aider names the cache directory after the cache FORMAT VERSION
+# (`TAGS_CACHE_DIR = f".aider.tags.cache.v{CACHE_VERSION}"`), so a bare
+# `.aider.tags.cache` never exists. Anchoring that pattern with `$` β which
+# reads like an obvious tightening β would match nothing and retire the rule.
+d=$(mkrepo); mkdir -p "$d/.aider.tags.cache.v3"; echo 'x' > "$d/.aider.tags.cache.v3/cache.db"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: aider tag cache"
+check "the version-suffixed tag cache is refused" 1 "$d"
+
+d=$(mkrepo); mkdir -p "$d/.specstory/history"; echo 'chat' > "$d/.specstory/history/2026-01-01.md"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: recorded chat"
+check "a recorded chat transcript is refused" 1 "$d"
+
+d=$(mkrepo); mkdir -p "$d/.continue/sessions" "$d/.goose/logs"
+echo '{}' > "$d/.continue/sessions/a.json"; echo 'x' > "$d/.goose/logs/a.log"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: other tools' exhaust"
+check "other tools' session and log directories are refused" 1 "$d"
+
+# The devcontainer image carries a whole .claude/ tree; a log dropped inside it
+# is still a log, and nothing is spared for sitting under .devcontainer/.
+d=$(mkrepo); mkdir -p "$d/.devcontainer/images/.claude/logs" "$d/.devcontainer/images/.claude/agents"
+echo '# a' > "$d/.devcontainer/images/.claude/agents/x.md"
+echo '{}' > "$d/.devcontainer/images/.claude/logs/session.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: image payload with a stray log"
+check "a log nested in the devcontainer payload is still refused" 1 "$d"
+
+echo "== agent artefacts: one case per pattern, both ways =="
+# Exhaustive by construction rather than by discipline. Every pattern in
+# agent-paths.txt gets a representative path here, and the assertion names the
+# ones that went dark β so adding a pattern without a case, or breaking one
+# nobody happened to exercise, fails loudly instead of quietly.
+REFUSED=(
+ .claude/logs/a.jsonl .claude/plans/p.md .claude/todos/t.json
+ .claude/shell-snapshots/s.sh .claude/statsig/s.json .claude/paste-cache/p
+ .claude/session-env/e .claude/backups/b .claude/downloads/d
+ .claude/projects/p .claude/ide/i .claude/chrome/c
+ .claude/history.jsonl .claude/.credentials.json
+ .claude/policy-limits.json .claude/remote-settings.json
+ .claude/scheduled_tasks.lock .claude/settings.local.json
+ .claude/settings.json.bak-20260101
+ .aider.chat.history.md .aider.input.history .aider.llm.history
+ .aider.tags.cache.v3/cache.db
+ .specstory/history/2026-01-01.md
+ .continue/sessions/a.json .continue/index/a .continue/dev_data/a
+ .goose/sessions/a .goose/logs/a.log
+ .amazonq/cache/a .codeium/cache/a .qodo/cache/a .qodo/history/a
+ .cursor/mcp.local.json
+)
+d=$(mkrepo)
+for f in "${REFUSED[@]}"; do mkdir -p "$d/$(dirname "$f")"; echo x > "$d/$f"; done
+git -C "$d" add -A; git -C "$d" commit -qm "chore: one artefact per pattern"
+out="$(cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null bash "$GATE" HEAD HEAD~1..HEAD 2>&1)"
+missed=""
+for f in "${REFUSED[@]}"; do
+ printf '%s' "$out" | grep -qF "file=$f" || missed="${missed:+$missed }$f"
+done
+if [ -z "$missed" ]; then
+ PASS=$((PASS+1)); printf ' ok %s (%s paths)\n' "every pattern refuses its own artefact" "${#REFUSED[@]}"
+else
+ FAIL=$((FAIL+1)); printf ' FAIL %s β not matched: %s\n' "every pattern refuses its own artefact" "$missed"
+fi
+rm -rf "$d"
+
+# The other half of the contract, and the half that matters more: everything a
+# human authored for the agent stays. A pattern that grows a little too wide
+# lands here rather than on a repository's pull requests.
+ALLOWED=(
+ .claude/agents/reviewer.md .claude/agents/routing-table.jsonl
+ .claude/commands/ship.md .claude/skills/deploy.md .claude/docs/pattern.md
+ .claude/scripts/hook.sh .claude/templates/t.tpl .claude/workflows/w.yml
+ .claude/settings.json .claude/.claude.json .claude/features.json
+ .claude/sessions/.gitkeep .claude/db
+ .mcp.json .cursorrules .cursor/rules/style.mdc
+ .aider.conf.yml .aider.model.settings.yml .aider.model.metadata.json
+ .roomodes .clinerules .windsurfrules .codex/config.toml .gemini/settings.json
+ CLAUDE.md AGENTS.md GEMINI.md
+ .vscode/settings.json .idea/workspace.xml .devcontainer/devcontainer.json
+)
+d=$(mkrepo)
+for f in "${ALLOWED[@]}"; do mkdir -p "$d/$(dirname "$f")"; echo x > "$d/$f"; done
+git -C "$d" add -A; git -C "$d" commit -qm "chore: everything a human authored"
+check "no authored file is ever refused (${#ALLOWED[@]} paths)" 0 "$d"
+
+echo "== agent artefacts: scope, exemptions and reporting =="
+# The tree, not the range: the artefact arrives in an ancestor and the change
+# that added it is long merged. A range-scoped check would call this clean.
+d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log"
commit_msg "$d" "feat: unrelated later work"
check "an artefact merged earlier still fails a later clean change" 1 "$d" PC_HISTORY=range
-# β¦and removing it is enough. No history rewrite, unlike an attribution.
-d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config"
-git -C "$d" rm -qr .claude; git -C "$d" commit -qm "chore: drop the agent config"
+d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log"
+git -C "$d" rm -qr .claude/logs; git -C "$d" commit -qm "chore: untrack the logs"
check "removing it in one commit clears the check" 0 "$d"
-d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config"
+d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log"
check "the check is switchable" 0 "$d" PC_AGENT_FILES=false
-# A repository whose purpose is to distribute this config exempts its paths.
-d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config"
-check "a bare allow entry exempts the whole subtree" 0 "$d" PC_AGENT_ALLOW=.claude
+d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log"
+check "a bare allow entry exempts the whole subtree" 0 "$d" PC_AGENT_ALLOW=.claude/logs
-d=$(mkrepo); mkdir -p "$d/.claude" "$d/.cursor"
-echo '{}' > "$d/.claude/settings.json"; echo '{}' > "$d/.cursor/rules.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: two agents"
-check "an allow entry exempts only what it names" 1 "$d" PC_AGENT_ALLOW=".claude/*"
+# `.claude/logs/*` must cover hidden files too. An unquoted expansion would
+# glob it against the working tree first, and filename globbing skips leading
+# dots β so this passed for a.jsonl and quietly failed for .hidden.jsonl.
+d=$(mkrepo); mkdir -p "$d/.claude/logs"
+echo '{}' > "$d/.claude/logs/a.jsonl"; echo '{}' > "$d/.claude/logs/.hidden.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: logs including a hidden one"
+check "a glob allow entry covers hidden files too" 0 "$d" PC_AGENT_ALLOW=".claude/logs/*"
+
+d=$(mkrepo); mkdir -p "$d/.claude/logs" "$d/.claude/plans"
+echo '{}' > "$d/.claude/logs/a.jsonl"; echo '#' > "$d/.claude/plans/p.md"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: logs and plans"
+check "an allow entry exempts only what it names" 1 "$d" PC_AGENT_ALLOW=".claude/logs/*"
# git C-quotes a path holding a non-ASCII or control character unless asked for
# NUL-delimited output, and the quote it adds lands exactly where `(^|/)` and
-# `$` need a path boundary. Before the fix these three were reported clean β
-# naming every file in `.claude/` with an accent evaded the whole rule.
-d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/naΓ―ve.md"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: accented name inside the artefact"
+# `$` need a path boundary. Before that fix these were reported clean.
+d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/naΓ―ve.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: accented log name"
check "a non-ASCII file name does not evade the scan" 1 "$d"
-d=$(mkrepo); mkdir -p "$d/dΓ©jΓ "; echo '{}' > "$d/dΓ©jΓ /.mcp.json"
+d=$(mkrepo); mkdir -p "$d/dΓ©jΓ /.claude"; echo '{}' > "$d/dΓ©jΓ /.claude/settings.local.json"
git -C "$d" add -A; git -C "$d" commit -qm "chore: anchored artefact below a non-ASCII parent"
check "an end-anchored pattern still matches below a non-ASCII parent" 1 "$d"
-d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/$(printf 'two\nlines').md"
+d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/$(printf 'two\nlines').jsonl"
git -C "$d" add -A; git -C "$d" commit -qm "chore: newline in the file name"
check "a newline in the file name does not split a record" 1 "$d"
@@ -425,9 +551,9 @@ check "a newline in the file name does not split a record" 1 "$d"
# array subscript, a [[ ]] operand and an annotation. `declare -A` is what makes
# the subscript a string rather than an arithmetic expression; this is here so
# that dropping the -A, or reaching for an indexed array, fails loudly.
-d=$(mkrepo); mkdir -p "$d/a\$(touch PWNED).d" "$d/c];touch PWNED3;x[.d"
-printf 'x' > "$d/a\$(touch PWNED).d/.mcp.json"
-printf 'x' > "$d/c];touch PWNED3;x[.d/.mcp.json"
+d=$(mkrepo); mkdir -p "$d/a\$(touch PWNED).d/.claude" "$d/c];touch PWNED3;x[.d/.claude"
+printf 'x' > "$d/a\$(touch PWNED).d/.claude/settings.local.json"
+printf 'x' > "$d/c];touch PWNED3;x[.d/.claude/settings.local.json"
git -C "$d" add -A; git -C "$d" commit -qm "chore: hostile parent names"
( cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null bash "$GATE" HEAD HEAD~1..HEAD ) >/dev/null 2>&1
rc=$?
@@ -443,25 +569,15 @@ else
fi
rm -rf "$d"
-# `.claude/*` must exempt what is under `.claude/`, hidden files included. An
-# unquoted expansion would glob it against the working tree first, and bash's
-# filename globbing skips leading dots β so this passed for settings.json and
-# quietly failed for .mcp.json next to it.
-d=$(mkrepo); mkdir -p "$d/.claude"
-echo '{}' > "$d/.claude/settings.json"; echo '{}' > "$d/.claude/.mcp.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config with a hidden file"
-check "a glob allow entry covers hidden files too" 0 "$d" PC_AGENT_ALLOW=".claude/*"
-
-# The root is found with the pattern list, so the pattern list has to reach awk
-# intact. On an awk that strips the escape from a -v value, `\.` becomes a bare
-# `.`, `aclaude/` matches `(^|/).claude/`, and the verdict names an ordinary
-# directory as the thing to delete.
-d=$(mkrepo); mkdir -p "$d/aclaude/.claude"
-echo '{}' > "$d/aclaude/.claude/settings.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config below a lookalike"
+# The root is found with the pattern list, so the pattern list has to reach the
+# matcher intact. With the escape stripped, `\.` becomes a bare `.`, `aclaude/`
+# matches `(^|/).claude/logs/`, and the verdict names an ordinary directory.
+d=$(mkrepo); mkdir -p "$d/aclaude/.claude/logs"
+echo '{}' > "$d/aclaude/.claude/logs/a.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: log below a lookalike"
r="$(mktemp)"
( cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null PC_REPORT="$r" bash "$GATE" HEAD HEAD~1..HEAD ) >/dev/null 2>&1
-if grep -qF '`aclaude/.claude/`' "$r" && ! grep -qF '`aclaude/`' "$r"; then
+if grep -qF '`aclaude/.claude/logs/`' "$r" && ! grep -qF '`aclaude/`' "$r"; then
PASS=$((PASS+1)); printf ' ok %s\n' "a directory whose name merely ends in the pattern is not the root"
else
FAIL=$((FAIL+1)); printf ' FAIL %s\n%s\n' "a directory whose name merely ends in the pattern is not the root" "$(sed 's/^/ /' "$r")"
@@ -469,26 +585,25 @@ fi
rm -rf "$d" "$r"
# The verdict names the artefact root, and getting that root wrong is worse
-# than not collapsing at all: the fleet's devcontainer ships its agent config
-# at .devcontainer/images/.claude/, and naming `.devcontainer/` as the thing to
-# delete would point at 400 files the gate has no quarrel with.
-d=$(mkrepo); mkdir -p "$d/.devcontainer/images/.claude"
-echo '{}' > "$d/.devcontainer/images/.claude/settings.json"
-echo '{}' > "$d/.devcontainer/devcontainer.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: devcontainer with agent config"
+# than not collapsing at all: the surrounding `.claude/` is legitimate here, so
+# naming it as the thing to delete would point at the agent's own source.
+d=$(mkrepo); mkdir -p "$d/.claude/logs" "$d/.claude/agents"
+echo '# a' > "$d/.claude/agents/x.md"
+for i in 1 2 3; do echo '{}' > "$d/.claude/logs/a$i.jsonl"; done
+git -C "$d" add -A; git -C "$d" commit -qm "chore: agent source beside its logs"
r="$(mktemp)"
( cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null PC_REPORT="$r" bash "$GATE" HEAD HEAD~1..HEAD ) >/dev/null 2>&1
-if grep -qF '`.devcontainer/images/.claude/`' "$r" && ! grep -qF '`.devcontainer/` β' "$r"; then
- PASS=$((PASS+1)); printf ' ok %s\n' "the reported root is the artefact, not the .devcontainer around it"
+if grep -qF '`.claude/logs/` β 3 file(s)' "$r" && ! grep -qF '`.claude/`' "$r"; then
+ PASS=$((PASS+1)); printf ' ok %s\n' "the reported root is the log directory, not the .claude around it"
else
- FAIL=$((FAIL+1)); printf ' FAIL %s\n%s\n' "the reported root is the artefact, not the .devcontainer around it" "$(sed 's/^/ /' "$r")"
+ FAIL=$((FAIL+1)); printf ' FAIL %s\n%s\n' "the reported root is the log directory, not the .claude around it" "$(sed 's/^/ /' "$r")"
fi
rm -rf "$d" "$r"
# The whole-repository scope (push, manual runs) is not a tree-ish; the check
# has to fall back to the checked-out head rather than fail the job.
-d=$(mkrepo); mkdir -p "$d/.claude"; echo '{}' > "$d/.claude/settings.json"
-git -C "$d" add -A; git -C "$d" commit -qm "chore: agent config"
+d=$(mkrepo); mkdir -p "$d/.claude/logs"; echo '{}' > "$d/.claude/logs/a.jsonl"
+git -C "$d" add -A; git -C "$d" commit -qm "chore: a session log"
out="$(cd "$d" && env GITHUB_STEP_SUMMARY=/dev/null bash "$GATE" --branches 2>&1)"; rc=$?
if [ "$rc" -eq 1 ] && printf '%s' "$out" | grep -q 'agent artefact'; then
PASS=$((PASS+1)); printf ' ok %s\n' "--branches scans the checked-out tree"
@@ -498,24 +613,24 @@ fi
rm -rf "$d"
# rewrite-history.sh keeps the artefact check off on both of its gate calls: it
-# rewrites messages and identities and never touches the tree, so a tracked
-# `.claude/` would make --execute refuse to push a correct rewrite. Running the
-# script itself needs a network mirror and git-filter-repo; what is guarded
-# here is the pairing that can silently come undone.
+# rewrites messages and identities and never touches the tree, so a tracked log
+# would make --execute refuse to push a correct rewrite. Running the script
+# itself needs a network mirror and git-filter-repo; what is guarded here is the
+# pairing that can silently come undone.
+#
# Counted on the invoking lines themselves, not on the file: the comment that
# explains the pairing names the variable too, and matching that would make the
-# guard pass on the explanation alone.
+# guard pass on the explanation alone. Two, not "at least one": the before scan
+# feeds the counters the report prints, the after scan is what --execute reads.
calls="$(grep -c 'post-commit.sh" --branches' "$ROOT/scripts/rewrite-history.sh")"
guarded="$(grep 'post-commit.sh" --branches' "$ROOT/scripts/rewrite-history.sh" | grep -c 'PC_AGENT_FILES=false')"
-# Two, not "at least one": the before scan feeds the tainted/identity counters
-# the report prints, and the after scan is what --execute consults before force
-# pushing. Losing either is a silent regression that `-gt 0` would wave through.
if [ "$calls" -eq 2 ] && [ "$guarded" -eq 2 ]; then
PASS=$((PASS+1)); printf ' ok %s\n' "every rewrite-history gate call disables the artefact check"
else
FAIL=$((FAIL+1)); printf ' FAIL %s (want 2 call(s) all guarded, got %s call(s), %s guarded)\n' "every rewrite-history gate call disables the artefact check" "$calls" "$guarded"
fi
+
echo "== usage errors (expect 2) =="
d=$(mkrepo)
out="$(cd "$d" && bash "$GATE" 2>&1)"; rc=$?