diff --git a/dist/claude-code/structured-coding/references/platforms.md b/dist/claude-code/structured-coding/references/platforms.md index c43e913..a14f482 100644 --- a/dist/claude-code/structured-coding/references/platforms.md +++ b/dist/claude-code/structured-coding/references/platforms.md @@ -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."".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. diff --git a/dist/claude-code/structured-coding/references/platforms.zh-CN.md b/dist/claude-code/structured-coding/references/platforms.zh-CN.md index c06c518..8b66fc9 100644 --- a/dist/claude-code/structured-coding/references/platforms.zh-CN.md +++ b/dist/claude-code/structured-coding/references/platforms.zh-CN.md @@ -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."".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。 diff --git a/dist/claude-code/structured-coding/references/standards.md b/dist/claude-code/structured-coding/references/standards.md index 1fb1620..0f2a258 100644 --- a/dist/claude-code/structured-coding/references/standards.md +++ b/dist/claude-code/structured-coding/references/standards.md @@ -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 @@ -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 diff --git a/dist/claude-code/structured-coding/scripts/standards.py b/dist/claude-code/structured-coding/scripts/standards.py index 62128de..464c724 100644 --- a/dist/claude-code/structured-coding/scripts/standards.py +++ b/dist/claude-code/structured-coding/scripts/standards.py @@ -10,6 +10,7 @@ import json import os import re +import signal import subprocess import time import sys @@ -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 @@ -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: @@ -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) diff --git a/dist/codex/structured-coding/references/platforms.md b/dist/codex/structured-coding/references/platforms.md index c43e913..a14f482 100644 --- a/dist/codex/structured-coding/references/platforms.md +++ b/dist/codex/structured-coding/references/platforms.md @@ -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."".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. diff --git a/dist/codex/structured-coding/references/platforms.zh-CN.md b/dist/codex/structured-coding/references/platforms.zh-CN.md index c06c518..8b66fc9 100644 --- a/dist/codex/structured-coding/references/platforms.zh-CN.md +++ b/dist/codex/structured-coding/references/platforms.zh-CN.md @@ -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."".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。 diff --git a/dist/codex/structured-coding/references/standards.md b/dist/codex/structured-coding/references/standards.md index 1fb1620..0f2a258 100644 --- a/dist/codex/structured-coding/references/standards.md +++ b/dist/codex/structured-coding/references/standards.md @@ -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 @@ -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 diff --git a/dist/codex/structured-coding/scripts/standards.py b/dist/codex/structured-coding/scripts/standards.py index 62128de..464c724 100644 --- a/dist/codex/structured-coding/scripts/standards.py +++ b/dist/codex/structured-coding/scripts/standards.py @@ -10,6 +10,7 @@ import json import os import re +import signal import subprocess import time import sys @@ -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 @@ -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: @@ -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) diff --git a/dist/structured-coding-claude-code.zip b/dist/structured-coding-claude-code.zip index a808e2b..d50d252 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 5a37696..426fc1e 100644 Binary files a/dist/structured-coding-codex.zip and b/dist/structured-coding-codex.zip differ diff --git a/scripts/hook_install.py b/scripts/hook_install.py index d85c6dc..faca660 100644 --- a/scripts/hook_install.py +++ b/scripts/hook_install.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[1] FLOORS = {"codex": (0, 153, 4), "claude-code": (2, 1, 261)} -PRESETS = ("continuity", "checkpoints") +PRESETS = ("continuity", "checkpoints", "standards") MAX_FILE = 1024 * 1024 MAX_JOURNAL = 8 * MAX_FILE @@ -152,7 +152,7 @@ def selection(presets): values = tuple(presets) if not values or len(set(values)) != len(values) or set(values) - set(PRESETS): raise ValueError( - "Select each supported preset at most once: continuity checkpoints" + "Select each supported preset at most once: " + " ".join(PRESETS) ) return tuple(p for p in PRESETS if p in values) @@ -219,10 +219,17 @@ def groups(host, project, skill, presets=("continuity",), shape=ABSOLUTE): ("Stop", None, "checkpoints", "stop"), ] ) + if "standards" in presets: + # One discrete event the host really has. There is no PR-completed event, + # so that granularity stays an explicit command rather than a stand-in. + capabilities.append(("PostToolUse", "^Bash$", "standards", "post-commit")) result = {} for event, matcher, script, mode in capabilities: command = registered_command(shape, host, project, skill, script, mode) - group = {"hooks": [{"type": "command", "command": command, "timeout": 12}]} + # A commit-time check needs longer than an advisory notice, and both + # hosts were measured to honour a larger value. + seconds = 60 if script == "standards" else 12 + group = {"hooks": [{"type": "command", "command": command, "timeout": seconds}]} if matcher is not None: group = {"matcher": matcher, **group} result.setdefault(event, []).append(group) @@ -485,8 +492,11 @@ def replace(path, expected, replacement): def verify_skill(plan): source = ROOT / "structured-coding" dependencies = {"scripts/continuity.py", "references/continuity.md"} - if "checkpoints" in plan.get("presets", ("continuity",)): + selected = plan.get("presets", ("continuity",)) + if "checkpoints" in selected or "standards" in selected: dependencies.update({"scripts/checkpoints.py", "references/checkpoints.md"}) + if "standards" in selected: + dependencies.update({"scripts/standards.py", "references/standards.md"}) for relative in sorted(dependencies): path = safe(plan["skill"], relative) if read(path) != (source / relative).read_bytes(): diff --git a/scripts/install b/scripts/install index 181d3f7..c196687 100755 --- a/scripts/install +++ b/scripts/install @@ -68,9 +68,9 @@ def main(): parser.add_argument("--project", required=True, type=Path, help="Existing target project directory (use . for the current directory)") modes = parser.add_mutually_exclusive_group() - modes.add_argument("--hooks", nargs="+", action="append", choices=("continuity", "checkpoints"), + modes.add_argument("--hooks", nargs="+", action="append", choices=("continuity", "checkpoints", "standards"), help="Add the selected project-local hook presets") - modes.add_argument("--remove-hooks", nargs="*", action="append", choices=("continuity", "checkpoints"), + modes.add_argument("--remove-hooks", nargs="*", action="append", choices=("continuity", "checkpoints", "standards"), help="Remove selected presets, or all owned presets when no names are given") modes.add_argument("--check-hooks", action="store_true", help="Check registration, runtime files, and host version") modes.add_argument("--upgrade-registration", action="store_true", diff --git a/scripts/test_hook_presets.py b/scripts/test_hook_presets.py index 7cb7b19..b26bd03 100644 --- a/scripts/test_hook_presets.py +++ b/scripts/test_hook_presets.py @@ -61,95 +61,49 @@ def legacy(self, host): receipt.write_bytes(hooks.serialize(value["receipt"])) return config, skill, receipt - def test_both_hosts_install_and_remove_orders_preserve_exact_original_and_state( - self, - ): + # What each preset registers. SessionStart is shared by all of them. + EVENTS = { + "continuity": {"SessionStart", "PreCompact"}, + "checkpoints": {"SessionStart", "PreToolUse", "Stop"}, + "standards": {"SessionStart", "PostToolUse"}, + } + TOTAL_GROUPS = 6 + + def test_every_preset_coexists_and_any_one_can_be_removed(self): original = b'{ "description": "keep", "hooks": {"SessionStart": []}}\n' for host in hooks.PATHS: - for first in hooks.PRESETS: - for removed in hooks.PRESETS: - with self.subTest(host=host, first=first, removed=removed): - _, config, _, receipt = hooks.locations(host, self.project) - config.parent.mkdir(exist_ok=True) - config.write_bytes(original) - self.install(host, [first]) - second = next(p for p in hooks.PRESETS if p != first) - plan = self.install(host, [second]) - state = json.loads(config.read_bytes()) - self.assertEqual(sum(map(len, state["hooks"].values())), 5) - self.assertEqual(len(state["hooks"]["SessionStart"]), 1) - self.assertEqual( - json.loads(receipt.read_bytes())["presets"], - list(hooks.PRESETS), - ) - before = config.read_bytes(), receipt.read_bytes() - self.install(host, [first]) - self.assertEqual( - before, (config.read_bytes(), receipt.read_bytes()) - ) - hooks.remove(host, self.project, presets=[removed]) - survivor = next(p for p in hooks.PRESETS if p != removed) - events = set(json.loads(config.read_bytes())["hooks"]) - self.assertEqual( - events, - {"SessionStart", "PreCompact"} - if survivor == "continuity" - else {"SessionStart", "PreToolUse", "Stop"}, - ) - self.assertIn(survivor, hooks.doctor(host, self.project)) - data = self.state(host) / "retained.json" - data.parent.mkdir(parents=True, exist_ok=True) - data.write_text("{}") - hooks.remove(host, self.project) - self.assertEqual(config.read_bytes(), original) - self.assertTrue(data.exists()) - self.assertTrue(plan["skill"].exists()) - - def test_absolute_shape_is_the_default_and_keeps_its_published_form(self): - """A shipped registration must keep its exact form once shape is a parameter.""" - for host, parent in (("codex", ".agents"), ("claude-code", ".claude")): - skill = self.project / parent / "skills/structured-coding" - for presets in ( - ("continuity",), - ("checkpoints",), - ("continuity", "checkpoints"), - ): - with self.subTest(host=host, presets=presets): - built = hooks.groups(host, self.project, skill, presets) + for removed in hooks.PRESETS: + with self.subTest(host=host, removed=removed): + _, config, _, receipt = hooks.locations(host, self.project) + config.parent.mkdir(exist_ok=True) + config.write_bytes(original) + for preset in hooks.PRESETS: + self.install(host, [preset]) + state = json.loads(config.read_bytes()) self.assertEqual( - built, - hooks.groups( - host, self.project, skill, presets, hooks.ABSOLUTE - ), + sum(map(len, state["hooks"].values())), self.TOTAL_GROUPS ) - for entries in built.values(): - for group in entries: - entry = group["hooks"][0] - self.assertEqual(entry["timeout"], 12) - words = shlex.split(entry["command"]) - script = Path(words[1]) - self.assertEqual(words[0], sys.executable) - self.assertEqual(script.parent, skill / "scripts") - self.assertTrue(script.is_absolute()) - self.assertEqual(words[2:5], ["event", "--host", host]) - self.assertEqual( - words[words.index("--project") + 1], str(self.project) - ) - - def test_unknown_shape_and_schema_are_refused(self): - skill = self.project / ".agents/skills/structured-coding" - with self.assertRaises(ValueError): - hooks.groups("codex", self.project, skill, ("continuity",), "relative") - with self.assertRaises(ValueError): - hooks.registered_command( - "relative", "codex", self.project, skill, "continuity", "pre-auto" - ) - for schema in (1, 2): - self.assertEqual(hooks.shape_for_schema(schema), hooks.ABSOLUTE) - self.assertEqual(hooks.shape_for_schema(3), hooks.PORTABLE) - for schema in (0, 4, 99, None, "1"): - with self.subTest(schema=schema), self.assertRaises(ValueError): - hooks.shape_for_schema(schema) + self.assertEqual(len(state["hooks"]["SessionStart"]), 1) + self.assertEqual( + json.loads(receipt.read_bytes())["presets"], list(hooks.PRESETS) + ) + before = config.read_bytes(), receipt.read_bytes() + self.install(host, [hooks.PRESETS[0]]) + self.assertEqual(before, (config.read_bytes(), receipt.read_bytes())) + hooks.remove(host, self.project, presets=[removed]) + survivors = [p for p in hooks.PRESETS if p != removed] + expected = set().union(*(self.EVENTS[p] for p in survivors)) + self.assertEqual(set(json.loads(config.read_bytes())["hooks"]), expected) + report = hooks.doctor(host, self.project) + for survivor in survivors: + self.assertIn(survivor, report) + data = self.state(host) / "retained.json" + data.parent.mkdir(parents=True, exist_ok=True) + data.write_text("{}") + hooks.remove(host, self.project) + self.assertEqual(config.read_bytes(), original) + self.assertTrue(data.exists()) + self.assertTrue(hooks.locations(host, self.project)[2].exists()) def owned_group_count(self, config): return sum(len(v) for v in json.loads(config.read_bytes())["hooks"].values()) diff --git a/scripts/test_standards.py b/scripts/test_standards.py index eca51b3..9cd3a62 100644 --- a/scripts/test_standards.py +++ b/scripts/test_standards.py @@ -11,6 +11,7 @@ import subprocess import sys import tempfile +import time import unittest from pathlib import Path from unittest.mock import patch @@ -887,5 +888,132 @@ def test_without_a_base_anywhere_run_reports_the_ordinary_reason(self): self.assertIn("--base is required", result.stderr) +class ProcessGroupTests(WorktreeTest): + """A timed-out tool must not leave a tree running behind it.""" + + def alive(self, pid, seconds=5): + end = time.monotonic() + seconds + while time.monotonic() < end: + try: + os.kill(pid, 0) + except (OSError, ProcessLookupError): + return False + time.sleep(0.1) + return True + + def test_a_timeout_ends_the_whole_tree_not_only_the_child(self): + pidfile = self.root / "grandchild.pid" + stub = self.root / "spawner" + stub.write_text( + "#!/bin/sh\n" + "sh -c 'sleep 300' &\n" + f"echo $! > '{pidfile}'\n" + "sleep 300\n" + ) + stub.chmod(0o755) + outcome, reason, _ = standards.execute( + self.project, {"name": "spawner"}, [str(stub)], 2 + ) + self.assertEqual(outcome, "INCONCLUSIVE") + self.assertIn("no result within", reason) + pid = int(pidfile.read_text().strip()) + self.assertFalse(self.alive(pid), "a grandchild outlived the timed-out tool") + + def test_a_tool_that_ignores_termination_is_still_ended(self): + stub = self.root / "stubborn" + stub.write_text("#!/bin/sh\ntrap '' TERM\nsleep 300\n") + stub.chmod(0o755) + started = time.monotonic() + outcome, _, _ = standards.execute( + self.project, {"name": "stubborn"}, [str(stub)], 2 + ) + self.assertEqual(outcome, "INCONCLUSIVE") + # Terminated, then killed after the grace period, rather than hanging. + self.assertLess(time.monotonic() - started, 2 + standards.GRACE_SECONDS * 2 + 5) + + def test_an_ordinary_tool_is_unaffected_by_the_new_session(self): + stub = self.root / "quick" + stub.write_text("#!/bin/sh\necho hello\nexit 1\n") + stub.chmod(0o755) + outcome, _, output = standards.execute( + self.project, {"name": "quick"}, [str(stub)], 30 + ) + self.assertEqual(outcome, "FAIL") + self.assertIn("hello", output) + + +class HandlerTests(WorktreeTest): + """The one hook this preset registers: report after a commit, block nothing.""" + + def setUp(self): + super().setUp() + self.bin = self.root / "bin" + self.bin.mkdir() + stub = self.bin / "ruff" + stub.write_text("#!/bin/sh\nexit 0\n"); stub.chmod(0o755) + environment = patch.dict( + os.environ, {"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}"}) + environment.start(); self.addCleanup(environment.stop) + + def config(self, trigger): + relative = dict(standards.LAYERS)["base"] + path = self.project / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('```json\n{"schema": 1, "checks": {"trigger": "%s", "tools": [' + '{"name": "ruff", "scope": "repository"},' + '{"name": "pyright", "enabled": false}]}}\n```\n' % trigger, + encoding="utf-8") + + def payload(self, command="git commit -m x", **extra): + return {"hook_event_name": "PostToolUse", "session_id": self.session, + "cwd": str(self.project), "tool_name": "Bash", + "tool_input": {"command": command}, **extra} + + def fire(self, payload): + result = subprocess.run( + [sys.executable, str(RUNTIME), "event", "--project", str(self.project), + "--host", "codex", "--event", "post-commit"], + input=json.dumps(payload), text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout), result.stderr + + def test_a_commit_reports_when_the_trigger_matches(self): + self.config("commit") + value, _ = self.fire(self.payload()) + context = value["hookSpecificOutput"]["additionalContext"] + self.assertIn("ruff: PASS", context) + self.assertIn("pyright: NOT RUN", context) + self.assertIn("advisory", context) + + def test_nothing_is_reported_when_the_trigger_does_not_match(self): + self.config("pr") + self.assertEqual(self.fire(self.payload())[0], {}) + + def test_only_a_direct_commit_is_acted_on(self): + self.config("commit") + for label, payload in { + "another command": self.payload("git status"), + "a chained command": self.payload("git commit -m x && rm -rf /"), + "another tool": self.payload(**{"tool_name": "Read"}), + "a redirected cwd": {**self.payload(), + "tool_input": {"command": "git commit -m x", + "cwd": "/elsewhere"}}, + "a foreign cwd": {**self.payload(), "cwd": str(self.root)}, + }.items(): + with self.subTest(case=label): + self.assertEqual(self.fire(payload)[0], {}) + + def test_a_malformed_payload_fails_open(self): + self.config("commit") + for payload in ({}, {"hook_event_name": "Stop"}, {"hook_event_name": "PostToolUse"}): + with self.subTest(payload=str(payload)[:24]): + value, stderr = self.fire(payload) + self.assertEqual(value, {}) + + 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], {}) + + if __name__ == "__main__": unittest.main() diff --git a/structured-coding/references/platforms.md b/structured-coding/references/platforms.md index c43e913..a14f482 100644 --- a/structured-coding/references/platforms.md +++ b/structured-coding/references/platforms.md @@ -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."".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. diff --git a/structured-coding/references/platforms.zh-CN.md b/structured-coding/references/platforms.zh-CN.md index c06c518..8b66fc9 100644 --- a/structured-coding/references/platforms.zh-CN.md +++ b/structured-coding/references/platforms.zh-CN.md @@ -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."".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。 diff --git a/structured-coding/references/standards.md b/structured-coding/references/standards.md index 1fb1620..0f2a258 100644 --- a/structured-coding/references/standards.md +++ b/structured-coding/references/standards.md @@ -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 @@ -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 diff --git a/structured-coding/scripts/standards.py b/structured-coding/scripts/standards.py index 62128de..464c724 100644 --- a/structured-coding/scripts/standards.py +++ b/structured-coding/scripts/standards.py @@ -10,6 +10,7 @@ import json import os import re +import signal import subprocess import time import sys @@ -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 @@ -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: @@ -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) diff --git a/translations.json b/translations.json index 9df51d8..62bd365 100644 --- a/translations.json +++ b/translations.json @@ -40,8 +40,8 @@ { "english": "structured-coding/references/platforms.md", "chinese": "structured-coding/references/platforms.zh-CN.md", - "english_sha256": "28194aa111ae5be67bd47ce64ce12f0b156846ce368d1901446725219df6e4e5", - "chinese_sha256": "4ac5f04457c5eee702c02ed281304d3cd2f5e3fbe5030aaf956d51d538c16e41" + "english_sha256": "c38c3a4029f8aa81fceff307fe0bf9d2053a4258104448ee3eb0231ececa1524", + "chinese_sha256": "b57aa667541f6d86712158cc2cf1ddfbce8be1bf06d2f7d23ee6a04f33ffd4ac" } ], "specification_sha256": {