Skip to content

feat(hooks): refuse a decision written into code at edit time - #541

Merged
cdeust merged 1 commit into
mainfrom
feat/decision-gate-hook
Sep 9, 2026
Merged

feat(hooks): refuse a decision written into code at edit time#541
cdeust merged 1 commit into
mainfrom
feat/decision-gate-hook

Conversation

@cdeust

@cdeust cdeust commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

The wiki is the only decision index and code carries a pointer, never the decision itself. Nothing enforced that. scripts/craftsmanship_decisions.py checks the converse property, that a source: citation resolves to a real decision, so prose written where a pointer belongs passes every gate.

It just did, twice, in scripts/setup.sh during PR #539. A reviewer caught one block; the owner caught the convention breach. Both are after the fact, which is the problem this PR addresses rather than the two blocks themselves.

Closes #

No issue: this came out of the #539 review conversation directly.

Why not CI

A check in ci.yml reports the violation once it is written, committed and pushed. The cost is already paid and the fix is a second commit. Hooks exist to refuse the action, so this is a PreToolUse hook that exits 2.

Why not .claude/hooks/

.gitignore excludes .claude/* with only settings.json excepted, so a hook written there is one machine's local configuration, versioned nowhere and distributed to nobody. The first attempt at this landed there and would have protected exactly one working copy. Cortex's own hooks are declared in .claude-plugin/plugin.json and implemented under mcp_server/hooks/, run through scripts/launcher.py. That is the only placement where the rule ships.

How it decides

Detecting a decision in general is not possible; detecting its shape is. A decision is prose, and prose in code is a long run of consecutive comment lines. The threshold is measured rather than chosen: over the tracked tree, excluding headers and tests, only a handful of files reach eight, and the two blocks that motivated this were ten and twelve.

A line carrying a source: pointer never counts toward a run. The refusal names wiki_adr and the pointer form to leave behind, so the correction is mechanical.

Three exemptions, each load-bearing:

  • The file header, defined as a run with nothing executable before it. Stating it that way avoids a line-number threshold, which would be a number to tune and a place for a decision to hide just inside. An earlier draft used a line number and I could smuggle a block past it, which is why it is gone.
  • Test files, which narrate scenarios at length and hold no decisions. The repo already exempts them from the file-size cap.
  • Any block already present in the file. Only what the call introduces is judged, so editing an unrelated part of a legacy file is never refused. Ten existing files carry a block over the threshold and are grandfathered.

CORTEX_DECISION_GATE=off releases one call and asks for a reason. A gate with no escape hatch gets disabled wholesale.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would change existing behavior)
  • Refactor (no functional change; rules/coding-standards.md compliance)
  • Documentation only
  • Audit-finding closure (cite the finding ID)

Test plan

tests_py/hooks/test_decision_gate.py, eleven tests, all passing. They cover the threshold and the line below it, all three exemptions, the override, a // language, malformed input, and the exit-2 contract through the module entry point.

Two mutations they would catch that are worth naming, because both are how this rule would quietly die: raising the threshold (the "one line under the limit" test pins it from below, the block test from above), and widening the header exemption back into a line-number test (test_allows_a_long_header_on_a_new_file passes only because the run has nothing executable before it, and the grandfathering test puts executable code first on purpose).

One mutation they would NOT catch: a change to the marker table that drops a language. That is a data table, and a test enumerating it would restate rather than verify.

Verified end to end through scripts/launcher.py, exactly as plugin.json invokes it, on a real file: exit 2 with the refusal on stderr.

  • All existing tests pass.
  • New tests added for new behavior.
  • Mutation survival check: I considered what mutations would NOT be caught.
  • Manual verification of any UI / CLI / MCP-tool behavior changes.

Audit notes

Self-review only; both blocks that motivated this were caught by the review passes on #539, whose findings are what this PR generalizes.

  • Engineering review: not run separately, the change is one new module plus a manifest entry
  • Genius review: not run separately
  • Outstanding deferred findings: ten files carry a pre-existing body block over the threshold, grandfathered by the ratchet and candidates for migration to the wiki

Coding-standards compliance

  • §2.2 Layer dependency direction preserved.
  • §3.2 No untyped dicts at boundaries beyond the hook event payload, which is external JSON.
  • §4.1 No file > 500 lines. The new module is 213 lines, under the repo's stricter 300 cap.
  • §4.2 No function > 50 lines.
  • §4.4 No function with > 4 parameters.
  • §7 Local reasoning preserved.
  • §8 Constants carry a source annotation.
  • §9 No dead code, no TODOs without issue references.

Breaking changes

None for library consumers. Behaviourally, an agent editing a file in a Cortex-enabled session can now be refused. The override and the grandfathering are what keep that from being disruptive.

Screenshots / logs

[decision-gate] BLOCKED: /tmp/dgtest/live.sh:5 would add 8 consecutive comment
lines. A block that long is a decision, and the wiki is the only decision index.
[decision-gate] Record it with the cortex wiki_adr tool, then leave only
`# source: ADR-NNNN` here. If this is genuinely not a decision (a worked
example, a data table), split it or set CORTEX_DECISION_GATE=off for the call
and say why.
  launcher exit=2

Reviewer checklist

  • CHANGELOG.md updated under the appropriate section.
  • Documentation updated: ADR-1060 records the decision and its mirror is regenerated.
  • No secrets or PII in the diff.
  • CI passes on the latest commit.

🤖 Generated with Claude Code

The wiki is the only decision index and code carries a pointer, but
nothing enforced it. craftsmanship_decisions.py checks the converse,
that a `source:` citation resolves, so prose written where a pointer
belongs passed every gate. It just did, twice, in scripts/setup.sh:
a reviewer caught one block, the owner caught the convention breach,
both after the fact.

CI is the wrong layer. A check in ci.yml reports the violation once it
is written, committed and pushed, so the cost is already paid. Hooks
refuse the action instead, which is what they are for.

Placement is half the point. `.claude/hooks/` is excluded by
.gitignore, so a hook written there is one machine's local config,
versioned nowhere. This one is declared in .claude-plugin/plugin.json
next to the other Cortex hooks and ships with the plugin.

Detection is by shape, not semantics: a decision is prose, and prose in
code is a long run of consecutive comment lines. The threshold comes
from measuring the tracked tree, not from taste. Exempt: the file
header, defined as a run with nothing executable before it so there is
no line number to tune or hide inside; tests; and any block already
present, so a legacy file stays editable elsewhere. Only what the call
introduces is judged. CORTEX_DECISION_GATE=off releases one call and
asks for a reason, because a gate with no escape hatch gets disabled
wholesale.

Eleven tests cover the threshold, both exemptions, the ratchet, the
override, a `//` language, malformed input, and the exit-2 contract.

Decision recorded in ADR-1060.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cdeust
cdeust merged commit 74aeee4 into main Sep 9, 2026
44 of 46 checks passed
@cdeust
cdeust deleted the feat/decision-gate-hook branch September 9, 2026 11:36
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.

1 participant