diff --git a/AGENTS.md b/AGENTS.md index 2d09c98..b1d3bad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,111 +1,55 @@ - + ## Commit messages -When asked to "agent-commit" changes, format the commit message as: - -1. A normal, clear one-line summary, same as any good commit subject. -2. Optionally, a blank line and a body, same as any normal commit. -3. Optionally, if there's context the summary and body don't already carry - that would save the next agent from having to open the diff (a rejected - alternative, a non-obvious constraint, the real cause behind a fix), add - a single-line trailer as the very last part of the message: - - `Agent-Context: {"why":"...","refs":["TICKET-123"]}` - -Never invent a `why` just to fill the field: only write one if there's a -real, specific alternative, constraint, or cause behind it. If there isn't, -omit it. An absent `why` is honest; a fabricated one looks just as -authoritative and isn't. - -Common fields: `why` (reasoning not already in the subject/body, usually -the only field worth including), `touches` (files or components affected, -only when it's not obvious from the subject), `breaking` (only when true; -never write `false`, just omit it), `refs` (ticket/issue links). None are -required. Skip the trailer entirely if the summary line already says -everything worth saying, and never repeat the commit's type in the -trailer if the subject already has one (e.g. `fix(auth): ...`), that -just creates two copies that can disagree. - -Keep `touches` and `refs` in a consistent form, that's what makes later -lookups actually find things: repo-relative paths with no leading `./` or -`/` for `touches` (`src/auth`, not `auth` or `./src/auth`), and the -tracker's own short form for `refs` (`INFRA-441` or `#123`, never a full -URL, never both forms for the same tracker in one repo). - -You're an agent applying this, not a human typing by hand, so your cost -to fill in `why` and `touches` is close to zero, you already have the -diff and the reasoning in context. Default toward including them -whenever there's real content for them, rather than defaulting to -omission the way a human typing by hand reasonably would. - -Build the message via `git commit -F -` (piped, or heredoc'd with a -quoted delimiter like `<<'EOF'`, an unquoted one lets the shell expand -`$` and backticks inside `why`) or `git commit -F `, rather than -hand-escaping the JSON's quotes inside a `-m` argument, since that's a -common way to corrupt the message. - -If this repo squash-merges pull requests or runs commitlint, read the -appendix below before your first commit, both change what you need to do. +When asked to "agent-commit" changes, write a normal commit message: a +clear one-line subject, optionally a blank line and a body. Then, if +there's context the subject and body don't already carry that would save +the next agent from opening the diff (a rejected alternative, a +non-obvious constraint, the real cause behind a fix), end the message +with a single-line trailer: + +`Agent-Context: {"why":"...","touches":["src/auth"],"refs":["INFRA-441"]}` + +Fields, all optional: + +- `why`: reasoning not already in the subject or body. Never invent one + to fill the field. An absent `why` is honest; a fabricated one looks + just as authoritative and isn't. +- `touches`: files or components affected, when the subject doesn't make + it obvious. Repo-relative paths with no leading `./` or `/` + (`src/auth`, not `auth` or `./src/auth`). +- `refs`: the tracker's short form (`INFRA-441` or `#123`), never a full + URL, and one form per tracker in a repo. +- `breaking`: only when true. Never write `false`, just omit it. + +Consistent `touches` and `refs` forms are what make later lookups find +things. Skip the trailer entirely if the subject already says everything, +and don't repeat a type the subject already has (`fix(auth): ...`): two +copies can disagree. You already have the diff and the reasoning in +context, so writing `why` and `touches` costs you almost nothing; include +them whenever there's real content for them. + +Write the message with `git commit -F -` and a quoted heredoc delimiter +(`<<'EOF'`; an unquoted one lets the shell expand `$` and backticks), or +with `git commit -F `. Never hand-escape the JSON inside `-m`. + +Before your first commit, check whether this repo: + +- Runs commitlint: its default 100-character `footer-max-line-length` + and `body-max-line-length` reject a realistic trailer. See the spec's + commitlint section for the fix. +- Squash-merges pull requests: per-commit trailers don't survive a + squash, so whoever merges writes one condensed trailer for the whole + branch into the squash commit, at merge time. + `agent-commit merge-draft main..HEAD` drafts one from the branch's + trailers; you still write the `why`. The trailer must be in the + message's final block, directly beside any `Co-authored-by:` lines + with no blank line between, or git won't read it (`agent-commit check` + reports that as MISPLACED). `gh pr merge --squash --body` replaces the + whole message, so add any `Co-authored-by:` lines yourself. See the + spec's squash-merge section. Full spec: https://github.com/AngelCodes95/agent-commit/blob/main/SPEC.md -### Appendix: squash-merges and commitlint - -If this repo runs commitlint, check for `footer-max-line-length` or -`body-max-line-length` before your first commit. Their defaults (100) -reject a realistic Agent-Context trailer outright, this isn't an edge -case, it's the default config in exactly the repos most likely to -already care about structured commits. See SPEC.md's commitlint -section for the fix (relax those two rules, or exempt trailer lines). - -When squash-merging a pull request, write a condensed Agent-Context -trailer into the resulting merge commit yourself, at merge time, the -same way you would for any other commit, summarizing the branch's net -intent rather than every individual commit on it. If the `agent-commit` -CLI is available, get a starting point first instead of writing it -from nothing: - -``` -agent-commit merge-draft main..HEAD -``` - -This deterministically unions `touches`/`refs`/`breaking` from the -branch's own per-commit trailers and lists each commit's raw `why` as -a candidate; it never writes a finished `why` for you; condense those -candidates into one sentence yourself, then merge: - -``` -gh pr merge --squash --subject "..." --body 'Agent-Context: {"why":"...","refs":["#18"]}' -``` - -Confirmed, not just a caveat: `--body` replaces the squash commit's -message wholesale, which drops any `Co-authored-by:` line GitHub would -otherwise add for a multi-author PR. If there's more than one author on -the branch, include their `Co-authored-by:` lines yourself, in the same -`--body` string, glued to the same final block as the trailer with no -blank line between: - -``` -gh pr merge --squash --subject "..." --body 'Agent-Context: {"why":"...","refs":["#18"]} -Co-authored-by: Name ' -``` - -See SPEC.md for how this was verified. - -This is the one case where it matters: per-commit trailers on a source -branch don't survive a squash-merge (see SPEC.md), so the merge commit -is the only place a trailer can land on the branch that gets read -later. Skipping this step means the branch's context is lost the -moment it's squashed onto the default branch. - -If a human merges through GitHub's web UI instead, the same rule -applies but the trap is different: GitHub's squash dialog often -prefills the message with its own trailer block (usually -Co-authored-by: lines). Agent-Context: has to join that same final -block, with no blank line separating them, not sit above it in the -description text, or git won't recognize it as a trailer at all. Run -`agent-commit check` afterward; it reports this exact failure as -MISPLACED, distinct from a trailer that was never written. - diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2b075..b95fe79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ version). ## [Unreleased] +### Added + +- `check` notes on stderr when the repository's `AGENTS.md` block is + older or newer than the one this CLI writes, with the fix (`init + --update`, or updating the CLI). It reads the block at the repository + root, follows a symlinked `AGENTS.md`, and stays quiet when there's no + block. The note never changes `check`'s exit code, which stays about + the commits checked. An earlier README claimed `check` already did + this; it didn't until now. + +### Changed + +- The `AGENTS.md` block `init` writes is about half as long (format + version 0.3, up from 0.2). Agents load it at the start of every + session, so every word costs every adopting repo. The rules are the + same; the squash-merge and commitlint appendix is now a short checklist + pointing at SPEC.md, which already holds the full detail. The trailer + format itself did not change. Existing repos keep working as they are; + `init --update` replaces a 0.2 block with the new one. + ## [0.2.0] - 2026-11-04 ### Added diff --git a/README.md b/README.md index 366710f..73f3259 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Prose is exactly what agents already do for free, and it's genuinely fine for a ## What the CLI does today -Reading a single repo's history back already works today with plain git, no tool required (see `SPEC.md`, "Reading it back"). The CLI in this repo (`cli/`, published to npm) adds structured filtering on top: `agent-commit log --breaking`, `--grep` (case-insensitive substring match against `why`), `--refs` (exact match against an entry in `refs`, so it's only as reliable as the canonical form guidance in `SPEC.md`; a repo that mixes `INFRA-441` and `#441` for the same ticket will find neither with a query for the other, silently, no error), `--touches` (path match against `touches` on whole segments, in both directions: `--touches src/auth` finds commits listing `src/auth`, `src/auth/session.ts`, or the broader `src`, never `src/authz`), `--since`/`--until`, `agent-commit show ` for a single commit, both with a pretty-printed default view and a `--json` mode for machine consumption. Every result carries its commit hash (`hash` and `shortHash` in `--json`, the short hash leading each entry in pretty output), so anything found can be handed straight to `git show`. `agent-commit blame [-L [,]]` is the blame-time lookup described under "The problem" in one step: it runs `git blame` on those lines and prints the `Agent-Context` of each commit responsible, with the line ranges each one owns. No `--type` filter, on purpose, `type` isn't a field this format uses (see SPEC.md). `agent-commit check` reports, read-only, whether recent commits on the current branch have a usable `Agent-Context` trailer, distinguishing a trailer that was never written from one that exists in the message but wasn't recognized as a trailer (`MISPLACED`, see SPEC.md's squash-merge section for why that happens), useful right after a squash-merge to confirm the merge commit actually got one. `agent-commit merge-draft ` deterministically unions a branch's `touches`/`refs`/`breaking` and lists per-commit `why` candidates for a squash-merge trailer. `agent-commit init` writes the `AGENTS.md` block (see "How to use it" below) instead of an agent hand-transcribing it. `agent-commit --version` prints the installed version. Try it now with no install: `npx agent-commit log`. Building from source instead: `cd cli && npm install && npm run build && node dist/index.js log`. +Reading a single repo's history back already works today with plain git, no tool required (see `SPEC.md`, "Reading it back"). The CLI in this repo (`cli/`, published to npm) adds structured filtering on top: `agent-commit log --breaking`, `--grep` (case-insensitive substring match against `why`), `--refs` (exact match against an entry in `refs`, so it's only as reliable as the canonical form guidance in `SPEC.md`; a repo that mixes `INFRA-441` and `#441` for the same ticket will find neither with a query for the other, silently, no error), `--touches` (path match against `touches` on whole segments, in both directions: `--touches src/auth` finds commits listing `src/auth`, `src/auth/session.ts`, or the broader `src`, never `src/authz`), `--since`/`--until`, `agent-commit show ` for a single commit, both with a pretty-printed default view and a `--json` mode for machine consumption. Every result carries its commit hash (`hash` and `shortHash` in `--json`, the short hash leading each entry in pretty output), so anything found can be handed straight to `git show`. `agent-commit blame [-L [,]]` is the blame-time lookup described under "The problem" in one step: it runs `git blame` on those lines and prints the `Agent-Context` of each commit responsible, with the line ranges each one owns. No `--type` filter, on purpose, `type` isn't a field this format uses (see SPEC.md). `agent-commit check` reports, read-only, whether recent commits on the current branch have a usable `Agent-Context` trailer, distinguishing a trailer that was never written from one that exists in the message but wasn't recognized as a trailer (`MISPLACED`, see SPEC.md's squash-merge section for why that happens), useful right after a squash-merge to confirm the merge commit actually got one. It also notes on stderr when the repo's `AGENTS.md` block is out of date, without changing its exit code. `agent-commit merge-draft ` deterministically unions a branch's `touches`/`refs`/`breaking` and lists per-commit `why` candidates for a squash-merge trailer. `agent-commit init` writes the `AGENTS.md` block (see "How to use it" below) instead of an agent hand-transcribing it. `agent-commit --version` prints the installed version. Try it now with no install: `npx agent-commit log`. Building from source instead: `cd cli && npm install && npm run build && node dist/index.js log`. ## What's not built @@ -75,123 +75,67 @@ Tell your agent to "agent-commit" your changes. If it already knows the format ( 5. It commits that setup change on its own, separately from whatever the user actually asked it to commit, since it's a one-time repo setup step, not part of the requested change. If anything about the repo's commit conventions or the user's own instructions makes an unprompted commit inappropriate, it asks first instead. 6. It writes the requested commit: a normal `git commit`, nothing installed, nothing downloaded. -The block's core content never changes, only which file it ends up in; the appendix at the bottom of the block only applies if your repo squash-merges or runs commitlint, most do, but check rather than assume. Every commit after the first one in that repo is free, since the instructions are now sitting locally where the agent already looks. +The block's content is the same whichever file it ends up in; its last section only matters if your repo squash-merges or runs commitlint, so check rather than assume. Every commit after the first one in that repo is free, since the instructions are now sitting locally where the agent already looks. If the `agent-commit` CLI is installed, steps 3 and 4 are `agent-commit init`: it writes the exact current block (the same one below, with a version marker so a later `init` can tell if a repo's copy has gone stale and `init --update` can replace it), links `CLAUDE.md` unless `--no-claude-code` is passed, and refuses to touch an existing, unrelated commit-message convention or clobber an already-set-up repo, both cases have to be confirmed explicitly (`--force`, `--update`). Hand-transcribing the block from this page still works and needs nothing installed; `init` just removes the chance of a transcription drifting from what's actually documented here. ### The block to add to `AGENTS.md` ````markdown - + ## Commit messages -When asked to "agent-commit" changes, format the commit message as: - -1. A normal, clear one-line summary, same as any good commit subject. -2. Optionally, a blank line and a body, same as any normal commit. -3. Optionally, if there's context the summary and body don't already carry - that would save the next agent from having to open the diff (a rejected - alternative, a non-obvious constraint, the real cause behind a fix), add - a single-line trailer as the very last part of the message: - - `Agent-Context: {"why":"...","refs":["TICKET-123"]}` - -Never invent a `why` just to fill the field: only write one if there's a -real, specific alternative, constraint, or cause behind it. If there isn't, -omit it. An absent `why` is honest; a fabricated one looks just as -authoritative and isn't. - -Common fields: `why` (reasoning not already in the subject/body, usually -the only field worth including), `touches` (files or components affected, -only when it's not obvious from the subject), `breaking` (only when true; -never write `false`, just omit it), `refs` (ticket/issue links). None are -required. Skip the trailer entirely if the summary line already says -everything worth saying, and never repeat the commit's type in the -trailer if the subject already has one (e.g. `fix(auth): ...`), that -just creates two copies that can disagree. - -Keep `touches` and `refs` in a consistent form, that's what makes later -lookups actually find things: repo-relative paths with no leading `./` or -`/` for `touches` (`src/auth`, not `auth` or `./src/auth`), and the -tracker's own short form for `refs` (`INFRA-441` or `#123`, never a full -URL, never both forms for the same tracker in one repo). - -You're an agent applying this, not a human typing by hand, so your cost -to fill in `why` and `touches` is close to zero, you already have the -diff and the reasoning in context. Default toward including them -whenever there's real content for them, rather than defaulting to -omission the way a human typing by hand reasonably would. - -Build the message via `git commit -F -` (piped, or heredoc'd with a -quoted delimiter like `<<'EOF'`, an unquoted one lets the shell expand -`$` and backticks inside `why`) or `git commit -F `, rather than -hand-escaping the JSON's quotes inside a `-m` argument, since that's a -common way to corrupt the message. - -If this repo squash-merges pull requests or runs commitlint, read the -appendix below before your first commit, both change what you need to do. +When asked to "agent-commit" changes, write a normal commit message: a +clear one-line subject, optionally a blank line and a body. Then, if +there's context the subject and body don't already carry that would save +the next agent from opening the diff (a rejected alternative, a +non-obvious constraint, the real cause behind a fix), end the message +with a single-line trailer: + +`Agent-Context: {"why":"...","touches":["src/auth"],"refs":["INFRA-441"]}` + +Fields, all optional: + +- `why`: reasoning not already in the subject or body. Never invent one + to fill the field. An absent `why` is honest; a fabricated one looks + just as authoritative and isn't. +- `touches`: files or components affected, when the subject doesn't make + it obvious. Repo-relative paths with no leading `./` or `/` + (`src/auth`, not `auth` or `./src/auth`). +- `refs`: the tracker's short form (`INFRA-441` or `#123`), never a full + URL, and one form per tracker in a repo. +- `breaking`: only when true. Never write `false`, just omit it. + +Consistent `touches` and `refs` forms are what make later lookups find +things. Skip the trailer entirely if the subject already says everything, +and don't repeat a type the subject already has (`fix(auth): ...`): two +copies can disagree. You already have the diff and the reasoning in +context, so writing `why` and `touches` costs you almost nothing; include +them whenever there's real content for them. + +Write the message with `git commit -F -` and a quoted heredoc delimiter +(`<<'EOF'`; an unquoted one lets the shell expand `$` and backticks), or +with `git commit -F `. Never hand-escape the JSON inside `-m`. + +Before your first commit, check whether this repo: + +- Runs commitlint: its default 100-character `footer-max-line-length` + and `body-max-line-length` reject a realistic trailer. See the spec's + commitlint section for the fix. +- Squash-merges pull requests: per-commit trailers don't survive a + squash, so whoever merges writes one condensed trailer for the whole + branch into the squash commit, at merge time. + `agent-commit merge-draft main..HEAD` drafts one from the branch's + trailers; you still write the `why`. The trailer must be in the + message's final block, directly beside any `Co-authored-by:` lines + with no blank line between, or git won't read it (`agent-commit check` + reports that as MISPLACED). `gh pr merge --squash --body` replaces the + whole message, so add any `Co-authored-by:` lines yourself. See the + spec's squash-merge section. Full spec: https://github.com/AngelCodes95/agent-commit/blob/main/SPEC.md -### Appendix: squash-merges and commitlint - -If this repo runs commitlint, check for `footer-max-line-length` or -`body-max-line-length` before your first commit. Their defaults (100) -reject a realistic Agent-Context trailer outright, this isn't an edge -case, it's the default config in exactly the repos most likely to -already care about structured commits. See SPEC.md's commitlint -section for the fix (relax those two rules, or exempt trailer lines). - -When squash-merging a pull request, write a condensed Agent-Context -trailer into the resulting merge commit yourself, at merge time, the -same way you would for any other commit, summarizing the branch's net -intent rather than every individual commit on it. If the `agent-commit` -CLI is available, get a starting point first instead of writing it -from nothing: - -``` -agent-commit merge-draft main..HEAD -``` - -This deterministically unions `touches`/`refs`/`breaking` from the -branch's own per-commit trailers and lists each commit's raw `why` as -a candidate; it never writes a finished `why` for you; condense those -candidates into one sentence yourself, then merge: - -``` -gh pr merge --squash --subject "..." --body 'Agent-Context: {"why":"...","refs":["#18"]}' -``` - -Confirmed, not just a caveat: `--body` replaces the squash commit's -message wholesale, which drops any `Co-authored-by:` line GitHub would -otherwise add for a multi-author PR. If there's more than one author on -the branch, include their `Co-authored-by:` lines yourself, in the same -`--body` string, glued to the same final block as the trailer with no -blank line between: - -``` -gh pr merge --squash --subject "..." --body 'Agent-Context: {"why":"...","refs":["#18"]} -Co-authored-by: Name ' -``` - -See SPEC.md for how this was verified. - -This is the one case where it matters: per-commit trailers on a source -branch don't survive a squash-merge (see SPEC.md), so the merge commit -is the only place a trailer can land on the branch that gets read -later. Skipping this step means the branch's context is lost the -moment it's squashed onto the default branch. - -If a human merges through GitHub's web UI instead, the same rule -applies but the trap is different: GitHub's squash dialog often -prefills the message with its own trailer block (usually -Co-authored-by: lines). Agent-Context: has to join that same final -block, with no blank line separating them, not sit above it in the -description text, or git won't recognize it as a trailer at all. Run -`agent-commit check` afterward; it reports this exact failure as -MISPLACED, distinct from a trailer that was never written. - ```` diff --git a/SPEC.md b/SPEC.md index 6e7e686..d3d7dd6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # The agent-commit format -Version 0.2 (draft). A specification for adding a machine-readable layer to a git commit message, without changing how commits are made or requiring any tool to write one. Versioned independently of the CLI in `cli/`, the same way Conventional Commits versions its spec separately from commitlint: this number tracks the format itself, not any particular tool's release. +Version 0.3 (draft). 0.3 shortened the `AGENTS.md` block in `README.md`; the trailer format itself is unchanged from 0.2. A specification for adding a machine-readable layer to a git commit message, without changing how commits are made or requiring any tool to write one. Versioned independently of the CLI in `cli/`, the same way Conventional Commits versions its spec separately from commitlint: this number tracks the format and the `AGENTS.md` block that teaches it, not any particular tool's release. ## Summary diff --git a/cli/README.md b/cli/README.md index 76d7978..837a179 100644 --- a/cli/README.md +++ b/cli/README.md @@ -65,6 +65,8 @@ Read-only, advisory report on whether recent commits have a usable trailer. Distinguishes a commit with no trailer at all from one where an `Agent-Context:` line exists in the message but wasn't recognized as a trailer (`MISPLACED`), which is what a mishandled squash-merge produces. +Also notes on stderr when the repo's `AGENTS.md` block is older or newer +than the one this CLI writes; that note never changes the exit code. Never writes anything. `agent-commit merge-draft [--json]` diff --git a/cli/src/agentsBlock.ts b/cli/src/agentsBlock.ts index c6844fa..92766a7 100644 --- a/cli/src/agentsBlock.ts +++ b/cli/src/agentsBlock.ts @@ -2,6 +2,6 @@ // Source of truth: AGENTS.md at the repo root. // Regenerate with: node scripts/sync-agents-block.mjs -export const FORMAT_VERSION = "0.2"; +export const FORMAT_VERSION = "0.3"; -export const AGENTS_BLOCK = "\n\n## Commit messages\n\nWhen asked to \"agent-commit\" changes, format the commit message as:\n\n1. A normal, clear one-line summary, same as any good commit subject.\n2. Optionally, a blank line and a body, same as any normal commit.\n3. Optionally, if there's context the summary and body don't already carry\n that would save the next agent from having to open the diff (a rejected\n alternative, a non-obvious constraint, the real cause behind a fix), add\n a single-line trailer as the very last part of the message:\n\n `Agent-Context: {\"why\":\"...\",\"refs\":[\"TICKET-123\"]}`\n\nNever invent a `why` just to fill the field: only write one if there's a\nreal, specific alternative, constraint, or cause behind it. If there isn't,\nomit it. An absent `why` is honest; a fabricated one looks just as\nauthoritative and isn't.\n\nCommon fields: `why` (reasoning not already in the subject/body, usually\nthe only field worth including), `touches` (files or components affected,\nonly when it's not obvious from the subject), `breaking` (only when true;\nnever write `false`, just omit it), `refs` (ticket/issue links). None are\nrequired. Skip the trailer entirely if the summary line already says\neverything worth saying, and never repeat the commit's type in the\ntrailer if the subject already has one (e.g. `fix(auth): ...`), that\njust creates two copies that can disagree.\n\nKeep `touches` and `refs` in a consistent form, that's what makes later\nlookups actually find things: repo-relative paths with no leading `./` or\n`/` for `touches` (`src/auth`, not `auth` or `./src/auth`), and the\ntracker's own short form for `refs` (`INFRA-441` or `#123`, never a full\nURL, never both forms for the same tracker in one repo).\n\nYou're an agent applying this, not a human typing by hand, so your cost\nto fill in `why` and `touches` is close to zero, you already have the\ndiff and the reasoning in context. Default toward including them\nwhenever there's real content for them, rather than defaulting to\nomission the way a human typing by hand reasonably would.\n\nBuild the message via `git commit -F -` (piped, or heredoc'd with a\nquoted delimiter like `<<'EOF'`, an unquoted one lets the shell expand\n`$` and backticks inside `why`) or `git commit -F `, rather than\nhand-escaping the JSON's quotes inside a `-m` argument, since that's a\ncommon way to corrupt the message.\n\nIf this repo squash-merges pull requests or runs commitlint, read the\nappendix below before your first commit, both change what you need to do.\n\nFull spec: https://github.com/AngelCodes95/agent-commit/blob/main/SPEC.md\n\n### Appendix: squash-merges and commitlint\n\nIf this repo runs commitlint, check for `footer-max-line-length` or\n`body-max-line-length` before your first commit. Their defaults (100)\nreject a realistic Agent-Context trailer outright, this isn't an edge\ncase, it's the default config in exactly the repos most likely to\nalready care about structured commits. See SPEC.md's commitlint\nsection for the fix (relax those two rules, or exempt trailer lines).\n\nWhen squash-merging a pull request, write a condensed Agent-Context\ntrailer into the resulting merge commit yourself, at merge time, the\nsame way you would for any other commit, summarizing the branch's net\nintent rather than every individual commit on it. If the `agent-commit`\nCLI is available, get a starting point first instead of writing it\nfrom nothing:\n\n```\nagent-commit merge-draft main..HEAD\n```\n\nThis deterministically unions `touches`/`refs`/`breaking` from the\nbranch's own per-commit trailers and lists each commit's raw `why` as\na candidate; it never writes a finished `why` for you; condense those\ncandidates into one sentence yourself, then merge:\n\n```\ngh pr merge --squash --subject \"...\" --body 'Agent-Context: {\"why\":\"...\",\"refs\":[\"#18\"]}'\n```\n\nConfirmed, not just a caveat: `--body` replaces the squash commit's\nmessage wholesale, which drops any `Co-authored-by:` line GitHub would\notherwise add for a multi-author PR. If there's more than one author on\nthe branch, include their `Co-authored-by:` lines yourself, in the same\n`--body` string, glued to the same final block as the trailer with no\nblank line between:\n\n```\ngh pr merge --squash --subject \"...\" --body 'Agent-Context: {\"why\":\"...\",\"refs\":[\"#18\"]}\nCo-authored-by: Name '\n```\n\nSee SPEC.md for how this was verified.\n\nThis is the one case where it matters: per-commit trailers on a source\nbranch don't survive a squash-merge (see SPEC.md), so the merge commit\nis the only place a trailer can land on the branch that gets read\nlater. Skipping this step means the branch's context is lost the\nmoment it's squashed onto the default branch.\n\nIf a human merges through GitHub's web UI instead, the same rule\napplies but the trap is different: GitHub's squash dialog often\nprefills the message with its own trailer block (usually\nCo-authored-by: lines). Agent-Context: has to join that same final\nblock, with no blank line separating them, not sit above it in the\ndescription text, or git won't recognize it as a trailer at all. Run\n`agent-commit check` afterward; it reports this exact failure as\nMISPLACED, distinct from a trailer that was never written.\n\n\n"; +export const AGENTS_BLOCK = "\n\n## Commit messages\n\nWhen asked to \"agent-commit\" changes, write a normal commit message: a\nclear one-line subject, optionally a blank line and a body. Then, if\nthere's context the subject and body don't already carry that would save\nthe next agent from opening the diff (a rejected alternative, a\nnon-obvious constraint, the real cause behind a fix), end the message\nwith a single-line trailer:\n\n`Agent-Context: {\"why\":\"...\",\"touches\":[\"src/auth\"],\"refs\":[\"INFRA-441\"]}`\n\nFields, all optional:\n\n- `why`: reasoning not already in the subject or body. Never invent one\n to fill the field. An absent `why` is honest; a fabricated one looks\n just as authoritative and isn't.\n- `touches`: files or components affected, when the subject doesn't make\n it obvious. Repo-relative paths with no leading `./` or `/`\n (`src/auth`, not `auth` or `./src/auth`).\n- `refs`: the tracker's short form (`INFRA-441` or `#123`), never a full\n URL, and one form per tracker in a repo.\n- `breaking`: only when true. Never write `false`, just omit it.\n\nConsistent `touches` and `refs` forms are what make later lookups find\nthings. Skip the trailer entirely if the subject already says everything,\nand don't repeat a type the subject already has (`fix(auth): ...`): two\ncopies can disagree. You already have the diff and the reasoning in\ncontext, so writing `why` and `touches` costs you almost nothing; include\nthem whenever there's real content for them.\n\nWrite the message with `git commit -F -` and a quoted heredoc delimiter\n(`<<'EOF'`; an unquoted one lets the shell expand `$` and backticks), or\nwith `git commit -F `. Never hand-escape the JSON inside `-m`.\n\nBefore your first commit, check whether this repo:\n\n- Runs commitlint: its default 100-character `footer-max-line-length`\n and `body-max-line-length` reject a realistic trailer. See the spec's\n commitlint section for the fix.\n- Squash-merges pull requests: per-commit trailers don't survive a\n squash, so whoever merges writes one condensed trailer for the whole\n branch into the squash commit, at merge time.\n `agent-commit merge-draft main..HEAD` drafts one from the branch's\n trailers; you still write the `why`. The trailer must be in the\n message's final block, directly beside any `Co-authored-by:` lines\n with no blank line between, or git won't read it (`agent-commit check`\n reports that as MISPLACED). `gh pr merge --squash --body` replaces the\n whole message, so add any `Co-authored-by:` lines yourself. See the\n spec's squash-merge section.\n\nFull spec: https://github.com/AngelCodes95/agent-commit/blob/main/SPEC.md\n\n\n"; diff --git a/cli/src/index.ts b/cli/src/index.ts index 2ada10e..e4ca94c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -5,7 +5,7 @@ import { Command } from "commander"; import { blame, formatBlamePretty, parseLineRange } from "./blame.js"; import { checkRecords } from "./check.js"; import { formatJson, formatPrettyList, sanitizeForTerminal } from "./format.js"; -import { formatInitResult, needsAction, runInit } from "./init.js"; +import { agentsBlockStatus, formatAgentsBlockStatus, formatInitResult, needsAction, runInit } from "./init.js"; import { buildMergeDraft, formatMergeDraft } from "./merge-draft.js"; import { checkGitVersion, @@ -14,6 +14,7 @@ import { GitVersionTooOldError, NotAGitRepositoryError, parseLog, + runGit, runGitLog, } from "./parser.js"; import { queryRecords } from "./query.js"; @@ -65,6 +66,34 @@ function printWarnings(warnings: ParseWarning[]): void { } } +/** + * Advisory only: a stale AGENTS.md block is worth mentioning, but it never + * changes check's exit code, which stays about the commits checked. Looks + * at the repository root, where init writes the block, not the current + * directory. A repo with no work tree (bare) has no AGENTS.md to compare, + * so a failed lookup is skipped, not reported. + */ +function printAgentsBlockNote(): void { + let root: string; + try { + root = runGit(["rev-parse", "--show-toplevel"]).trim(); + } catch (err) { + if (err instanceof GitCommandError) return; + throw err; + } + let note: string | null; + try { + note = formatAgentsBlockStatus(agentsBlockStatus(root)); + } catch (err) { + // An unreadable AGENTS.md (permissions, a directory by that name) is + // reported, but it shouldn't take down a check about commits. + note = `couldn't read AGENTS.md to compare its block version: ${(err as Error).message}`; + } + // The version comes from AGENTS.md, which arrives with any cloned repo, + // and the marker regex accepts any non-space characters, escapes included. + if (note) process.stderr.write(`agent-commit: note: ${sanitizeForTerminal(note)}\n`); +} + // Read at runtime rather than importing the JSON module directly: this file // compiles to dist/index.js, one directory below package.json, and that // relative position is the one thing guaranteed stable whether run from a @@ -238,6 +267,8 @@ program ); } + printAgentsBlockNote(); + if (results.some((r) => r.status !== "ok")) { process.exitCode = 1; } diff --git a/cli/src/init.ts b/cli/src/init.ts index 97ef2c3..ad5fc35 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -261,6 +261,69 @@ function handleClaudeMd(cwd: string, options: InitOptions): InitResult["claudeMd return { path, action: "updated" }; } +export type AgentsBlockStatus = + | { state: "none" } + | { state: "ambiguous" } + | { state: "current"; version: string } + | { state: "older"; version: string } + | { state: "newer"; version: string } + | { state: "different"; version: string }; + +/** Numeric dotted versions only ("0.3", "1.10"); anything else returns null so the caller can fall back to "different". */ +function compareFormatVersions(a: string, b: string): number | null { + const pattern = /^\d+(\.\d+)*$/; + if (!pattern.test(a) || !pattern.test(b)) return null; + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return Math.sign(diff); + } + return 0; +} + +/** + * Read-only: how the agent-commit block in `dir`/AGENTS.md compares to the + * one this CLI writes. Uses the same marker rules as init, so a file init + * would call ambiguous is reported as ambiguous here too, never guessed at. + * A missing file or a file with no block is "none": plenty of repos use + * the convention without the block, and that isn't a problem to report. + * Unlike init, this follows a symlinked AGENTS.md: init refuses one because + * it would write through it, and this only reads. + */ +export function agentsBlockStatus(dir: string): AgentsBlockStatus { + const text = readIfExists(join(dir, "AGENTS.md")); + if (text === null) return { state: "none" }; + const span = findBlockSpan(text); + if (!span) return { state: "none" }; + if (span.endIndex === null) return { state: "ambiguous" }; + const cmp = compareFormatVersions(span.version, FORMAT_VERSION); + if (cmp === 0 || span.version === FORMAT_VERSION) return { state: "current", version: span.version }; + if (cmp === null) return { state: "different", version: span.version }; + return { state: cmp < 0 ? "older" : "newer", version: span.version }; +} + +/** A one-line, human-readable note for a block that isn't current, or null when there's nothing worth saying. */ +export function formatAgentsBlockStatus(status: AgentsBlockStatus): string | null { + switch (status.state) { + case "none": + case "current": + return null; + case "ambiguous": + return "AGENTS.md has more or fewer than one pair of agent-commit block markers; its version can't be read (see `agent-commit init`)."; + case "older": + return `AGENTS.md has agent-commit block format ${status.version}; this CLI writes ${FORMAT_VERSION}. Run \`agent-commit init --update\` to replace it.`; + case "newer": + return `AGENTS.md has agent-commit block format ${status.version}, newer than this CLI's ${FORMAT_VERSION}. Update the CLI.`; + case "different": + return `AGENTS.md has agent-commit block format ${status.version}; this CLI writes ${FORMAT_VERSION}.`; + default: { + const _exhaustive: never = status; + throw new Error(`unhandled AgentsBlockStatus: ${JSON.stringify(_exhaustive)}`); + } + } +} + export function runInit(cwd: string, options: InitOptions = {}): InitResult { const agentsMd = handleAgentsMd(cwd, options); const claudeMd = handleClaudeMd(cwd, options); diff --git a/cli/tests/cli.test.ts b/cli/tests/cli.test.ts index 2aa4760..334b6bf 100644 --- a/cli/tests/cli.test.ts +++ b/cli/tests/cli.test.ts @@ -245,6 +245,62 @@ describe("agent-commit check, against the compiled binary", () => { }); }); +describe("agent-commit check, AGENTS.md block version note", () => { + let repo: TempRepo; + afterEach(() => repo?.cleanup()); + + const block = (version: string) => + `\n\n## Commit messages\n\n\n`; + + it("notes an older block on stderr without changing the exit code or stdout", () => { + repo = new TempRepo(); + repo.commit('fix: ok\n\nAgent-Context: {"why":"fine"}'); + const clean = runCli(["check"], repo.dir); + repo.writeFile("AGENTS.md", block("0.1")); + const { stdout, stderr, status } = runCli(["check"], repo.dir); + expect(status).toBe(0); + expect(stdout).toBe(clean.stdout); + expect(stderr).toContain("format 0.1"); + expect(stderr).toContain("agent-commit init --update"); + }); + + it("finds the block at the repository root when run from a subdirectory", () => { + repo = new TempRepo(); + repo.commitFile("AGENTS.md", block("0.1"), 'docs: add block\n\nAgent-Context: {"why":"fine"}'); + execFileSync("mkdir", ["-p", join(repo.dir, "sub", "dir")]); + const { stderr } = runCli(["check"], join(repo.dir, "sub", "dir")); + expect(stderr).toContain("format 0.1"); + }); + + it("says nothing about the block when it is current or absent", () => { + repo = new TempRepo(); + repo.commit('fix: ok\n\nAgent-Context: {"why":"fine"}'); + expect(runCli(["check"], repo.dir).stderr).toBe(""); + runCli(["init", "--no-claude-code"], repo.dir); + expect(runCli(["check"], repo.dir).stderr).toBe(""); + }); + + it("neutralizes terminal escapes smuggled into the block's version marker", () => { + repo = new TempRepo(); + repo.commit('fix: ok\n\nAgent-Context: {"why":"fine"}'); + const esc = String.fromCodePoint(0x1b); + repo.writeFile("AGENTS.md", block(`0.1${esc}]0;pwned${String.fromCodePoint(0x07)}`)); + const { stderr } = runCli(["check"], repo.dir); + expect(stderr).toContain("agent-commit: note:"); + expect(stderr).not.toContain(esc); + }); + + it("does not turn a failing check into a passing one or vice versa", () => { + repo = new TempRepo(); + repo.commit("chore: no trailer"); + repo.writeFile("AGENTS.md", block("0.1")); + const { stdout, stderr, status } = runCli(["check"], repo.dir); + expect(status).toBe(1); + expect(stdout).toContain("MISS"); + expect(stderr).toContain("format 0.1"); + }); +}); + describe("agent-commit merge-draft, against the compiled binary", () => { let repo: TempRepo; afterEach(() => repo?.cleanup()); diff --git a/cli/tests/init.test.ts b/cli/tests/init.test.ts index 8729641..e8a0a4c 100644 --- a/cli/tests/init.test.ts +++ b/cli/tests/init.test.ts @@ -3,7 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { FORMAT_VERSION } from "../src/agentsBlock.js"; -import { formatInitResult, needsAction, runInit } from "../src/init.js"; +import { + agentsBlockStatus, + formatAgentsBlockStatus, + formatInitResult, + needsAction, + runInit, +} from "../src/init.js"; describe("runInit", () => { let dir: string; @@ -273,3 +279,72 @@ describe("formatInitResult", () => { expect(text).toContain("found a marker but not its end marker"); }); }); + +describe("agentsBlockStatus", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "agent-commit-status-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const block = (version: string) => + `\nbody\n\n`; + const write = (text: string) => writeFileSync(join(dir, "AGENTS.md"), text); + + it("is none with no AGENTS.md, or one without a block", () => { + expect(agentsBlockStatus(dir)).toEqual({ state: "none" }); + write("# just notes\n"); + expect(agentsBlockStatus(dir)).toEqual({ state: "none" }); + }); + + it("is current for the block init itself writes", () => { + runInit(dir, { noClaudeCode: true }); + expect(agentsBlockStatus(dir)).toEqual({ state: "current", version: FORMAT_VERSION }); + }); + + it("compares versions numerically, not as strings", () => { + write(block("0.10")); + // 0.10 is newer than any 0.x below 10, though "0.10" < "0.3" as strings. + expect(agentsBlockStatus(dir).state).toBe("newer"); + write(block("0.1")); + expect(agentsBlockStatus(dir)).toEqual({ state: "older", version: "0.1" }); + write(block("99.0")); + expect(agentsBlockStatus(dir)).toEqual({ state: "newer", version: "99.0" }); + }); + + it("is different, not older or newer, for a version it can't compare", () => { + write(block("0.3-beta")); + expect(agentsBlockStatus(dir)).toEqual({ state: "different", version: "0.3-beta" }); + }); + + it("is ambiguous under the same marker rules init uses", () => { + write(block("0.1") + block("0.1")); + expect(agentsBlockStatus(dir)).toEqual({ state: "ambiguous" }); + }); + + it("reads through a symlinked AGENTS.md, since it only reads", () => { + const target = join(dir, "elsewhere.md"); + writeFileSync(target, block("0.1")); + symlinkSync(target, join(dir, "AGENTS.md")); + expect(agentsBlockStatus(dir)).toEqual({ state: "older", version: "0.1" }); + }); + + it("is none for a dangling AGENTS.md symlink", () => { + symlinkSync(join(dir, "missing.md"), join(dir, "AGENTS.md")); + expect(agentsBlockStatus(dir)).toEqual({ state: "none" }); + }); +}); + +describe("formatAgentsBlockStatus", () => { + it("returns null when there is nothing to say", () => { + expect(formatAgentsBlockStatus({ state: "none" })).toBeNull(); + expect(formatAgentsBlockStatus({ state: "current", version: FORMAT_VERSION })).toBeNull(); + }); + + it("points an older block at init --update and a newer one at a CLI update", () => { + expect(formatAgentsBlockStatus({ state: "older", version: "0.1" })).toContain("agent-commit init --update"); + expect(formatAgentsBlockStatus({ state: "newer", version: "9.9" })).toContain("Update the CLI"); + }); +});