diff --git a/dist/claude-code/structured-coding/references/standards.md b/dist/claude-code/structured-coding/references/standards.md index 0f2a258..872381d 100644 --- a/dist/claude-code/structured-coding/references/standards.md +++ b/dist/claude-code/structured-coding/references/standards.md @@ -1,15 +1,13 @@ # Project standards configuration -Status: **REPORTS; NEVER BLOCKS.** This release reads the configuration and, -on the `run` command, executes the declared checks and classifies each result. -With the optional `standards` preset installed, it also reports after a direct -`git commit`. It blocks nothing: no commit, no merge, no tool call. Automatic triggering is later work; the target behaviour for a hook -remains in the [hook contract](hook-contract.md). +Status: **REPORTS; NEVER BLOCKS.** Checks run when you invoke `run`, and, with the +optional `standards` preset installed, after a direct `git commit`. Nothing is +blocked: no commit, no merge, no tool call. A clean report is not proof that a +check ran; read the outcomes. -A project uses this to state, once, what is true of its whole codebase, so the -operator does not restate it in every planning conversation. Requirements -specific to one PR stay in that PR's conversation and its frozen design; putting -them here would create a second authority over the same decision. +A project states here what is true of its whole codebase, so the operator does +not restate it in every planning conversation. Requirements specific to one PR +belong in that PR's conversation, not here. ## Files @@ -19,59 +17,36 @@ them here would create a second authority over the same decision. | `.structured-coding/standards.local.md` | overlay | Copy [the template](../standards-template.md) to the base path and edit its -single fenced `json` block. The surrounding prose is never read, which is the -point: it carries the options, the alternatives and the reasons that JSON cannot. -Exactly one top-level `json` block is read. A block nested inside another fence -is prose. Zero blocks, several, or unparsable content is refused, naming the file. +single fenced `json` block. The surrounding prose is never read. Exactly one +top-level `json` block is read; a block nested inside another fence is prose. +Zero blocks, several, or unparsable content is refused, naming the file. Both +files are optional, and with neither the shipped defaults apply. -Both files are optional. With neither present the shipped defaults apply and -behaviour is unchanged. +## Layer and trust are decided separately -## Two determinations, decided separately +Layer comes from the filename. Trust comes from `git ls-files`: a tracked file is +shared, so anything it declares outside the allowlist needs approval; an untracked +file is personal, absent from a fresh clone, so authoring it is the approval. -**Layer comes from the filename.** The base states the project's standard. The -overlay may add and tighten only. - -**Trust comes from `git ls-files`.** A tracked file is shared: any contributor can -change it in a pull request, so a tool it declares outside the allowlist needs -approval. An untracked file is personal: it is absent from a fresh clone and can -only have been written by the machine's owner, so authoring it is the approval. - -The two are independent, which removes the special cases. A tracked overlay is an -overlay that happens to be shared. An untracked base is the base and happens to -be personal. Filename must not decide trust, because `.gitignore` does not apply -to a file Git already tracks, so a repository could otherwise commit a file under -the personal name and have it treated as locally authored. +Filename must not decide trust, because `.gitignore` does not apply to a file Git +already tracks. A tracked overlay is simply a shared overlay; an untracked base is +simply a personal base. ## Resolution -Defaults, then base, then overlay. The base may relax the shipped defaults: those -defaults are a suggestion and the project's standard is the authority. Only the -overlay is restricted. +Defaults, then base, then overlay. The base may relax the defaults; they are a +suggestion and the project's standard is the authority. ```text trigger: off < pr < commit scope: changed < repository ``` -The overlay may raise a rank, enable a disabled tool, add a tool, and add a -convention. It may not lower a rank, disable a tool, or narrow a scope. Overlay -conventions are additions, so removing one has no representation at all. - -A relaxing overlay is **refused, naming the field**. Ignoring it would leave a -developer believing a local skip took effect. Applying it would let one machine -opt out of the team's standard while its report still read as compliant. A -genuine local skip belongs in the PR as a recorded deviation. This is the same -rule the workflow already states for documents: a child may not silently relax a -binding restriction. - -## Tool classification - -Grouped by whether the tool executes project code, not by whether the name is -recognized. `ruff` and `pyright` analyze without running anything. `pytest` -imports `conftest.py` by design and `mypy` imports configured plugins, so both are -recognized and still require approval. Anything else is unrecognized and also -requires approval. Recognition is not trust. +The overlay may only raise a rank, enable a disabled tool, add a tool, or add a +convention. A relaxing overlay is **refused, naming the field** — ignoring it +would leave a developer believing a local skip took effect, and applying it would +let one machine opt out while its report still read as compliant. Overlay +conventions are additions, so removing one has no representation. ## Commands @@ -81,95 +56,84 @@ python3 /scripts/standards.py run --project . --base main python3 /scripts/standards.py approve --project . ``` -`inspect` prints the files found with their trust, the effective values, the -layer every value came from, each tool's approval verdict, and a note. It runs -nothing. A refusal exits non-zero with an empty stdout, so a failure cannot be -mistaken for a defaults report. - -`run` adds the result of every enabled check. A base is required while any -enabled check uses changed-file scope, and there is no fallback to a guessed -default branch: guessing which branch a project treats as its base is how a check -silently examines the wrong range. +`inspect` prints the files found, their trust, the effective values, the layer +every value came from, and each tool's approval verdict. It runs nothing. -The base comes from `--base`, or from the session binding when `--host` and -`--session` identify one that recorded it. An explicit `--base` wins, because a -person naming a revision is more specific than a record made when the PR was -bound. With neither, a changed-scope check reports that it was not run. +`run` adds the result of every enabled check. A base is required while any enabled +check uses changed-file scope; it comes from `--base` or from a session binding +identified by `--host` and `--session`, with `--base` winning. There is no +fallback to a guessed default branch, because guessing which branch a project +treats as its base is how a check examines the wrong range and reports +confidently about it. ## Outcomes | Outcome | Meaning | | --- | --- | -| `PASS` | the tool ran and reported nothing | -| `FAIL` | the tool ran and reported findings | -| `INCONCLUSIVE` | it could not run or could not finish: absent, timed out, killed, or exited in its own error mode | -| `NOT RUN` | disabled, no files in scope, no command declared, or approval missing | +| `PASS` | ran, reported nothing | +| `FAIL` | ran, reported findings | +| `INCONCLUSIVE` | could not run or finish: absent, timed out, killed, or exited in its own error mode | +| `NOT RUN` | disabled, nothing in scope, no command, or approval missing | -An absent tool is `INCONCLUSIVE`, never `PASS`: nothing was examined, so nothing -was established. An empty changed-file selection is `NOT RUN` for the same -reason. +An absent tool is `INCONCLUSIVE`, never `PASS`, and so is an empty changed-file +selection: neither examined anything. -Exit codes are mapped per tool, because tools disagree. `ruff check` returns 2 -when it terminates abnormally; `pyright` returns 2 for a fatal error, 3 for a -config file it could not read and 4 for illegal parameters. Those mean the tool -could not run. A command the project supplied has no such map, so a non-zero exit -is reported as `FAIL` and the report says its exit codes are unmapped. +Exit codes are mapped per tool because tools disagree. `ruff check` returns 2 on +abnormal termination; `pyright` returns 2 fatal, 3 unreadable config, 4 illegal +parameters. A command the project supplied has no map, so a non-zero exit is +`FAIL` and the report says the codes are unmapped. -Runs are bounded: a per-tool timeout, a total budget, and captured output -truncated with the truncation marked. +Runs are bounded by a per-tool timeout, a total budget, and truncated output. A +timed-out tool has its process group ended, so nothing it spawned outlives it. -## The optional preset +## Tools the skill does not ship argv for + +`ruff` and `pyright` run with argv this skill supplies; declaring `command` for +them is refused. Anything else needs `command` as argv words, which requires +schema 2, and an approval: ```sh -./scripts/install codex --project /path/to/project --hooks standards +python3 /scripts/standards.py approve --project . ``` -It registers one hook: `PostToolUse` on a direct `git commit`. After the commit -exists, the checks whose trigger is `commit` run and the result is reported to -the agent as context. Nothing is blocked, and the commit has already happened. - -The registered budget is 60 seconds, larger than the 12 the other presets use, -because a check is slower than a notice. The host is blocked while it runs, so -keep the commit trigger to fast checks; a tool that outruns the budget is -`INCONCLUSIVE`, and its process group is ended rather than left running. - -**`trigger: "pr"` has no hook, deliberately.** Neither host has a PR-completed -event. `Stop` is the nearest moment, and it fires at the end of every agent turn, -which is not what `pr` means. Rather than map onto an event that means something -else and then suppress the noise, that granularity stays an explicit -`standards.py run` at review time, alongside the review discipline the workflow -already carries. +`approve` prints every command it authorizes and records the approval under the +Git directory, outside the working tree, so a pull request cannot carry approval +for the command it introduces. The record is bound to those commands alone: +changing one revokes it, an unrelated edit does not. Until an approval matches, +those tools are `NOT RUN`. -## Commands this skill does not ship +Tools are grouped by whether they execute project code, not by whether the name +is recognized. `pytest` imports `conftest.py` by design and `mypy` imports +configured plugins, so both are recognized and still need approval. -`ruff` and `pyright` run with argv this skill supplies, and a configuration -declaring `command` for them is refused: those names mean that argv, and letting -a project redefine them would make the allowlist meaningless. +**A disclosed limit:** `approve` records an operator decision; it does not enforce +who made it. Nothing here prevents an agent from running it, exactly as nothing +prevents an agent from running the installer. -Any other tool must supply `command` as argv words, which requires schema 2, and -must be approved before it runs: +## The optional preset ```sh -python3 /scripts/standards.py approve --project . +./scripts/install codex --project /path/to/project --hooks standards ``` -`approve` prints every command it authorizes and records the approval under the -Git directory, outside the working tree, so a pull request cannot carry approval -for the command it introduces. The record is bound to those commands alone: -changing one revokes it, while an unrelated edit to the configuration does not, -because a prompt that appears after harmless changes is one people learn to -accept without reading. Until an approval matches, those tools are `NOT RUN` and -nothing is executed. +One hook: `PostToolUse` on a direct `git commit`. Whatever has `commit` as its +trigger rides along in one report: the checks run, and the declared review +conventions are restated for the agent to apply to what it just committed. +The commit already exists; nothing is blocked. The budget is 60 seconds rather than the 12 the other presets +use, and the host is blocked while it runs, so keep the commit trigger to fast +checks. -**A disclosed limit:** `approve` is an operator command, and nothing in this -skill prevents an agent from running it, exactly as nothing prevents an agent -from running the installer. It records a decision; it does not enforce who made -it. +**`trigger: "pr"` has no hook, deliberately.** Neither host has a PR-completed +event, and `Stop`, the nearest moment, fires at the end of every agent turn, +which is not what `pr` means. That granularity stays an explicit `run` at review +time. Conventions with the `pr` trigger still reach the agent, because `SKILL.md` +routes it to this contract whenever a project declares one; the hook adds the +commit-time reminder, not the only path. ## Limits Stdlib and Git only; Python 3.9 or newer. A declared command is argv and is never -passed to a shell. The configuration itself is read as data and never executed. A refusal names the file and the field and never carries the -file's contents. Symlinked, non-regular, oversized and non-UTF-8 configuration -files are refused rather than parsed. Reading a file proves nothing about whether -the agent followed it. +passed to a shell; the configuration itself is read as data and never executed. A +refusal names the file and field and never carries file contents. Symlinked, +non-regular, oversized and non-UTF-8 configuration files are refused. Reading a +file proves nothing about whether the agent followed it. diff --git a/dist/claude-code/structured-coding/scripts/standards.py b/dist/claude-code/structured-coding/scripts/standards.py index 464c724..724052e 100644 --- a/dist/claude-code/structured-coding/scripts/standards.py +++ b/dist/claude-code/structured-coding/scripts/standards.py @@ -679,19 +679,23 @@ def event(project, host, mode, payload): if Path(cwd).resolve(strict=True) != repository.root: return {} effective, _ = resolved(project) - if effective["checks"]["trigger"] != "commit": + parts = [] + if effective["review"]["trigger"] == "commit": + for convention in effective["review"]["conventions"]: + parts.append(f"Convention: {convention}") + if effective["checks"]["trigger"] == "commit": + base = bound_base(project, host, payload.get("session_id")) + for result in outcomes(project, effective, base): + parts.append(f"{result['name']}: {result['outcome']} ({result['reason']})") + if not parts: return {} - base = bound_base(project, host, payload.get("session_id")) - results = outcomes(project, effective, base) - lines = [f"{r['name']}: {r['outcome']} ({r['reason']})" for r in results] return { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": bounded( - "Structured Coding standards, reported after this commit. " - "These results are advisory; nothing was blocked, and an " - "INCONCLUSIVE or NOT RUN check is not a pass. " - + "; ".join(lines) + "Structured Coding standards, after this commit. Advisory: nothing " + "was blocked, a convention is for you to apply, and an INCONCLUSIVE " + "or NOT RUN check is not a pass. " + "; ".join(parts) ), } } diff --git a/dist/codex/structured-coding/references/standards.md b/dist/codex/structured-coding/references/standards.md index 0f2a258..872381d 100644 --- a/dist/codex/structured-coding/references/standards.md +++ b/dist/codex/structured-coding/references/standards.md @@ -1,15 +1,13 @@ # Project standards configuration -Status: **REPORTS; NEVER BLOCKS.** This release reads the configuration and, -on the `run` command, executes the declared checks and classifies each result. -With the optional `standards` preset installed, it also reports after a direct -`git commit`. It blocks nothing: no commit, no merge, no tool call. Automatic triggering is later work; the target behaviour for a hook -remains in the [hook contract](hook-contract.md). +Status: **REPORTS; NEVER BLOCKS.** Checks run when you invoke `run`, and, with the +optional `standards` preset installed, after a direct `git commit`. Nothing is +blocked: no commit, no merge, no tool call. A clean report is not proof that a +check ran; read the outcomes. -A project uses this to state, once, what is true of its whole codebase, so the -operator does not restate it in every planning conversation. Requirements -specific to one PR stay in that PR's conversation and its frozen design; putting -them here would create a second authority over the same decision. +A project states here what is true of its whole codebase, so the operator does +not restate it in every planning conversation. Requirements specific to one PR +belong in that PR's conversation, not here. ## Files @@ -19,59 +17,36 @@ them here would create a second authority over the same decision. | `.structured-coding/standards.local.md` | overlay | Copy [the template](../standards-template.md) to the base path and edit its -single fenced `json` block. The surrounding prose is never read, which is the -point: it carries the options, the alternatives and the reasons that JSON cannot. -Exactly one top-level `json` block is read. A block nested inside another fence -is prose. Zero blocks, several, or unparsable content is refused, naming the file. +single fenced `json` block. The surrounding prose is never read. Exactly one +top-level `json` block is read; a block nested inside another fence is prose. +Zero blocks, several, or unparsable content is refused, naming the file. Both +files are optional, and with neither the shipped defaults apply. -Both files are optional. With neither present the shipped defaults apply and -behaviour is unchanged. +## Layer and trust are decided separately -## Two determinations, decided separately +Layer comes from the filename. Trust comes from `git ls-files`: a tracked file is +shared, so anything it declares outside the allowlist needs approval; an untracked +file is personal, absent from a fresh clone, so authoring it is the approval. -**Layer comes from the filename.** The base states the project's standard. The -overlay may add and tighten only. - -**Trust comes from `git ls-files`.** A tracked file is shared: any contributor can -change it in a pull request, so a tool it declares outside the allowlist needs -approval. An untracked file is personal: it is absent from a fresh clone and can -only have been written by the machine's owner, so authoring it is the approval. - -The two are independent, which removes the special cases. A tracked overlay is an -overlay that happens to be shared. An untracked base is the base and happens to -be personal. Filename must not decide trust, because `.gitignore` does not apply -to a file Git already tracks, so a repository could otherwise commit a file under -the personal name and have it treated as locally authored. +Filename must not decide trust, because `.gitignore` does not apply to a file Git +already tracks. A tracked overlay is simply a shared overlay; an untracked base is +simply a personal base. ## Resolution -Defaults, then base, then overlay. The base may relax the shipped defaults: those -defaults are a suggestion and the project's standard is the authority. Only the -overlay is restricted. +Defaults, then base, then overlay. The base may relax the defaults; they are a +suggestion and the project's standard is the authority. ```text trigger: off < pr < commit scope: changed < repository ``` -The overlay may raise a rank, enable a disabled tool, add a tool, and add a -convention. It may not lower a rank, disable a tool, or narrow a scope. Overlay -conventions are additions, so removing one has no representation at all. - -A relaxing overlay is **refused, naming the field**. Ignoring it would leave a -developer believing a local skip took effect. Applying it would let one machine -opt out of the team's standard while its report still read as compliant. A -genuine local skip belongs in the PR as a recorded deviation. This is the same -rule the workflow already states for documents: a child may not silently relax a -binding restriction. - -## Tool classification - -Grouped by whether the tool executes project code, not by whether the name is -recognized. `ruff` and `pyright` analyze without running anything. `pytest` -imports `conftest.py` by design and `mypy` imports configured plugins, so both are -recognized and still require approval. Anything else is unrecognized and also -requires approval. Recognition is not trust. +The overlay may only raise a rank, enable a disabled tool, add a tool, or add a +convention. A relaxing overlay is **refused, naming the field** — ignoring it +would leave a developer believing a local skip took effect, and applying it would +let one machine opt out while its report still read as compliant. Overlay +conventions are additions, so removing one has no representation. ## Commands @@ -81,95 +56,84 @@ python3 /scripts/standards.py run --project . --base main python3 /scripts/standards.py approve --project . ``` -`inspect` prints the files found with their trust, the effective values, the -layer every value came from, each tool's approval verdict, and a note. It runs -nothing. A refusal exits non-zero with an empty stdout, so a failure cannot be -mistaken for a defaults report. - -`run` adds the result of every enabled check. A base is required while any -enabled check uses changed-file scope, and there is no fallback to a guessed -default branch: guessing which branch a project treats as its base is how a check -silently examines the wrong range. +`inspect` prints the files found, their trust, the effective values, the layer +every value came from, and each tool's approval verdict. It runs nothing. -The base comes from `--base`, or from the session binding when `--host` and -`--session` identify one that recorded it. An explicit `--base` wins, because a -person naming a revision is more specific than a record made when the PR was -bound. With neither, a changed-scope check reports that it was not run. +`run` adds the result of every enabled check. A base is required while any enabled +check uses changed-file scope; it comes from `--base` or from a session binding +identified by `--host` and `--session`, with `--base` winning. There is no +fallback to a guessed default branch, because guessing which branch a project +treats as its base is how a check examines the wrong range and reports +confidently about it. ## Outcomes | Outcome | Meaning | | --- | --- | -| `PASS` | the tool ran and reported nothing | -| `FAIL` | the tool ran and reported findings | -| `INCONCLUSIVE` | it could not run or could not finish: absent, timed out, killed, or exited in its own error mode | -| `NOT RUN` | disabled, no files in scope, no command declared, or approval missing | +| `PASS` | ran, reported nothing | +| `FAIL` | ran, reported findings | +| `INCONCLUSIVE` | could not run or finish: absent, timed out, killed, or exited in its own error mode | +| `NOT RUN` | disabled, nothing in scope, no command, or approval missing | -An absent tool is `INCONCLUSIVE`, never `PASS`: nothing was examined, so nothing -was established. An empty changed-file selection is `NOT RUN` for the same -reason. +An absent tool is `INCONCLUSIVE`, never `PASS`, and so is an empty changed-file +selection: neither examined anything. -Exit codes are mapped per tool, because tools disagree. `ruff check` returns 2 -when it terminates abnormally; `pyright` returns 2 for a fatal error, 3 for a -config file it could not read and 4 for illegal parameters. Those mean the tool -could not run. A command the project supplied has no such map, so a non-zero exit -is reported as `FAIL` and the report says its exit codes are unmapped. +Exit codes are mapped per tool because tools disagree. `ruff check` returns 2 on +abnormal termination; `pyright` returns 2 fatal, 3 unreadable config, 4 illegal +parameters. A command the project supplied has no map, so a non-zero exit is +`FAIL` and the report says the codes are unmapped. -Runs are bounded: a per-tool timeout, a total budget, and captured output -truncated with the truncation marked. +Runs are bounded by a per-tool timeout, a total budget, and truncated output. A +timed-out tool has its process group ended, so nothing it spawned outlives it. -## The optional preset +## Tools the skill does not ship argv for + +`ruff` and `pyright` run with argv this skill supplies; declaring `command` for +them is refused. Anything else needs `command` as argv words, which requires +schema 2, and an approval: ```sh -./scripts/install codex --project /path/to/project --hooks standards +python3 /scripts/standards.py approve --project . ``` -It registers one hook: `PostToolUse` on a direct `git commit`. After the commit -exists, the checks whose trigger is `commit` run and the result is reported to -the agent as context. Nothing is blocked, and the commit has already happened. - -The registered budget is 60 seconds, larger than the 12 the other presets use, -because a check is slower than a notice. The host is blocked while it runs, so -keep the commit trigger to fast checks; a tool that outruns the budget is -`INCONCLUSIVE`, and its process group is ended rather than left running. - -**`trigger: "pr"` has no hook, deliberately.** Neither host has a PR-completed -event. `Stop` is the nearest moment, and it fires at the end of every agent turn, -which is not what `pr` means. Rather than map onto an event that means something -else and then suppress the noise, that granularity stays an explicit -`standards.py run` at review time, alongside the review discipline the workflow -already carries. +`approve` prints every command it authorizes and records the approval under the +Git directory, outside the working tree, so a pull request cannot carry approval +for the command it introduces. The record is bound to those commands alone: +changing one revokes it, an unrelated edit does not. Until an approval matches, +those tools are `NOT RUN`. -## Commands this skill does not ship +Tools are grouped by whether they execute project code, not by whether the name +is recognized. `pytest` imports `conftest.py` by design and `mypy` imports +configured plugins, so both are recognized and still need approval. -`ruff` and `pyright` run with argv this skill supplies, and a configuration -declaring `command` for them is refused: those names mean that argv, and letting -a project redefine them would make the allowlist meaningless. +**A disclosed limit:** `approve` records an operator decision; it does not enforce +who made it. Nothing here prevents an agent from running it, exactly as nothing +prevents an agent from running the installer. -Any other tool must supply `command` as argv words, which requires schema 2, and -must be approved before it runs: +## The optional preset ```sh -python3 /scripts/standards.py approve --project . +./scripts/install codex --project /path/to/project --hooks standards ``` -`approve` prints every command it authorizes and records the approval under the -Git directory, outside the working tree, so a pull request cannot carry approval -for the command it introduces. The record is bound to those commands alone: -changing one revokes it, while an unrelated edit to the configuration does not, -because a prompt that appears after harmless changes is one people learn to -accept without reading. Until an approval matches, those tools are `NOT RUN` and -nothing is executed. +One hook: `PostToolUse` on a direct `git commit`. Whatever has `commit` as its +trigger rides along in one report: the checks run, and the declared review +conventions are restated for the agent to apply to what it just committed. +The commit already exists; nothing is blocked. The budget is 60 seconds rather than the 12 the other presets +use, and the host is blocked while it runs, so keep the commit trigger to fast +checks. -**A disclosed limit:** `approve` is an operator command, and nothing in this -skill prevents an agent from running it, exactly as nothing prevents an agent -from running the installer. It records a decision; it does not enforce who made -it. +**`trigger: "pr"` has no hook, deliberately.** Neither host has a PR-completed +event, and `Stop`, the nearest moment, fires at the end of every agent turn, +which is not what `pr` means. That granularity stays an explicit `run` at review +time. Conventions with the `pr` trigger still reach the agent, because `SKILL.md` +routes it to this contract whenever a project declares one; the hook adds the +commit-time reminder, not the only path. ## Limits Stdlib and Git only; Python 3.9 or newer. A declared command is argv and is never -passed to a shell. The configuration itself is read as data and never executed. A refusal names the file and the field and never carries the -file's contents. Symlinked, non-regular, oversized and non-UTF-8 configuration -files are refused rather than parsed. Reading a file proves nothing about whether -the agent followed it. +passed to a shell; the configuration itself is read as data and never executed. A +refusal names the file and field and never carries file contents. Symlinked, +non-regular, oversized and non-UTF-8 configuration files are refused. Reading a +file proves nothing about whether the agent followed it. diff --git a/dist/codex/structured-coding/scripts/standards.py b/dist/codex/structured-coding/scripts/standards.py index 464c724..724052e 100644 --- a/dist/codex/structured-coding/scripts/standards.py +++ b/dist/codex/structured-coding/scripts/standards.py @@ -679,19 +679,23 @@ def event(project, host, mode, payload): if Path(cwd).resolve(strict=True) != repository.root: return {} effective, _ = resolved(project) - if effective["checks"]["trigger"] != "commit": + parts = [] + if effective["review"]["trigger"] == "commit": + for convention in effective["review"]["conventions"]: + parts.append(f"Convention: {convention}") + if effective["checks"]["trigger"] == "commit": + base = bound_base(project, host, payload.get("session_id")) + for result in outcomes(project, effective, base): + parts.append(f"{result['name']}: {result['outcome']} ({result['reason']})") + if not parts: return {} - base = bound_base(project, host, payload.get("session_id")) - results = outcomes(project, effective, base) - lines = [f"{r['name']}: {r['outcome']} ({r['reason']})" for r in results] return { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": bounded( - "Structured Coding standards, reported after this commit. " - "These results are advisory; nothing was blocked, and an " - "INCONCLUSIVE or NOT RUN check is not a pass. " - + "; ".join(lines) + "Structured Coding standards, after this commit. Advisory: nothing " + "was blocked, a convention is for you to apply, and an INCONCLUSIVE " + "or NOT RUN check is not a pass. " + "; ".join(parts) ), } } diff --git a/dist/structured-coding-claude-code.zip b/dist/structured-coding-claude-code.zip index d50d252..c6f736d 100644 Binary files a/dist/structured-coding-claude-code.zip and b/dist/structured-coding-claude-code.zip differ diff --git a/dist/structured-coding-codex.zip b/dist/structured-coding-codex.zip index 426fc1e..da25c1e 100644 Binary files a/dist/structured-coding-codex.zip and b/dist/structured-coding-codex.zip differ diff --git a/scripts/test_standards.py b/scripts/test_standards.py index 9cd3a62..f7ef889 100644 --- a/scripts/test_standards.py +++ b/scripts/test_standards.py @@ -983,7 +983,7 @@ def test_a_commit_reports_when_the_trigger_matches(self): context = value["hookSpecificOutput"]["additionalContext"] self.assertIn("ruff: PASS", context) self.assertIn("pyright: NOT RUN", context) - self.assertIn("advisory", context) + self.assertIn("Advisory", context) def test_nothing_is_reported_when_the_trigger_does_not_match(self): self.config("pr") @@ -1010,6 +1010,28 @@ def test_a_malformed_payload_fails_open(self): value, stderr = self.fire(payload) self.assertEqual(value, {}) + def test_review_conventions_ride_along_when_their_trigger_matches(self): + relative = dict(standards.LAYERS)["base"] + path = self.project / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('```json\n{"schema": 1,' + ' "review": {"trigger": "commit", "conventions":' + ' ["No bare except"]},' + ' "checks": {"trigger": "pr"}}\n```\n', encoding="utf-8") + context = self.fire(self.payload())[0]["hookSpecificOutput"]["additionalContext"] + self.assertIn("Convention: No bare except", context) + # checks trigger is pr, so no check result rides along + self.assertNotIn("ruff:", context) + + def test_conventions_stay_out_when_their_trigger_does_not_match(self): + relative = dict(standards.LAYERS)["base"] + path = self.project / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('```json\n{"schema": 1,' + ' "review": {"trigger": "pr", "conventions": ["No bare except"]},' + ' "checks": {"trigger": "pr"}}\n```\n', encoding="utf-8") + self.assertEqual(self.fire(self.payload())[0], {}) + def test_a_project_without_configuration_reports_the_default_trigger(self): """The default trigger is pr, so a commit produces nothing.""" self.assertEqual(self.fire(self.payload())[0], {}) diff --git a/structured-coding/references/standards.md b/structured-coding/references/standards.md index 0f2a258..872381d 100644 --- a/structured-coding/references/standards.md +++ b/structured-coding/references/standards.md @@ -1,15 +1,13 @@ # Project standards configuration -Status: **REPORTS; NEVER BLOCKS.** This release reads the configuration and, -on the `run` command, executes the declared checks and classifies each result. -With the optional `standards` preset installed, it also reports after a direct -`git commit`. It blocks nothing: no commit, no merge, no tool call. Automatic triggering is later work; the target behaviour for a hook -remains in the [hook contract](hook-contract.md). +Status: **REPORTS; NEVER BLOCKS.** Checks run when you invoke `run`, and, with the +optional `standards` preset installed, after a direct `git commit`. Nothing is +blocked: no commit, no merge, no tool call. A clean report is not proof that a +check ran; read the outcomes. -A project uses this to state, once, what is true of its whole codebase, so the -operator does not restate it in every planning conversation. Requirements -specific to one PR stay in that PR's conversation and its frozen design; putting -them here would create a second authority over the same decision. +A project states here what is true of its whole codebase, so the operator does +not restate it in every planning conversation. Requirements specific to one PR +belong in that PR's conversation, not here. ## Files @@ -19,59 +17,36 @@ them here would create a second authority over the same decision. | `.structured-coding/standards.local.md` | overlay | Copy [the template](../standards-template.md) to the base path and edit its -single fenced `json` block. The surrounding prose is never read, which is the -point: it carries the options, the alternatives and the reasons that JSON cannot. -Exactly one top-level `json` block is read. A block nested inside another fence -is prose. Zero blocks, several, or unparsable content is refused, naming the file. +single fenced `json` block. The surrounding prose is never read. Exactly one +top-level `json` block is read; a block nested inside another fence is prose. +Zero blocks, several, or unparsable content is refused, naming the file. Both +files are optional, and with neither the shipped defaults apply. -Both files are optional. With neither present the shipped defaults apply and -behaviour is unchanged. +## Layer and trust are decided separately -## Two determinations, decided separately +Layer comes from the filename. Trust comes from `git ls-files`: a tracked file is +shared, so anything it declares outside the allowlist needs approval; an untracked +file is personal, absent from a fresh clone, so authoring it is the approval. -**Layer comes from the filename.** The base states the project's standard. The -overlay may add and tighten only. - -**Trust comes from `git ls-files`.** A tracked file is shared: any contributor can -change it in a pull request, so a tool it declares outside the allowlist needs -approval. An untracked file is personal: it is absent from a fresh clone and can -only have been written by the machine's owner, so authoring it is the approval. - -The two are independent, which removes the special cases. A tracked overlay is an -overlay that happens to be shared. An untracked base is the base and happens to -be personal. Filename must not decide trust, because `.gitignore` does not apply -to a file Git already tracks, so a repository could otherwise commit a file under -the personal name and have it treated as locally authored. +Filename must not decide trust, because `.gitignore` does not apply to a file Git +already tracks. A tracked overlay is simply a shared overlay; an untracked base is +simply a personal base. ## Resolution -Defaults, then base, then overlay. The base may relax the shipped defaults: those -defaults are a suggestion and the project's standard is the authority. Only the -overlay is restricted. +Defaults, then base, then overlay. The base may relax the defaults; they are a +suggestion and the project's standard is the authority. ```text trigger: off < pr < commit scope: changed < repository ``` -The overlay may raise a rank, enable a disabled tool, add a tool, and add a -convention. It may not lower a rank, disable a tool, or narrow a scope. Overlay -conventions are additions, so removing one has no representation at all. - -A relaxing overlay is **refused, naming the field**. Ignoring it would leave a -developer believing a local skip took effect. Applying it would let one machine -opt out of the team's standard while its report still read as compliant. A -genuine local skip belongs in the PR as a recorded deviation. This is the same -rule the workflow already states for documents: a child may not silently relax a -binding restriction. - -## Tool classification - -Grouped by whether the tool executes project code, not by whether the name is -recognized. `ruff` and `pyright` analyze without running anything. `pytest` -imports `conftest.py` by design and `mypy` imports configured plugins, so both are -recognized and still require approval. Anything else is unrecognized and also -requires approval. Recognition is not trust. +The overlay may only raise a rank, enable a disabled tool, add a tool, or add a +convention. A relaxing overlay is **refused, naming the field** — ignoring it +would leave a developer believing a local skip took effect, and applying it would +let one machine opt out while its report still read as compliant. Overlay +conventions are additions, so removing one has no representation. ## Commands @@ -81,95 +56,84 @@ python3 /scripts/standards.py run --project . --base main python3 /scripts/standards.py approve --project . ``` -`inspect` prints the files found with their trust, the effective values, the -layer every value came from, each tool's approval verdict, and a note. It runs -nothing. A refusal exits non-zero with an empty stdout, so a failure cannot be -mistaken for a defaults report. - -`run` adds the result of every enabled check. A base is required while any -enabled check uses changed-file scope, and there is no fallback to a guessed -default branch: guessing which branch a project treats as its base is how a check -silently examines the wrong range. +`inspect` prints the files found, their trust, the effective values, the layer +every value came from, and each tool's approval verdict. It runs nothing. -The base comes from `--base`, or from the session binding when `--host` and -`--session` identify one that recorded it. An explicit `--base` wins, because a -person naming a revision is more specific than a record made when the PR was -bound. With neither, a changed-scope check reports that it was not run. +`run` adds the result of every enabled check. A base is required while any enabled +check uses changed-file scope; it comes from `--base` or from a session binding +identified by `--host` and `--session`, with `--base` winning. There is no +fallback to a guessed default branch, because guessing which branch a project +treats as its base is how a check examines the wrong range and reports +confidently about it. ## Outcomes | Outcome | Meaning | | --- | --- | -| `PASS` | the tool ran and reported nothing | -| `FAIL` | the tool ran and reported findings | -| `INCONCLUSIVE` | it could not run or could not finish: absent, timed out, killed, or exited in its own error mode | -| `NOT RUN` | disabled, no files in scope, no command declared, or approval missing | +| `PASS` | ran, reported nothing | +| `FAIL` | ran, reported findings | +| `INCONCLUSIVE` | could not run or finish: absent, timed out, killed, or exited in its own error mode | +| `NOT RUN` | disabled, nothing in scope, no command, or approval missing | -An absent tool is `INCONCLUSIVE`, never `PASS`: nothing was examined, so nothing -was established. An empty changed-file selection is `NOT RUN` for the same -reason. +An absent tool is `INCONCLUSIVE`, never `PASS`, and so is an empty changed-file +selection: neither examined anything. -Exit codes are mapped per tool, because tools disagree. `ruff check` returns 2 -when it terminates abnormally; `pyright` returns 2 for a fatal error, 3 for a -config file it could not read and 4 for illegal parameters. Those mean the tool -could not run. A command the project supplied has no such map, so a non-zero exit -is reported as `FAIL` and the report says its exit codes are unmapped. +Exit codes are mapped per tool because tools disagree. `ruff check` returns 2 on +abnormal termination; `pyright` returns 2 fatal, 3 unreadable config, 4 illegal +parameters. A command the project supplied has no map, so a non-zero exit is +`FAIL` and the report says the codes are unmapped. -Runs are bounded: a per-tool timeout, a total budget, and captured output -truncated with the truncation marked. +Runs are bounded by a per-tool timeout, a total budget, and truncated output. A +timed-out tool has its process group ended, so nothing it spawned outlives it. -## The optional preset +## Tools the skill does not ship argv for + +`ruff` and `pyright` run with argv this skill supplies; declaring `command` for +them is refused. Anything else needs `command` as argv words, which requires +schema 2, and an approval: ```sh -./scripts/install codex --project /path/to/project --hooks standards +python3 /scripts/standards.py approve --project . ``` -It registers one hook: `PostToolUse` on a direct `git commit`. After the commit -exists, the checks whose trigger is `commit` run and the result is reported to -the agent as context. Nothing is blocked, and the commit has already happened. - -The registered budget is 60 seconds, larger than the 12 the other presets use, -because a check is slower than a notice. The host is blocked while it runs, so -keep the commit trigger to fast checks; a tool that outruns the budget is -`INCONCLUSIVE`, and its process group is ended rather than left running. - -**`trigger: "pr"` has no hook, deliberately.** Neither host has a PR-completed -event. `Stop` is the nearest moment, and it fires at the end of every agent turn, -which is not what `pr` means. Rather than map onto an event that means something -else and then suppress the noise, that granularity stays an explicit -`standards.py run` at review time, alongside the review discipline the workflow -already carries. +`approve` prints every command it authorizes and records the approval under the +Git directory, outside the working tree, so a pull request cannot carry approval +for the command it introduces. The record is bound to those commands alone: +changing one revokes it, an unrelated edit does not. Until an approval matches, +those tools are `NOT RUN`. -## Commands this skill does not ship +Tools are grouped by whether they execute project code, not by whether the name +is recognized. `pytest` imports `conftest.py` by design and `mypy` imports +configured plugins, so both are recognized and still need approval. -`ruff` and `pyright` run with argv this skill supplies, and a configuration -declaring `command` for them is refused: those names mean that argv, and letting -a project redefine them would make the allowlist meaningless. +**A disclosed limit:** `approve` records an operator decision; it does not enforce +who made it. Nothing here prevents an agent from running it, exactly as nothing +prevents an agent from running the installer. -Any other tool must supply `command` as argv words, which requires schema 2, and -must be approved before it runs: +## The optional preset ```sh -python3 /scripts/standards.py approve --project . +./scripts/install codex --project /path/to/project --hooks standards ``` -`approve` prints every command it authorizes and records the approval under the -Git directory, outside the working tree, so a pull request cannot carry approval -for the command it introduces. The record is bound to those commands alone: -changing one revokes it, while an unrelated edit to the configuration does not, -because a prompt that appears after harmless changes is one people learn to -accept without reading. Until an approval matches, those tools are `NOT RUN` and -nothing is executed. +One hook: `PostToolUse` on a direct `git commit`. Whatever has `commit` as its +trigger rides along in one report: the checks run, and the declared review +conventions are restated for the agent to apply to what it just committed. +The commit already exists; nothing is blocked. The budget is 60 seconds rather than the 12 the other presets +use, and the host is blocked while it runs, so keep the commit trigger to fast +checks. -**A disclosed limit:** `approve` is an operator command, and nothing in this -skill prevents an agent from running it, exactly as nothing prevents an agent -from running the installer. It records a decision; it does not enforce who made -it. +**`trigger: "pr"` has no hook, deliberately.** Neither host has a PR-completed +event, and `Stop`, the nearest moment, fires at the end of every agent turn, +which is not what `pr` means. That granularity stays an explicit `run` at review +time. Conventions with the `pr` trigger still reach the agent, because `SKILL.md` +routes it to this contract whenever a project declares one; the hook adds the +commit-time reminder, not the only path. ## Limits Stdlib and Git only; Python 3.9 or newer. A declared command is argv and is never -passed to a shell. The configuration itself is read as data and never executed. A refusal names the file and the field and never carries the -file's contents. Symlinked, non-regular, oversized and non-UTF-8 configuration -files are refused rather than parsed. Reading a file proves nothing about whether -the agent followed it. +passed to a shell; the configuration itself is read as data and never executed. A +refusal names the file and field and never carries file contents. Symlinked, +non-regular, oversized and non-UTF-8 configuration files are refused. Reading a +file proves nothing about whether the agent followed it. diff --git a/structured-coding/scripts/standards.py b/structured-coding/scripts/standards.py index 464c724..724052e 100644 --- a/structured-coding/scripts/standards.py +++ b/structured-coding/scripts/standards.py @@ -679,19 +679,23 @@ def event(project, host, mode, payload): if Path(cwd).resolve(strict=True) != repository.root: return {} effective, _ = resolved(project) - if effective["checks"]["trigger"] != "commit": + parts = [] + if effective["review"]["trigger"] == "commit": + for convention in effective["review"]["conventions"]: + parts.append(f"Convention: {convention}") + if effective["checks"]["trigger"] == "commit": + base = bound_base(project, host, payload.get("session_id")) + for result in outcomes(project, effective, base): + parts.append(f"{result['name']}: {result['outcome']} ({result['reason']})") + if not parts: return {} - base = bound_base(project, host, payload.get("session_id")) - results = outcomes(project, effective, base) - lines = [f"{r['name']}: {r['outcome']} ({r['reason']})" for r in results] return { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": bounded( - "Structured Coding standards, reported after this commit. " - "These results are advisory; nothing was blocked, and an " - "INCONCLUSIVE or NOT RUN check is not a pass. " - + "; ".join(lines) + "Structured Coding standards, after this commit. Advisory: nothing " + "was blocked, a convention is for you to apply, and an INCONCLUSIVE " + "or NOT RUN check is not a pass. " + "; ".join(parts) ), } }