diff --git a/CHANGELOG.md b/CHANGELOG.md index 5302bfd..dcfef6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## Unreleased +### Added + +- Cursor `hook-gate-doctrine.mdc` rule template (`alwaysApply`) with hook gate triage: + invalid JSON vs plan binding vs deterministic path errors. +- `scripts/test-mutation-hook-stdout.sh` to assert the mutation gate emits one JSON object. + +### Changed + +- Mutation hook shell adapter: emit codex/claude allow JSON; capture + `verify-mutation-hook` stdout/stderr off the hook pipe so Cursor receives valid JSON. +- Cursor `agent-governance.mdc` template: hooks are operational directions, not + obstacles to bypass. +- `docs/agent-environment-matrix.md` Cursor row documents hook operational doctrine. + +### Fixed + +- `source-limit check` skips generated `keysymdef.go` and vendored `xorg-deps/` trees + in consumer repositories. + ## 2.1.0 - 2026-06-01 ### Added diff --git a/MANIFEST.toml b/MANIFEST.toml index 23be54b..0b42b82 100644 --- a/MANIFEST.toml +++ b/MANIFEST.toml @@ -86,6 +86,10 @@ config_key = "mutation_gate.hook_script_path" template = "templates/.cursor/rules/agent-governance.mdc.template" destination = ".cursor/rules/agent-governance.mdc" +[[render]] +template = "templates/.cursor/rules/hook-gate-doctrine.mdc.template" +destination = ".cursor/rules/hook-gate-doctrine.mdc" + [[render]] template = "templates/.claude/settings.json.template" destination = ".claude/settings.json" @@ -196,6 +200,7 @@ tracked_for_ci = [ ".agents/skills/task-registry-flow", ".agents/skills/task-registry-flow.md", ".cursor/rules/agent-governance.mdc", + ".cursor/rules/hook-gate-doctrine.mdc", ".cursor/hooks.json", ".cursor/hooks/gap-closure-gate.sh", "AGENTS.md", diff --git a/REQUIREMENTS.toml b/REQUIREMENTS.toml index 93dd72a..8f47b3a 100644 --- a/REQUIREMENTS.toml +++ b/REQUIREMENTS.toml @@ -28,6 +28,7 @@ required = [ ".agents/skills/task-registry-flow", ".agents/skills/task-registry-flow.md", ".cursor/rules/agent-governance.mdc", + ".cursor/rules/hook-gate-doctrine.mdc", ".cursor/hooks.json", ".cursor/hooks/gap-closure-gate.sh", "AGENTS.md", diff --git a/docs/agent-environment-matrix.md b/docs/agent-environment-matrix.md index da147d9..5015efe 100644 --- a/docs/agent-environment-matrix.md +++ b/docs/agent-environment-matrix.md @@ -8,7 +8,7 @@ are guardrails. |-------------|--------------|--------------| | Codex | `AGENTS.md`, `.codex/config.toml`, `.codex/hooks.json`, `.agents/skills//SKILL.md` | `plugins/agent-governance/scripts/status.sh --env codex`; Codex hooks require a trusted project | | Antigravity CLI | `GEMINI.md`, `.agents/hooks.json`, `.agents/skills/*.md`, `.agents/plugins/agent-governance` | `agy --version` must be 1.0.3 or newer; `agy plugin validate plugins/agent-governance` must process hooks | -| Cursor | `.cursor/rules/agent-governance.mdc`, `.cursor/skills//SKILL.md`, `.cursor/hooks.json` | `plugins/agent-governance/scripts/status.sh --env cursor`; `cursor-agent --plugin-dir plugins/agent-governance` can load local plugin code | +| Cursor | `.cursor/rules/agent-governance.mdc`, `.cursor/rules/hook-gate-doctrine.mdc` (always-on gate triage), `.cursor/skills//SKILL.md`, `.cursor/hooks.json` | `plugins/agent-governance/scripts/status.sh --env cursor`; hooks are **operational directions** at mutation time (deny = missing governance step; invalid JSON = gate repair on active hook target, not a new plan); optional user-level governed subagents complement repo-local skills/hooks | | Claude Code | `CLAUDE.md`, `.claude/settings.json`, `.claude/skills//SKILL.md` | `plugins/agent-governance/scripts/status.sh --strict`; `.claude/settings.json` must delegate PreToolUse to the canonical mutation gate | Do not add compatibility shims for old workspace `.gemini/settings.json`, stale `.codex/settings.toml`, or `.codex/hooks/user-plan-approval.toml`. Current install removes those generated paths. diff --git a/rust/task-registry-flow-cli/src/source_limit.rs b/rust/task-registry-flow-cli/src/source_limit.rs index b60c9e2..b057c46 100644 --- a/rust/task-registry-flow-cli/src/source_limit.rs +++ b/rust/task-registry-flow-cli/src/source_limit.rs @@ -562,6 +562,7 @@ fn skip_dir(path: &str) -> bool { | "out" | "venv" | "vendor" + | "xorg-deps" ) }) } @@ -586,6 +587,7 @@ fn skip_file(path: &str) -> bool { | "deno.lock" | "flake.lock" | "go.sum" + | "keysymdef.go" ) || path == "docs/task-registry/events.jsonl" || path.starts_with("docs/task-registry/archive/") } diff --git a/scripts/test-mutation-hook-stdout.sh b/scripts/test-mutation-hook-stdout.sh new file mode 100755 index 0000000..0e39acf --- /dev/null +++ b/scripts/test-mutation-hook-stdout.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Assert the canonical mutation gate emits exactly one JSON object on stdout. +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +cd "$root" + +hook="${MUTATION_HOOK_SCRIPT:-tools/agent-governance/pre-tool-use-gap-closure.sh}" +if [[ ! -f "$hook" ]]; then + echo "FAIL: mutation hook not found: $hook" >&2 + exit 1 +fi + +out="$(printf '{}' | GOVERNANCE_HOOK_FORMAT=cursor bash "$hook" --format cursor)" +if printf '%s' "$out" | grep -q '^TASK_VERIFY'; then + echo "FAIL: verify-mutation-hook leaked to stdout: $out" >&2 + exit 1 +fi +printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' +echo "ok: single valid JSON on hook stdout" diff --git a/templates/.cursor/rules/agent-governance.mdc.template b/templates/.cursor/rules/agent-governance.mdc.template index 4ef5525..e7f1e3d 100644 --- a/templates/.cursor/rules/agent-governance.mdc.template +++ b/templates/.cursor/rules/agent-governance.mdc.template @@ -9,7 +9,7 @@ Use the plugin-owned registry only: `{{TASK_REGISTRY_CLI}} validate`, `status`, Keep source, scripts, configs, docs, templates, and governance files at or below 1600 lines. Treat this as a design-time rule. Before adding behavior to a violating file, run `{{TASK_REGISTRY_CLI}} source-limit plan --path ` and split first. -Cursor hooks live in `.cursor/hooks.json`; they are runtime guardrails, not the source of truth. CI and `{{TASK_REGISTRY_CLI}} source-limit check` are authoritative. +Cursor hooks in `.cursor/hooks.json` enforce repo law at mutation time. Treat hook outcomes as **operational directions** (see `.cursor/rules/hook-gate-doctrine.mdc`): a deny names the missing governance step; **invalid JSON ≠ need another plan** — fix stdout on the existing active hook target. Do not disable hooks or skip the mutation gate. Policy authority remains in `.codex/agent-governance.toml`, `docs/task-registry.toml`, and CI; hooks apply that policy live. Use exact active or planned task targets. Ambiguous shell redirections, compact redirects, and inline write calls without deterministic paths fail closed. A terminal task is immutable after `completed` or `cancelled`; changed follow-up work needs a new `task_id`. diff --git a/templates/.cursor/rules/hook-gate-doctrine.mdc.template b/templates/.cursor/rules/hook-gate-doctrine.mdc.template new file mode 100644 index 0000000..08d1554 --- /dev/null +++ b/templates/.cursor/rules/hook-gate-doctrine.mdc.template @@ -0,0 +1,22 @@ +--- +description: Hook gate triage — invalid JSON vs plan binding vs path errors. +alwaysApply: true +--- + +# Hook gate triage (operational directions) + +Cursor hooks enforce repo law at mutation time. Read `.cursor/rules/agent-governance.mdc` for the full workflow. **Do not disable hooks or use subagents or external terminals to skip registry binding** — except gate self-repair on `{{MUTATION_HOOK_SCRIPT}}` when the hook cannot emit valid JSON. + +## Triage table + +| Symptom | Meaning | Response | +|---------|---------|----------| +| **invalid JSON** from hook | Gate script stdout is polluted (e.g. `TASK_VERIFY_MUTATION_HOOK ok` before JSON) | **Not** “create another plan.” Fix stdout on the **existing active hook target** (capture `verify-mutation-hook` output off the hook pipe). Then retry inside Cursor. | +| **not bound to an active registry task target** | Path not in `[[tasks.targets]]` for an active task | Write/refresh `docs/plans/.md`, run `{{TASK_REGISTRY_CLI}} activate`, edit only listed targets. | +| **did not expose a deterministic target path** | Write/Shell lacked an exact repo-relative path | Use `Write`/`StrReplace` with full path; no vague shell redirects. | + +## When a plan is already active + +Activation unlocks **only** paths in `[[tasks.targets]]`. If those paths still fail with **invalid JSON**, the gate script is broken — repair it first, not the registry. + +Policy authority: `.codex/agent-governance.toml`, `docs/task-registry.toml`, CI. Hooks apply that policy live. diff --git a/templates/tools/agent-governance/pre-tool-use-gap-closure.sh.template b/templates/tools/agent-governance/pre-tool-use-gap-closure.sh.template index dcf5866..9e4fa01 100644 --- a/templates/tools/agent-governance/pre-tool-use-gap-closure.sh.template +++ b/templates/tools/agent-governance/pre-tool-use-gap-closure.sh.template @@ -27,6 +27,7 @@ emit_json() { printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$escaped_reason" ;; codex:allow|claude:allow) + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n' ;; cursor:deny) printf '{"permission":"deny","user_message":%s,"agent_message":%s}\n' "$escaped_reason" "$escaped_reason" @@ -88,9 +89,17 @@ if [[ "$base_verify_cmd" != "$canonical_verify_cmd" ]]; then exit 0 fi -if output="$(.codex/scripts/task-registry verify-mutation-hook --format "$format" 2>&1)"; then +verify_stderr="$(mktemp)" +verify_stdout="$(mktemp)" +trap 'rm -f "${verify_stderr}" "${verify_stdout}"' EXIT + +if .codex/scripts/task-registry verify-mutation-hook --format "$format" >"${verify_stdout}" 2>"${verify_stderr}"; then emit_json allow else + output="$(tr '\n' ' ' <"${verify_stderr}")" + if [[ -z "${output// }" ]]; then + output="$(tr '\n' ' ' <"${verify_stdout}")" + fi emit_deny "mutation gate failed: ${output}" exit 0 fi diff --git a/tools/agent-governance/pre-tool-use-gap-closure.sh b/tools/agent-governance/pre-tool-use-gap-closure.sh index b788b6f..d78dff2 100755 --- a/tools/agent-governance/pre-tool-use-gap-closure.sh +++ b/tools/agent-governance/pre-tool-use-gap-closure.sh @@ -27,6 +27,7 @@ emit_json() { printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$escaped_reason" ;; codex:allow|claude:allow) + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n' ;; cursor:deny) printf '{"permission":"deny","user_message":%s,"agent_message":%s}\n' "$escaped_reason" "$escaped_reason" @@ -88,9 +89,17 @@ if [[ "$base_verify_cmd" != "$canonical_verify_cmd" ]]; then exit 0 fi -if output="$(.codex/scripts/task-registry verify-mutation-hook --format "$format" 2>&1)"; then +verify_stderr="$(mktemp)" +verify_stdout="$(mktemp)" +trap 'rm -f "${verify_stderr}" "${verify_stdout}"' EXIT + +if .codex/scripts/task-registry verify-mutation-hook --format "$format" >"${verify_stdout}" 2>"${verify_stderr}"; then emit_json allow else + output="$(tr '\n' ' ' <"${verify_stderr}")" + if [[ -z "${output// }" ]]; then + output="$(tr '\n' ' ' <"${verify_stdout}")" + fi emit_deny "mutation gate failed: ${output}" exit 0 fi