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
94 changes: 56 additions & 38 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**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):
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand Down
25 changes: 13 additions & 12 deletions action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -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.
Expand Down
Loading