Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dist/claude-code/structured-coding/references/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Use `claude-code` for Claude Code. Python 3.9+, Git and macOS/Linux/WSL are requ

Codex requires trust at **two** levels, and a registration is inert until both are given. First the project: until it is trusted, Codex loads no project-local hooks at all and says so on stderr, naming the file it wants the entry in. That entry must live in the `config.toml` under `CODEX_HOME`; a `-c projects."<path>".trust_level="trusted"` override on the command line does **not** satisfy it, which is the failure that looks like hooks being delivered but ignored. Second, each hook definition is trusted by its own content hash, so changing a command asks again. Both are Codex's own mechanism; neither is granted by this installer. Claude Code has no equivalent second level.

Installation is additive. Checkpoints alone registers shared SessionStart, PreToolUse and Stop, without compact handlers. Continuity alone registers shared SessionStart and manual/automatic PreCompact. Both use five groups, with SessionStart exactly once. Removing one keeps the other usable. Bare `--remove-hooks` removes all owned presets and preserves the skill and session data. Repeating a selection is a byte-preserving no-op; duplicate/unknown names are rejected.
Installation is additive. Standards alone registers shared SessionStart and PostToolUse, and is the only preset that runs anything; its PostToolUse hook carries a 60-second budget rather than 12, because a check is slower than a notice. Checkpoints alone registers shared SessionStart, PreToolUse and Stop, without compact handlers. Continuity alone registers shared SessionStart and manual/automatic PreCompact. Both use five groups, with SessionStart exactly once. Removing one keeps the other usable. Bare `--remove-hooks` removes all owned presets and preserves the skill and session data. Repeating a selection is a byte-preserving no-op; duplicate/unknown names are rejected.

One aggregate receipt owns the original settings backup. Legacy continuity receipts remain readable and migrate only on an explicit state change; check, preview and unchanged reinstall do not rewrite them. Existing customized runtime dependencies are never overwritten: compare/back up the installed skill and explicitly update it first. Missing, changed or duplicate owned groups, unknown receipt schemas, and redirected/unverifiable paths cause refusal. New installations register commands that contain no machine-specific path, so a registration committed to a shared settings file works on a teammate's machine; each machine still reviews and trusts the hooks itself. An installation made by an older release keeps its absolute commands, stays inspectable and removable, and is rewritten only by `--upgrade-registration`, which changes every command and therefore requires trusting the hooks again. `--check-hooks` says whether a registration is portable, and names the recorded interpreter only for an absolute one.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Claude Code 用户把 `codex` 换成 `claude-code`。需要 Python 3.9+、Git

Codex 的信任分**两层**,两层都给到之前注册都是空转的。第一层是项目:在项目被信任之前,Codex 根本不加载任何 project-local hook,并会在 stderr 上说明,同时指出它要求写入的文件。那条记录必须写在 `CODEX_HOME` 下的 `config.toml` 里;命令行上的 `-c projects."<path>".trust_level="trusted"` 覆盖**不满足**这一条,而这种失败看起来像是 hook 被送达却被忽略了。第二层是每个 hook 定义按自身内容 hash 单独信任,所以改动命令会重新询问。两层都是 Codex 自己的机制,installer 都不会替你授予。Claude Code 没有对应的第二层。

安装是追加操作。单装 checkpoints 会注册共享 SessionStart、PreToolUse 和 Stop,不注册 compact handler。单装 continuity 会注册共享 SessionStart 和手动/自动 PreCompact。组合安装共五个 group,SessionStart 只出现一次。移除一个后另一个仍可用。裸 `--remove-hooks` 移除全部自有 preset,保留 skill 和 session 数据。重复安装同一选择不会改变文件字节;重复或未知名称会被拒绝。
安装是追加操作。单装 standards 会注册共享 SessionStart 和 PostToolUse,它是唯一会真正运行东西的 preset;它的 PostToolUse hook 预算是 60 秒而不是 12 秒,因为跑一次检查比发一条提示慢。单装 checkpoints 会注册共享 SessionStart、PreToolUse 和 Stop,不注册 compact handler。单装 continuity 会注册共享 SessionStart 和手动/自动 PreCompact。组合安装共五个 group,SessionStart 只出现一次。移除一个后另一个仍可用。裸 `--remove-hooks` 移除全部自有 preset,保留 skill 和 session 数据。重复安装同一选择不会改变文件字节;重复或未知名称会被拒绝。

一份 aggregate receipt 保存原始设置备份。旧 continuity receipt 仍可读取,仅在明确改变安装状态时迁移;检查、预览和未改变选择的重装都不改写它。installer 不覆盖定制过的 runtime 依赖:先比较、备份已有 skill,再明确升级。自有 group 缺失、被修改或重复,receipt schema 未知,以及路径被重定向或无法核验,都会导致拒绝。新安装注册的命令不含任何机器相关路径,因此提交到共享设置文件里的注册在同事的机器上同样有效;每台机器仍需自己审阅并信任这些 hook。旧版本装出来的注册保留其绝对路径命令,仍可检查、仍可移除,只有 `--upgrade-registration` 会重写它;重写会改变每一条命令,因此需要重新信任这些 hook。`--check-hooks` 会说明注册是否可移植,并且只对绝对路径的注册报告记录的 interpreter。

Expand Down
28 changes: 25 additions & 3 deletions dist/claude-code/structured-coding/references/standards.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Project standards configuration

Status: **RUNS ONLY WHEN INVOKED.** This release reads the configuration and,
Status: **REPORTS; NEVER BLOCKS.** This release reads the configuration and,
on the `run` command, executes the declared checks and classifies each result.
It registers no hook, so nothing happens automatically, and it blocks no commit
and no merge. Automatic triggering is later work; the target behaviour for a hook
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).

A project uses this to state, once, what is true of its whole codebase, so the
Expand Down Expand Up @@ -118,6 +118,28 @@ is reported as `FAIL` and the report says its exit codes are unmapped.
Runs are bounded: a per-tool timeout, a total budget, and captured output
truncated with the truncation marked.

## The optional preset

```sh
./scripts/install codex --project /path/to/project --hooks standards
```

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.

## Commands this skill does not ship

`ruff` and `pyright` run with argv this skill supplies, and a configuration
Expand Down
107 changes: 100 additions & 7 deletions dist/claude-code/structured-coding/scripts/standards.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import json
import os
import re
import signal
import subprocess
import time
import sys
Expand All @@ -24,8 +25,12 @@
from continuity import ( # noqa: E402
HOSTS, Repository, active_record, atomic_json, read_json, safe_path,
)
# recognized() is the careful direct-git-commit test; it is generic rather than
# checkpoints-specific, and both files always ship together.
from checkpoints import bounded, recognized # noqa: E402

MAX_INPUT = 256 * 1024
ERRORS = (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError)
# 1 is what the shipped defaults declare, because they need nothing newer.
# 2 adds a project-supplied command, for a tool we ship no argv for.
SCHEMA = 1
Expand Down Expand Up @@ -470,26 +475,61 @@ def bounded_output(raw):
return text[:MAX_OUTPUT].strip() + "\n[output truncated]"


GRACE_SECONDS = 5


def end_group(process):
"""A tool's children must not outlive the event that started it.

subprocess terminates only the child it started. Under an explicit command an
operator would notice an orphaned tree; under a hook nothing would."""
for number in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(process.pid, number)
except (OSError, ProcessLookupError):
return
try:
process.wait(timeout=GRACE_SECONDS)
if number is signal.SIGKILL:
return
except subprocess.TimeoutExpired:
continue
else:
# The leader is gone; make sure nothing it spawned is still holding on.
try:
os.killpg(process.pid, 0)
except (OSError, ProcessLookupError):
return


def execute(root, tool, command, seconds):
"""Run one tool and classify from what happened, not from the exit code alone."""
if seconds <= 0:
return "INCONCLUSIVE", "the total time budget was already spent", ""
started = time.monotonic()
try:
result = subprocess.run(
# Its own session, so a timeout can end the whole tree and not just the leader.
process = subprocess.Popen(
command, cwd=str(root), stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=seconds,
stderr=subprocess.STDOUT, start_new_session=True,
)
except FileNotFoundError:
# Never a pass: an absent tool established nothing.
return "INCONCLUSIVE", f"{command[0]} is not installed or not on PATH", ""
except subprocess.TimeoutExpired:
return "INCONCLUSIVE", f"no result within {seconds:.0f}s", ""
except OSError as error:
return "INCONCLUSIVE", f"could not start ({type(error).__name__})", ""
try:
captured, _ = process.communicate(timeout=seconds)
except subprocess.TimeoutExpired:
end_group(process)
try:
process.communicate(timeout=GRACE_SECONDS)
except (subprocess.TimeoutExpired, OSError, ValueError):
pass
return "INCONCLUSIVE", f"no result within {seconds:.0f}s", ""
elapsed = time.monotonic() - started
output = bounded_output(result.stdout or b"")
code = result.returncode
output = bounded_output(captured or b"")
code = process.returncode
if code == 0:
return "PASS", f"clean in {elapsed:.1f}s", output
if code < 0:
Expand Down Expand Up @@ -617,15 +657,68 @@ def approve(project):
return 0


COMMIT_TIMEOUT = 60


def event(project, host, mode, payload):
"""Report after a commit. Never blocks, never raises to the host."""
if mode != "post-commit" or payload.get("hook_event_name") != "PostToolUse":
raise Invalid(project, "event", "unexpected lifecycle payload")
tool_input = payload.get("tool_input")
if payload.get("tool_name") != "Bash" or not isinstance(tool_input, dict):
return {}
if any(key in tool_input for key in ("cwd", "workdir")):
return {}
if not recognized(tool_input.get("command")):
return {}
cwd = payload.get("cwd")
if not isinstance(cwd, str) or not cwd or not Path(cwd).is_absolute():
raise Invalid(project, "cwd", "missing or invalid lifecycle cwd")
repository = Repository(project)
# Exact root excludes nested repositories and shell cwd overrides.
if Path(cwd).resolve(strict=True) != repository.root:
return {}
effective, _ = resolved(project)
if effective["checks"]["trigger"] != "commit":
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)
),
}
}


def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("inspect", "run", "approve"))
parser.add_argument("action", choices=("inspect", "run", "approve", "event"))
parser.add_argument("--event", choices=("post-commit",))
parser.add_argument("--project", required=True, type=Path)
parser.add_argument("--base", help="revision changed-file scope compares against")
parser.add_argument("--host", choices=HOSTS, help="host of a session binding to read the base from")
parser.add_argument("--session", help="session id of that binding")
args = parser.parse_args(argv)
try:
if args.action == "event":
raw = sys.stdin.buffer.read(MAX_INPUT + 1)
if len(raw) > MAX_INPUT:
raise Invalid(args.project, "event", "oversized lifecycle payload")
try:
print(json.dumps(event(args.project, args.host, args.event,
json.loads(raw)), ensure_ascii=True))
except ERRORS:
# Fail open: a hook must never block the host it advises.
print("Standards hook unavailable; reconcile locally.", file=sys.stderr)
print("{}")
return 0
if args.action == "approve":
return approve(args.project)
value = report(args.project)
Expand Down
2 changes: 1 addition & 1 deletion dist/codex/structured-coding/references/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Use `claude-code` for Claude Code. Python 3.9+, Git and macOS/Linux/WSL are requ

Codex requires trust at **two** levels, and a registration is inert until both are given. First the project: until it is trusted, Codex loads no project-local hooks at all and says so on stderr, naming the file it wants the entry in. That entry must live in the `config.toml` under `CODEX_HOME`; a `-c projects."<path>".trust_level="trusted"` override on the command line does **not** satisfy it, which is the failure that looks like hooks being delivered but ignored. Second, each hook definition is trusted by its own content hash, so changing a command asks again. Both are Codex's own mechanism; neither is granted by this installer. Claude Code has no equivalent second level.

Installation is additive. Checkpoints alone registers shared SessionStart, PreToolUse and Stop, without compact handlers. Continuity alone registers shared SessionStart and manual/automatic PreCompact. Both use five groups, with SessionStart exactly once. Removing one keeps the other usable. Bare `--remove-hooks` removes all owned presets and preserves the skill and session data. Repeating a selection is a byte-preserving no-op; duplicate/unknown names are rejected.
Installation is additive. Standards alone registers shared SessionStart and PostToolUse, and is the only preset that runs anything; its PostToolUse hook carries a 60-second budget rather than 12, because a check is slower than a notice. Checkpoints alone registers shared SessionStart, PreToolUse and Stop, without compact handlers. Continuity alone registers shared SessionStart and manual/automatic PreCompact. Both use five groups, with SessionStart exactly once. Removing one keeps the other usable. Bare `--remove-hooks` removes all owned presets and preserves the skill and session data. Repeating a selection is a byte-preserving no-op; duplicate/unknown names are rejected.

One aggregate receipt owns the original settings backup. Legacy continuity receipts remain readable and migrate only on an explicit state change; check, preview and unchanged reinstall do not rewrite them. Existing customized runtime dependencies are never overwritten: compare/back up the installed skill and explicitly update it first. Missing, changed or duplicate owned groups, unknown receipt schemas, and redirected/unverifiable paths cause refusal. New installations register commands that contain no machine-specific path, so a registration committed to a shared settings file works on a teammate's machine; each machine still reviews and trusts the hooks itself. An installation made by an older release keeps its absolute commands, stays inspectable and removable, and is rewritten only by `--upgrade-registration`, which changes every command and therefore requires trusting the hooks again. `--check-hooks` says whether a registration is portable, and names the recorded interpreter only for an absolute one.

Expand Down
2 changes: 1 addition & 1 deletion dist/codex/structured-coding/references/platforms.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Claude Code 用户把 `codex` 换成 `claude-code`。需要 Python 3.9+、Git

Codex 的信任分**两层**,两层都给到之前注册都是空转的。第一层是项目:在项目被信任之前,Codex 根本不加载任何 project-local hook,并会在 stderr 上说明,同时指出它要求写入的文件。那条记录必须写在 `CODEX_HOME` 下的 `config.toml` 里;命令行上的 `-c projects."<path>".trust_level="trusted"` 覆盖**不满足**这一条,而这种失败看起来像是 hook 被送达却被忽略了。第二层是每个 hook 定义按自身内容 hash 单独信任,所以改动命令会重新询问。两层都是 Codex 自己的机制,installer 都不会替你授予。Claude Code 没有对应的第二层。

安装是追加操作。单装 checkpoints 会注册共享 SessionStart、PreToolUse 和 Stop,不注册 compact handler。单装 continuity 会注册共享 SessionStart 和手动/自动 PreCompact。组合安装共五个 group,SessionStart 只出现一次。移除一个后另一个仍可用。裸 `--remove-hooks` 移除全部自有 preset,保留 skill 和 session 数据。重复安装同一选择不会改变文件字节;重复或未知名称会被拒绝。
安装是追加操作。单装 standards 会注册共享 SessionStart 和 PostToolUse,它是唯一会真正运行东西的 preset;它的 PostToolUse hook 预算是 60 秒而不是 12 秒,因为跑一次检查比发一条提示慢。单装 checkpoints 会注册共享 SessionStart、PreToolUse 和 Stop,不注册 compact handler。单装 continuity 会注册共享 SessionStart 和手动/自动 PreCompact。组合安装共五个 group,SessionStart 只出现一次。移除一个后另一个仍可用。裸 `--remove-hooks` 移除全部自有 preset,保留 skill 和 session 数据。重复安装同一选择不会改变文件字节;重复或未知名称会被拒绝。

一份 aggregate receipt 保存原始设置备份。旧 continuity receipt 仍可读取,仅在明确改变安装状态时迁移;检查、预览和未改变选择的重装都不改写它。installer 不覆盖定制过的 runtime 依赖:先比较、备份已有 skill,再明确升级。自有 group 缺失、被修改或重复,receipt schema 未知,以及路径被重定向或无法核验,都会导致拒绝。新安装注册的命令不含任何机器相关路径,因此提交到共享设置文件里的注册在同事的机器上同样有效;每台机器仍需自己审阅并信任这些 hook。旧版本装出来的注册保留其绝对路径命令,仍可检查、仍可移除,只有 `--upgrade-registration` 会重写它;重写会改变每一条命令,因此需要重新信任这些 hook。`--check-hooks` 会说明注册是否可移植,并且只对绝对路径的注册报告记录的 interpreter。

Expand Down
Loading
Loading