Skip to content

Agent compatibility: audit, CLI ergonomics, Ollama fixes, cross-tool portability, and test coverage - #49

Merged
martient merged 14 commits into
developfrom
claude/agent-compatibility-2026-09
Sep 21, 2026
Merged

martient merged 14 commits into
developfrom
claude/agent-compatibility-2026-09

Conversation

@martient

Copy link
Copy Markdown
Owner

Supersedes and replaces #42, #43, #44, #45, #46 and #47 — same 14 signed commits, collapsed into one PR so it needs a single approval instead of six.

cargo test 421 passed, 0 failed; fmt and clippy --all-targets --all-features -D warnings clean.


1. The audit

docs/audits/agent-compatibility-2026-09.md reviews every surface an AI coding agent touches: the instruction files, the shared Codex/Claude plugin and skills, the machine-readable CLI contract, generated hooks and CI, and the src/ai integration — against the state of the agent ecosystem as of September 2026.

Two of its findings turned out to be wrong, and both are recorded as withdrawn rather than quietly dropped:

  • P0-4 was false. It claimed --ai was silently ignored in --mode apply. It never was — the apply arm has always had its own AI block. I'd misread a grep piped through awk 'NR>=530' and taken the renumbered output for plan-arm line numbers.
  • The tag_tests "bug" was not a Committy bug (see §5).

2. Agents couldn't drive the CLI

Three mechanical faults, each reproduced before fixing:

  • Flag placement. --non-interactive and -q/--quiet were root-only, so committy schema --non-interactive … — the form agents write by default — was rejected by clap. Both are now global.
  • Parse errors broke the machine contract. A rejected invocation wrote nothing to stdout and prose to stderr, even with --output json. It now emits the standard envelope with "code": "invalid_usage". Text mode unchanged; --help/--version keep plain text and exit 0.
  • COMMITTY_NONINTERACTIVE=1 makes placement irrelevant but appeared in no agent-facing doc.

--verbose was deliberately not made global: -v is branch --validate and --verbose belongs to config validate|show. Promoting it panics the binary at startup on a short-flag collision. A test pins that trade-off.

Capability discovery went 10 → 21 entries, covering the whole surface the shipped skills already tell agents to use. Each declares mutating and requires_confirmation, so an agent can reason about consent without pattern-matching command names.

3. The Ollama provider had never worked

Two independent bugs, either fatal:

  • stream was never set. /api/chat defaults to stream: true, so responses were newline-delimited events and resp.json::<ResponseBody>() failed — every run ended in LlmError::Parse and silently fell back to the default message.
  • format was nested under options, where Ollama does not read it. It is top-level, and now carries a JSON Schema for AiCommitSuggestion rather than the bare string "json".

The default AI path also had no signal: unless --ai-allow-sensitive was set, the prompt held only the group name and an instruction to improve it "without revealing code or filenames". It now sends a redacted shape — file count, extension histogram, top-level areas. No filename, no path below the first segment, and no file content in either mode; --ai-allow-sensitive controls paths, not content, which the docs got wrong in both directions.

Request bodies are now pure functions with unit tests, so the wire format is verifiable without a network call.

Removed: --ai-diff-lines-per-file (declared, documented, accepted, never read), LlmError::_Timeout, the commented-out LlmProvider enum, and an empty roadmap page. plan and apply no longer carry byte-identical copies of group building and the ~150-line AI block.

4. Cross-tool portability

AGENTS.md is now canonical; CLAUDE.md is an @AGENTS.md import plus the one Claude-specific line. They were hand-synced duplicates that had already drifted. Pointers added for the two runtimes that read neither: .github/copilot-instructions.md and GEMINI.md.

Skills are exposed at .agents/skills (symlink to the canonical copy, no duplication) and carry the Agent Skills spec's optional fields — license, allowed-tools, metadata.

⚠️ Symlink caveat: a checkout that materialises .agents/skills as a plain file (Windows without developer mode, core.symlinks=false) breaks discovery silently. A test fails loudly instead.

5. Tests, including two pre-existing failures on develop

tag_tests::test_tag_respects_repo_git_overrides… — not a Committy bug. The test asserts gpg.program is consulted, which only holds when gpg.format is openpgp. On a host with gpg.format=ssh git routes signing elsewhere, the failing stub never runs, and the test blames Committy for correctly forwarding the override. Now pins gpg.format=openpgp.

config::hierarchy::tests::test_repository_config_overrides_user — hardened, causality unproven. Those tests read $HOME via MergedConfig::load while #[serial] tests in config.rs mutate it. They're now #[serial] too. But I saw the failure once and could not reproduce it in thirteen later runs, including with the fix reverted. Read it as hardening, not a verified fix.

New coverage: 12 agent-ergonomics tests, 9 plugin-manifest/frontmatter tests (both mutation-checked against deliberately broken inputs), and a 12-case behaviour matrix for the local guardrail hook.

6. CI and the guardrail

CI was not running on stacked PRs at all. lint.yml filtered branches: ["*"], and in Actions filters * does not match /. Every PR based on a claude/… branch had zero check runs — silently, since a workflow that doesn't trigger looks like one with nothing to report. Fixed to ["**"]; it would equally have hidden CI on any feat/ or fix/ prefix.

The generated hooks install --with-ci workflow pinned actions/checkout@v4 and ran an uncached cargo install per PR; now checkout@v5, pinned toolchain, Swatinem/rust-cache.

The local guardrail hook was rewritten and then actually tested. It had grepped raw tool input for nine substrings — matching the text of a command rather than an invocation (documentation naming a push was refused) while missing git push, env X=1 git push and git -C . push entirely. Writing the behaviour matrix found three further defects in my own rewrite, all fixed here: an unblocked git push -f, a bash <<EOF heredoc bypass, and a deliberate over-block on quoted prose now recorded as a decision.


Generated by Claude Code

martient and others added 14 commits September 20, 2026 18:00
Audit the repository's agent-facing surfaces (AGENTS.md/CLAUDE.md, the
shared Codex/Claude plugin and skills, the schema capability contract,
generated hooks/CI, and the src/ai LLM integration) against the current
state of the agent-integration ecosystem.

Records 15 prioritised findings covering defects, compatibility gaps and
removals, plus two explicit non-recommendations.
Agents consistently failed against the CLI for three mechanical reasons,
each now reproduced and covered by tests/agent_ergonomics_tests.rs.

- --non-interactive and -q/--quiet are global, so they parse on either
  side of the subcommand. --verbose stays root-only: -v belongs to
  `branch --validate` and --verbose to `config validate|show`, and
  promoting it would steal both shorts. A test pins that trade-off.
- Argument-parse rejections now emit the machine envelope on stdout with
  code "invalid_usage" when --output json was requested, instead of an
  empty stdout and prose on stderr. Text mode is unchanged, and --help
  and --version keep their plain-text form and exit 0.
- schema capabilities cover the whole agent command surface (tag, bump,
  changelog, group-commit, config, packages, schema) and each entry now
  declares `mutating` and `requires_confirmation`, so an agent can reason
  about consent without pattern-matching command names.
- COMMITTY_NONINTERACTIVE is documented in AGENTS.md, CLAUDE.md and the
  agent-workflows reference; it makes flag placement irrelevant.

Also withdraws the audit's blanket "do not build an MCP server": that was
argued against eager-loading MCP, which the 2026-07-28 stateless spec has
moved off.
Second half of the agent-ergonomics change: main.rs flag globals and the
clap rejection handler, the expanded schema capability list, the
ergonomics test suite, and the docs describing flag placement.
Adds tests/agent_ergonomics_tests.rs (12 tests) covering flag placement
on both sides of the subcommand, the invalid_usage envelope on argv
rejection, the expanded capability surface with consent metadata, and
the presence of COMMITTY_NONINTERACTIVE in agent-facing docs.

Documents flag placement in the agent-workflows reference and records
the diagnosis in the audit, withdrawing its blanket recommendation
against an MCP server.
Tooling re-adds Co-Authored-By and Claude-Session by default, so the
prohibition has to live where agents read it rather than being restated
each session.
Four real defects from the agent audit, plus one withdrawn finding.

- Ollama requests now set `stream: false`. The endpoint defaults to
  streaming, so every previous call received newline-delimited events and
  died in LlmError::Parse before silently falling back to the default
  message. The provider has never worked.
- `format` moves from `options` to the top level, where Ollama reads it,
  and now carries a JSON Schema for AiCommitSuggestion rather than the
  string "json", so decoding is constrained instead of merely requested.
- The default (non-sensitive) AI path sent only the group name and an
  instruction not to reveal anything, leaving the model nothing to work
  from. It now sends a redacted shape summary: file count, extension
  histogram and top-level areas. No filename, no path below the first
  segment, and no file content is sent in either mode.
- `--ai-diff-lines-per-file` was declared, documented and accepted but
  never read. Removed, along with the dead LlmError::_Timeout variant and
  the commented-out LlmProvider enum.

The audit's P0-4 ("--ai silently ignored in --mode apply") was wrong and
is withdrawn: the apply arm has always had its own AI block. The real
problem there was duplication -- plan and apply carried identical copies
of group building and the AI block -- which is what made the misreading
possible. Both are extracted into build_groups() and ai_user_prompt().

Request bodies are now built by pure functions with unit tests, so the
wire format is verifiable without a network call.
The pull_request trigger filtered on branches: ["*"], and in Actions
branch filters `*` matches everything except `/`. Any pull request whose
base branch contains a slash therefore matched nothing and ran no checks
at all -- silently, since a workflow that does not trigger looks the same
as one with nothing to report.

That covers every stacked pull request in this series (all but the first
target a claude/... base and have zero check runs), and would equally
cover anything based on a feat/ or fix/ prefix.

`**` matches across slashes, which is what the filter meant.
AGENTS.md and CLAUDE.md were near-identical files kept in sync by hand;
the diff was two lines and had already started drifting (the trailer ban
landed in one of them only). AGENTS.md is now the single source and
CLAUDE.md is an @AGENTS.md import plus the one genuinely Claude-specific
line, which is the pattern the ecosystem settled on.

Two runtimes read neither file, so they now get thin pointers:
.github/copilot-instructions.md and GEMINI.md.

Skills are exposed at .agents/skills, the cross-tool discovery path that
Codex, Claude Code and Copilot CLI all resolve, via a symlink to the
canonical copy under plugins/committy/skills -- no duplication. A test
guards the symlink, because a checkout that materialises it as a plain
file would break discovery silently.

Skill frontmatter gains the spec's optional fields: license,
allowed-tools (so security-conscious runtimes can pre-authorise exactly
what each skill needs) and metadata carrying a skill version independent
of the CLI version.

committy-release referenced siblings with Codex's `$skill` syntax inside
a body shared by both runtimes; they are now named plainly and each
runtime resolves them its own way.

The COMMITTY_NONINTERACTIVE docs test now accepts a file that defers to
AGENTS.md rather than restating it, which is what the split requires.
AGENTS.md asks for public-seam tests over "machine output, exit codes,
Git behavior, hook protocol, and plugin manifests". The first four were
covered; nothing compiled or executed the manifests, so a typo in any of
the six JSON and Markdown files shipped silently.

Nine tests: both marketplace manifests parse and their declared sources
resolve to real directories carrying the expected plugin manifest; the
Claude and Codex plugin manifests agree on name, version and license;
the Codex skills path resolves; skill frontmatter uses only the keys the
Agent Skills spec allows and carries the two required ones; a skill's
frontmatter name matches its directory; descriptions carry a trigger
rather than just a capability; no skill body uses runtime-specific
invocation syntax, since that body is shared by Codex and Claude Code;
and every skill is reachable from the cross-tool .agents/skills path.

Each assertion was mutation-checked -- a bogus frontmatter key and a
marketplace source pointing at a missing directory both fail with the
message that names the problem.
…rdrail

The workflow `hooks install --with-ci` writes for users pinned
actions/checkout@v4 and ran `cargo install committy --locked` on every
pull request -- a multi-minute Rust build per run, uncached, with no
toolchain pin. It now pins checkout@v5, pins the toolchain, caches the
build, and takes non-interactive mode from COMMITTY_NONINTERACTIVE so the
command cannot be broken by flag placement. The repository's own three
workflows carried the same stale checkout pin and are bumped too.

The local PreToolUse guardrail grepped the raw tool input for nine
substrings, which made it both over- and under-inclusive. Over: it
matched the *text* of a command rather than an invocation, so writing
documentation that merely named a push was refused -- including,
circularly, the heredoc that would have replaced this file -- and it
refused the push-with-upstream form the repository's own agent
instructions require. Under: two spaces between git and the subcommand,
an `env X=1` prefix, or `git -C .` all slipped through, so it never was
a control. It now resolves the command field (with a jq-free fallback),
discards heredoc bodies, returns early unless git is invoked, and refuses
only what destroys history or uncommitted work: unguarded force-push
(--force-with-lease allowed), hard reset, forced clean, forced branch
delete, and bulk working-tree discards. Plain push is left to the
pre-push hook, which is policy-engine backed.

PARTIALLY VALIDATED: the full behaviour matrix could not be run from the
session that wrote the hook, since executing one's own permission hook is
refused as self-modification. Observed in normal use: ordinary commands
and a plain push pass, and `git branch -D` was correctly refused. The
remaining deny cases are unverified and a reviewer should confirm them.

Also removes the roadmap page, which carried frontmatter and no body and
published as an empty page.
test_tag_respects_repo_git_overrides_and_cli_can_override_them asserts
that a repo-configured gpg.program is consulted, by pointing it at a
script that always fails and expecting the tag creation to fail with it.

That only holds when gpg.format is openpgp. On a machine whose global
config sets gpg.format=ssh, git routes signing to gpg.ssh.program, the
stub never runs, the tag is signed successfully by the ssh signer, and
the assertion evaporates -- the test fails claiming Committy ignored the
override when Committy had passed it through correctly. Verified: the tag
git produced in that environment carried a valid SSH signature.

The test now pins gpg.format=openpgp alongside the other overrides, so it
exercises the path it means to exercise regardless of the host.

Separately, the three config::hierarchy tests call MergedConfig::load,
which reads the user config from $HOME. The tests in config.rs mutate
HOME and are marked #[serial]; these were not, so they could read a
half-written or relocated user config while one of those held it, and
MergedConfig::load(...).unwrap() would panic. They are now serial too.

Caveat on that second change: it closes a real race but is unproven. The
failure was observed once, and did not recur in thirteen subsequent runs
(eight lib-only, five full) even with the fix reverted, so causality is
reasoned rather than demonstrated. It is cheap and correct regardless --
a test reading global state that other tests mutate belongs in the same
serial group.
…t caught

The rewritten guardrail shipped unvalidated: nothing executed it, so its
deny rules were assumed rather than checked. That matters more than for
ordinary code, because a guardrail that silently stops refusing looks
exactly like one that works.

Twelve tests feed the hook the same JSON the harness does and assert the
exit code. Writing them found three defects in the hook, all now fixed:

- `git push -f origin main` was not refused. The force check was one
  ordered regex whose `-f` alternative required a preceding space, which
  the `push ` match had already consumed. Force detection is now two
  independent conditions -- a push, and a force flag anywhere -- so flag
  order and adjacency stop mattering.
- A heredoc feeding an interpreter was dropped along with every other
  heredoc body, so `bash <<EOF` / `git reset --hard` / `EOF` sailed
  through. Heredoc bodies are still discarded when the opener writes to a
  file, but scanned when it names a shell or interpreter.
- Quoted prose naming a dangerous command is still refused. This one is
  deliberate and now recorded as a decision: stripping quoted segments
  would fix it and would also let `bash -c "git reset --hard"` through,
  and for a guardrail a false refusal costs far less than a false pass.

Coverage includes the variants the previous substring hook missed
entirely -- doubled spaces, a leading env assignment, `git -C .`, and a
git call after `&&`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants