A coding agent should not have to read a CI workflow or hunt through package manifests to learn how to check a change. Every covered repository publishes one command interface for routine work and documents it in AGENTS.md.
Keep one repository command interface
Use the tool the repository already uses. The standard does not require just, make, or another wrapper merely to give an agent shorter commands.
- Use native package or build commands when they already provide clear repository-level entry points.
- Use a root task runner when one outcome spans packages, languages, or two or more ordered commands.
- Put branching, loops, and platform handling in a checked-in script. Keep the public task entry point short.
- Make CI call the same entry points that people and agents run locally.
- Let provider-specific commands or skills point to the repository task. They must not become a second implementation.
These are all valid interfaces:
tradovate/serversuses directsbtcommands.NT-NinjaTrader/ninja-web-traderuses root and package-levelpnpmscripts.NT-NinjaTrader/innolab-adauses a rootJustfileto compose Cargo and infrastructure checks.
Tell the agent which interface is authoritative:
## Commands
Use root `pnpm` scripts for repository-wide work. Use `pnpm --filter <package>`
for one workspace package. Scripts under `scripts/ci/` implement multi-step checks;
call them through `pnpm` instead of invoking them directly.Publish an exact command contract
The Commands section of the root AGENTS.md lists every applicable outcome below. Give the exact command and a result the reader can observe. If an outcome does not apply, say why instead of silently omitting it.
| Outcome | Requirement |
|---|---|
| Discover commands | List the available repository tasks without opening an interactive interface. |
| Build | Produce the normal local artifact or compile every supported target. |
| Format | Apply the repository formatter. Name this as a file-changing command. |
| Check formatting | Check formatting without changing tracked files. |
| Lint or static analysis | Run the standard language and repository linters. |
| Type-check | Check types without requiring a production build when the stack supports it. |
| Check edited files | Format and inspect named files after an agent edits them. |
| Check staged files | Run the fast, non-mutating checks required before a commit. |
| Focused tests | Select one test, package, module, or affected area. |
| Full tests | Run the complete local test suite. |
| Generate and check | Regenerate checked-in output and fail when it leaves an unexpected diff. Use only when the repository has generated files. |
| Full local verification | Compose every local check required before handoff. |
| Clean build output | Remove declared generated build output. This command is optional. |
The command map belongs in AGENTS.md, even when the runner can list its own tasks:
## Commands
Run commands from the repository root.
| Outcome | Command | Done when |
| --- | --- | --- |
| List tasks | `just --list` | The repository recipes print and the command exits zero. |
| Build | `just build` | Every workspace package compiles. |
| Check formatting | `just fmt-check` | The command exits zero and leaves no diff. |
| Focused tests | `just test-package <package>` | Tests for the named package pass. |
| Full tests | `just test-all` | Both local test lanes pass. |
| Full local verification | `just verify` | Formatting, lint, and all local tests pass. |
| Type-check | Not applicable | Rust type-checking is part of `just check`. |Test the command interface
Write automated tests for every new or changed canonical command before other people or agents use it. Exercise the public entry point, not only the script or function behind it.
The tests cover:
- documented arguments and selectors;
- a successful exit;
- a failed child process and its preserved nonzero exit code;
- missing or unsupported arguments;
- documented output;
- file changes for format, generate, and clean commands.
Use the test framework that fits the interface:
- Test Bash commands with Bats.
- Test TypeScript commands with Vitest or Jest.
- Test Python commands with pytest.
- Test Rust commands with the Rust test harness.
- Test task-runner recipes through Bats or the repository’s standard test framework.
The example keeps the public Bash interface small and replaces pnpm with a controlled executable during the contract test:
#!/usr/bin/env bash
set -euo pipefail
pnpm_command="${TASK_PNPM:-pnpm}"
usage() {
printf 'Usage: scripts/task {list|test <package>|verify}\n'
}
case "${1:-}" in
list)
printf 'test <package>\nverify\n'
;;
test)
if [[ $# -ne 2 ]]; then
usage >&2
exit 64
fi
exec "$pnpm_command" --filter "$2" test
;;
verify)
if [[ $# -ne 1 ]]; then
usage >&2
exit 64
fi
exec "$pnpm_command" verify
;;
*)
usage >&2
exit 64
;;
esac#!/usr/bin/env bats
setup() {
export TASK_CALLS="$BATS_TEST_TMPDIR/calls"
export TASK_PNPM="$BATS_TEST_TMPDIR/pnpm"
cat >"$TASK_PNPM" <<'SCRIPT'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$TASK_CALLS"
exit "${FAKE_PNPM_EXIT:-0}"
SCRIPT
chmod +x "$TASK_PNPM"
}
@test "focused tests pass the package to pnpm" {
run ./scripts/task test @nt/orders
[ "$status" -eq 0 ]
run cat "$TASK_CALLS"
[ "$output" = "--filter @nt/orders test" ]
}
@test "verify preserves a child failure" {
export FAKE_PNPM_EXIT=7
run ./scripts/task verify
[ "$status" -eq 7 ]
}
@test "a missing package is a usage error" {
run ./scripts/task test
[ "$status" -eq 64 ]
[[ "$output" == Usage:* ]]
}These tests prove the wrapper contract. They do not prove that the real build, lint, or test command works. CI also runs each canonical command against the repository before the command ships for wider use.
Run checks before commits and pushes
Every covered repository uses a Git hook manager with checked-in configuration and installs its hooks during setup. Use pre-commit, Husky, or the tool already used by the repository. Configure both boundaries:
- The pre-commit hook runs fast, non-mutating checks against staged files.
- The pre-push hook runs full local verification.
Hooks call the canonical repository commands. Do not copy formatter, linter, or test logic into hook files. CI repeats the same checks because a missing local hook must not weaken a merge gate.
These configurations make the two boundaries explicit:
default_install_hook_types: [pre-commit, pre-push]
repos:
- repo: local
hooks:
- id: check-staged
name: Check staged files
entry: scripts/task check-staged
language: system
pass_filenames: false
stages: [pre-commit]
- id: verify-before-push
name: Verify before push
entry: scripts/task verify
language: system
pass_filenames: false
stages: [pre-push]# .husky/pre-commit
scripts/task check-staged
# .husky/pre-push
scripts/task verifyDocument installation and behavior in AGENTS.md:
Local Git hooks:
- `pnpm install` installs the checked-in Husky hooks.
- Before each commit, `scripts/task check-staged` checks staged files without changing them.
- Before each push, `scripts/task verify` runs full local verification.
- Do not bypass either hook with `--no-verify`. Fix the failure.Test the hook configuration before shipping it. The tests prove that setup installs both hooks, each hook calls the documented command, and a failed command blocks the Git operation.
Provide focused and full paths
A repository needs both. Running the whole monorepo for every edit wastes time. Running only one test before handoff misses failures elsewhere.
Focused commands accept the smallest useful selector for the stack:
- a test file or class;
- a package or module;
- a workspace filter;
- a changed-file set for formatting or linting.
The full verification command runs every local merge gate that applies to the repository. It may call other public tasks, but it must not hide a different implementation of those checks.
Document the selection syntax, including its easy-to-miss details:
Focused verification:
- One Web package: `pnpm --filter <package> lint`
- One test file: `pnpm --filter <package> test <path>`
- One Scala module: `sbt "project <module>" test`
- One Scala test class: `sbt "testOnly *<TestClassName>"`
Do not insert `--` before the Vitest path. Run the full local check with
`pnpm verify` before handoff.Make commands safe for automation
Every canonical command:
- runs from the repository root;
- finishes without a prompt, watcher, editor, or graphical interface;
- returns zero only when the requested outcome succeeds;
- preserves a nonzero exit code from a failed child command;
- prints the failed check and enough context to rerun it;
- behaves the same in a terminal, a sandbox, and CI;
- leaves tracked files unchanged unless its name says it formats or generates files.
Report skipped and blocked checks honestly. SKIP means the check does not apply to the selected change. A missing tool, credential, service, or network route is BLOCKED and returns nonzero.
PASS format-check
PASS lint
SKIP codegen: no schema files changed
BLOCKED integration-test: local MySQL is not ready
Verification failed. Start MySQL with `just services-start`, then run `just verify`.Do not make the caller parse prose to learn whether verification passed. A summary is useful, but the process exit code remains the contract.
Keep destructive work out of verification
Build, test, lint, and verification commands do not deploy, publish, apply infrastructure, run migrations against shared systems, or delete source files. Put those operations behind separate commands and the approval rules in AGENTS.md.
An optional clean command removes only named build output and caches owned by the repository. It does not remove untracked source, local environment files, credentials, or data outside the repository.
Cleanup:
- `just clean-build` removes `target/` and `.cache/test/` only.
- It does not remove `.env`, untracked files, containers, volumes, or databases.
- Stop before any broader cleanup. Do not substitute `git clean`.Reuse the commands in CI
CI invokes the same full verification entry point. It may split tests into documented shards, but each shard uses the same underlying task and selection syntax as local work.
Some gates need GitHub or a managed service. List them as CI-only checks in AGENTS.md. Do not claim the local verification command ran them, and do not add a fake local command that always skips them.
- name: Run repository verification
run: just verifyWhen a local task changes, update its CI caller and AGENTS.md in the same pull request.
Give every approved agent the same task
Claude Code, Codex, Cursor, and OpenCode use the same repository commands. Do not maintain one test command per provider. Their non-interactive launch syntax differs, but the task does not.
claude -p \
'Read AGENTS.md. Run the focused tests for the changed package, then run full local verification. Report skipped or blocked checks.'codex exec \
'Read AGENTS.md. Run the focused tests for the changed package, then run full local verification. Report skipped or blocked checks.'cursor-agent -p \
'Read AGENTS.md. Run the focused tests for the changed package, then run full local verification. Report skipped or blocked checks.'opencode run \
'Read AGENTS.md. Run the focused tests for the changed package, then run full local verification. Report skipped or blocked checks.'Do not add a permission-bypass flag merely to make an unattended run pass. Configure the agent’s approved permissions and let a denied action fail. When broader permissions are justified, run the agent in an approved sbx sandbox instead of removing host protections.
Check files after an agent edits them
Coding agents have lifecycle hooks that can run a repository command immediately after an edit. Configure every approved agent to call the same checked-in adapter. The adapter reads the provider’s event, finds the edited files, and calls the canonical edited-file command.
Keep this hook quick and file-scoped:
- Run the formatter on the edited file.
- Run file-scoped lint and static analysis, including complexity and likely-bug checks configured by the repository.
- Print concise failures so the agent can correct the edit in the same turn.
- Leave full tests and full verification to the normal task and pre-push paths.
Immediate feedback saves model turns and tokens. The agent sees a formatting, complexity, or correctness failure while the edit is fresh instead of finding it at the end and reading the file again.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "./scripts/hooks/check-agent-edit claude"
}
]
}
]
}
}{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "./scripts/hooks/check-agent-edit codex",
"statusMessage": "Checking edited files"
}
]
}
]
}
}{
"version": 1,
"hooks": {
"afterFileEdit": [
{
"command": "./scripts/hooks/check-agent-edit cursor"
}
]
}
}import type { Plugin } from "@opencode-ai/plugin"
export const CheckAgentEdit: Plugin = async ({ $ }) => ({
"tool.execute.after": async (input) => {
if (!["edit", "write", "apply_patch"].includes(input.tool)) return
const event = JSON.stringify({
tool_name: input.tool,
tool_input: input.args,
})
await $`printf %s ${event} | ./scripts/hooks/check-agent-edit opencode`
},
})The adapter requires Bash, Git, and jq. It accepts the provider name as its only argument and reads the hook event from standard input:
#!/usr/bin/env bash
set -euo pipefail
provider="${1:-}"
payload="$(cat)"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(git -C "$script_dir" rev-parse --show-toplevel)"
paths=()
read_json() {
local filter="$1"
local error_message="$2"
local value
if ! value="$(jq --exit-status --raw-output "$filter" <<<"$payload")"; then
printf '%s\n' "$error_message" >&2
exit 2
fi
printf '%s' "$value"
}
add_patch_paths() {
local patch="$1"
local line
while IFS= read -r line; do
case "$line" in
"*** Add File: "*) paths+=("${line#"*** Add File: "}") ;;
"*** Update File: "*) paths+=("${line#"*** Update File: "}") ;;
"*** Delete File: "*) paths+=("${line#"*** Delete File: "}") ;;
"*** Move to: "*) paths+=("${line#"*** Move to: "}") ;;
esac
done <<<"$patch"
}
case "$provider" in
claude)
paths+=("$(read_json '.tool_input.file_path | strings | select(length > 0)' 'Claude Code hook event has no tool_input.file_path.')")
;;
cursor)
paths+=("$(read_json '.file_path | strings | select(length > 0)' 'Cursor hook event has no file_path.')")
;;
opencode)
tool_name="$(read_json '.tool_name | strings | select(length > 0)' 'OpenCode hook event has no tool_name.')"
case "$tool_name" in
edit | write)
paths+=("$(read_json '.tool_input.filePath | strings | select(length > 0)' 'OpenCode edit event has no tool_input.filePath.')")
;;
apply_patch)
patch="$(read_json '.tool_input.patchText | strings | select(length > 0)' 'OpenCode apply_patch event has no tool_input.patchText.')"
add_patch_paths "$patch"
;;
esac
;;
codex)
patch="$(read_json '.tool_input.command | strings | select(length > 0)' 'Codex hook event has no tool_input.command.')"
add_patch_paths "$patch"
;;
*)
printf 'Unsupported provider: %s\n' "$provider" >&2
exit 64
;;
esac
checked_paths=()
for path in "${paths[@]}"; do
if [[ "$path" == /* ]]; then
candidate="$path"
else
candidate="$repo_root/$path"
fi
if ! path_dir="$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P)"; then
continue
fi
candidate="$path_dir/$(basename "$candidate")"
if [[ "$candidate" != "$repo_root/"* ]]; then
printf 'Edited path is outside the worktree: %s\n' "$path" >&2
exit 65
fi
if [[ -L "$candidate" ]]; then
printf 'Edited path is outside the worktree or is a symlink: %s\n' "$path" >&2
exit 65
fi
if [[ ! -f "$candidate" ]]; then
continue
fi
relative_path="${candidate#"$repo_root/"}"
duplicate=false
for checked_path in "${checked_paths[@]}"; do
if [[ "$checked_path" == "$relative_path" ]]; then
duplicate=true
break
fi
done
if [[ "$duplicate" == false ]]; then
checked_paths+=("$relative_path")
fi
done
if [[ ${#checked_paths[@]} -eq 0 ]]; then
exit 0
fi
task="${CHECK_AGENT_EDIT_TASK:-$repo_root/scripts/task}"
cd "$repo_root"
if "$task" check-files -- "${checked_paths[@]}"; then
exit 0
else
task_status=$?
printf 'Edited-file checks failed with exit %d. Fix the reported issues before continuing.\n' "$task_status" >&2
exit 2
fiClaude Code, Codex, and Cursor send the hook event as JSON on standard input. The OpenCode plugin converts its tool event to the same input. The adapter handles each provider’s schema, refuses paths outside the worktree, ignores deleted files, and calls a command such as scripts/task check-files -- <path...>.
The examples/task-automation folder contains the executable adapter, Bats tests, and sanitized events captured from controlled edits by all four approved agents. A smoke test compares the code block above with the executable file. If one changes without the other, the test fails.
Tell agents what the hook does and how to respond:
Agent edit hooks:
- After an edit, the repository formats and checks the files changed by the agent.
- Treat hook output as part of the edit result. Fix each reported issue before continuing.
- Do not run full verification after every edit. Run `scripts/task verify` before handoff.Test the adapter with recorded events from all four providers. Cover multiple edited files, renamed and deleted files, paths with spaces, files outside the worktree, formatter changes, and linter failures. Agent hooks improve the editing loop. They do not replace Git hooks or CI.
References
tradovate/servers, directsbtcommands with module and test selectors.NT-NinjaTrader/ninja-web-trader, root package scripts and workspace filters.NT-NinjaTrader/innolab-ada, a root task runner that composes focused and full checks.- Claude Code non-interactive mode, the
claude -psyntax used above. - Codex non-interactive mode, the
codex execsyntax used above. - Cursor CLI, the
cursor-agent -psyntax used above. - OpenCode CLI, the
opencode runsyntax used above. - Bats, the Bash command test framework used above.
pre-commitGit hook stages, including pre-commit and pre-push installation.- Husky, checked-in Git hooks for Node repositories.
- Claude Code hooks, including
PostToolUsefor edited files. - Codex hooks, including project-local
PostToolUsehooks. - Cursor hooks, including
afterFileEdit. - OpenCode plugins, including tool lifecycle hooks.
- Agent instructions, where the command table lives.
- Development environment, how a clean clone obtains the tools these commands call.
- Quality gates, the checks that block merge.
- CI/CD, how CI runs and enforces them.