From 2b204c70d19fff479f6b6939f355165560656a52 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Tue, 29 Sep 2026 11:22:53 +0800 Subject: [PATCH 1/2] Add spreadsheet completeness stop guard Change-Id: I913b91811835219351c9267075e078ccbe5795d1 --- engine/hooks/hooks.toml | 5 ++ .../spreadsheet-completeness-guard/README.md | 8 +++ .../claude.hook.json | 5 ++ .../claude_stop_check.py | 10 ++++ .../codex.hook.json | 5 ++ .../codex_stop_check.py | 10 ++++ .../cursor.hook.json | 5 ++ .../cursor_stop_check.py | 10 ++++ .../spreadsheet-completeness-guard/detect.py | 55 +++++++++++++++++++ .../tests/test_hooks.py | 38 +++++++++++++ 10 files changed, 151 insertions(+) create mode 100644 engine/hooks/spreadsheet-completeness-guard/README.md create mode 100644 engine/hooks/spreadsheet-completeness-guard/claude.hook.json create mode 100644 engine/hooks/spreadsheet-completeness-guard/claude_stop_check.py create mode 100644 engine/hooks/spreadsheet-completeness-guard/codex.hook.json create mode 100644 engine/hooks/spreadsheet-completeness-guard/codex_stop_check.py create mode 100644 engine/hooks/spreadsheet-completeness-guard/cursor.hook.json create mode 100644 engine/hooks/spreadsheet-completeness-guard/cursor_stop_check.py create mode 100644 engine/hooks/spreadsheet-completeness-guard/detect.py create mode 100644 engine/hooks/spreadsheet-completeness-guard/tests/test_hooks.py diff --git a/engine/hooks/hooks.toml b/engine/hooks/hooks.toml index 7c8f4268f..82d9d6be1 100644 --- a/engine/hooks/hooks.toml +++ b/engine/hooks/hooks.toml @@ -212,6 +212,11 @@ summary = "Stops drift after the user narrowed the task twice." enabled_by = "CATSTACK_REFLECT_ENFORCEMENT" rule_modes = { "scope-lock.prompt-instruction" = "warn", "scope-lock.reflection-acknowledged" = "warn" } +[hooks.spreadsheet-completeness-guard] +mode = "stop" +why_mode = "attention" +summary = "Blocks spreadsheet completion claims without source-backed entity coverage." + [hooks.scratchpad-collision] mode = "stop" why_mode = "outward" diff --git a/engine/hooks/spreadsheet-completeness-guard/README.md b/engine/hooks/spreadsheet-completeness-guard/README.md new file mode 100644 index 000000000..b8cd99595 --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/README.md @@ -0,0 +1,8 @@ +# spreadsheet-completeness-guard + +Stop hook for spreadsheet completion claims. A completion claim must carry a +source-backed `SPREADSHEET_COVERAGE: PASS` receipt from the expected-entity +validator. Missing observations must be explicitly classified; estimates and +zeros are not substitutes for absent competitor data. + +Tests: `python3 -m unittest discover -s engine/hooks/spreadsheet-completeness-guard/tests -v` diff --git a/engine/hooks/spreadsheet-completeness-guard/claude.hook.json b/engine/hooks/spreadsheet-completeness-guard/claude.hook.json new file mode 100644 index 000000000..3562a0c98 --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/claude.hook.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "Stop": [{"matcher": "*", "hooks": [{"type": "command", "command": "python3 $HOME/.claude/hooks/spreadsheet-completeness-guard/claude_stop_check.py", "timeout": 10}]}] + } +} diff --git a/engine/hooks/spreadsheet-completeness-guard/claude_stop_check.py b/engine/hooks/spreadsheet-completeness-guard/claude_stop_check.py new file mode 100644 index 000000000..4a4d88062 --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/claude_stop_check.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import os +import sys +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + +if __name__ == "__main__": + run_hook("spreadsheet-completeness-guard", "claude", detect, "Stop", json_error_stderr=False) diff --git a/engine/hooks/spreadsheet-completeness-guard/codex.hook.json b/engine/hooks/spreadsheet-completeness-guard/codex.hook.json new file mode 100644 index 000000000..447e5ad22 --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/codex.hook.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "Stop": [{"matcher": "*", "hooks": [{"type": "command", "command": "python3 $HOME/.codex/hooks/spreadsheet-completeness-guard/codex_stop_check.py", "timeout": 10}]}] + } +} diff --git a/engine/hooks/spreadsheet-completeness-guard/codex_stop_check.py b/engine/hooks/spreadsheet-completeness-guard/codex_stop_check.py new file mode 100644 index 000000000..dd667ce59 --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/codex_stop_check.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import os +import sys +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + +if __name__ == "__main__": + run_hook("spreadsheet-completeness-guard", "codex", detect, "Stop") diff --git a/engine/hooks/spreadsheet-completeness-guard/cursor.hook.json b/engine/hooks/spreadsheet-completeness-guard/cursor.hook.json new file mode 100644 index 000000000..8705fc896 --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/cursor.hook.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "stop": [{"command": "python3 $HOME/.cursor/hooks/spreadsheet-completeness-guard/cursor_stop_check.py", "timeout": 10}] + } +} diff --git a/engine/hooks/spreadsheet-completeness-guard/cursor_stop_check.py b/engine/hooks/spreadsheet-completeness-guard/cursor_stop_check.py new file mode 100644 index 000000000..d586b3f52 --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/cursor_stop_check.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import os +import sys +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + +if __name__ == "__main__": + run_hook("spreadsheet-completeness-guard", "cursor", detect, "stop") diff --git a/engine/hooks/spreadsheet-completeness-guard/detect.py b/engine/hooks/spreadsheet-completeness-guard/detect.py new file mode 100644 index 000000000..62b6b32dc --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/detect.py @@ -0,0 +1,55 @@ +"""Stop completion claims for spreadsheet work without a coverage receipt.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "_sdk")) +from finding import Finding # noqa: E402 + +RULE_INCOMPLETE = "spreadsheet-completeness-guard.incomplete" +SPREADSHEET_RE = re.compile(r"\b(?:spreadsheet|google sheet|google sheets|workbook|raw tab|aggregation table|chart)\b", re.I) +COMPLETION_RE = re.compile(r"\b(?:complete|completed|done|filled|populated|regenerated|ready|all competitors)\b", re.I) +RECEIPT_RE = re.compile(r"SPREADSHEET_COVERAGE\s*:\s*PASS\b", re.I) +FAIL_RE = re.compile(r"SPREADSHEET_COVERAGE\s*:\s*FAIL\b", re.I) + + +def claims_completion(message: str) -> bool: + return bool(SPREADSHEET_RE.search(message or "") and COMPLETION_RE.search(message or "")) + + +def has_receipt(message: str) -> bool: + return bool(RECEIPT_RE.search(message or "")) + + +def decide(payload: dict) -> Finding | None: + message = str(payload.get("last_assistant_message") or payload.get("message") or "") + if not claims_completion(message) or has_receipt(message): + return None + try: + retry_count = int(payload.get("spreadsheet_retry_count") or 0) + except (TypeError, ValueError): + retry_count = 0 + retry_count = max(0, min(retry_count, 3)) + if FAIL_RE.search(message): + return Finding( + rule_id=RULE_INCOMPLETE, + subject="spreadsheet coverage failure", + message="Coverage failed after the bounded retry loop. Do not generate or declare the table/chart complete; list the unresolved entity/period keys.", + evidence=f"SPREADSHEET_COVERAGE: FAIL; retry {retry_count}/3", + ) + return Finding( + rule_id=RULE_INCOMPLETE, + subject="spreadsheet completion claim", + message=(f"Do not declare spreadsheet data complete until the expected-entity coverage " + f"validator has passed. Run source collection retry {min(retry_count + 1, 3)}/3; " + "add source-backed rows or state unresolved entity/period keys. " + "Only SPREADSHEET_COVERAGE: PASS permits completion."), + evidence=f"completion claim without a coverage receipt; retry {retry_count}/3", + ) + + +def detect(event: dict[str, object]) -> list[Finding]: + finding = decide(event) + return [] if finding is None else [finding] diff --git a/engine/hooks/spreadsheet-completeness-guard/tests/test_hooks.py b/engine/hooks/spreadsheet-completeness-guard/tests/test_hooks.py new file mode 100644 index 000000000..375deda37 --- /dev/null +++ b/engine/hooks/spreadsheet-completeness-guard/tests/test_hooks.py @@ -0,0 +1,38 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import detect # noqa: E402 + + +class SpreadsheetCompletenessTests(unittest.TestCase): + def test_blocks_completion_without_coverage_receipt(self): + finding = detect.decide({"last_assistant_message": "The spreadsheet is complete and all competitors are populated."}) + self.assertIsNotNone(finding) + self.assertEqual(finding.rule_id, "spreadsheet-completeness-guard.incomplete") + + def test_stays_silent_with_pass_receipt(self): + finding = detect.decide({"last_assistant_message": "SPREADSHEET_COVERAGE: PASS — the workbook is complete."}) + self.assertIsNone(finding) + + def test_stays_silent_for_non_spreadsheet_completion(self): + self.assertIsNone(detect.decide({"last_assistant_message": "The README edit is complete."})) + + def test_requests_bounded_retry_when_coverage_is_missing(self): + finding = detect.decide({ + "last_assistant_message": "The spreadsheet is complete.", + "spreadsheet_retry_count": 1, + }) + self.assertIn("retry 2/3", finding.message) + + def test_keeps_blocking_after_three_failed_retries(self): + finding = detect.decide({ + "last_assistant_message": "SPREADSHEET_COVERAGE: FAIL — the spreadsheet is complete.", + "spreadsheet_retry_count": 3, + }) + self.assertIn("after the bounded retry loop", finding.message) + + +if __name__ == "__main__": + unittest.main() From 96d5f912feca4af36b0bcf7eec27942567c73bf0 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Tue, 29 Sep 2026 12:37:56 +0800 Subject: [PATCH 2/2] Require a log when a caught exception is not rethrown. Co-authored-by: Cursor Change-Id: I8409b8f5933df08f9ebf6cd9bffc1f7a32ee3720 --- .../skills/principle-explicit-errors/SKILL.md | 26 +++++++++++------ .../tests/fires_example.md | 10 +++++-- .../tests/test_void_discard.py | 28 +++++++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 corpus/skills/principle-explicit-errors/tests/test_void_discard.py diff --git a/corpus/skills/principle-explicit-errors/SKILL.md b/corpus/skills/principle-explicit-errors/SKILL.md index a655a0aaf..e50fd4dfb 100644 --- a/corpus/skills/principle-explicit-errors/SKILL.md +++ b/corpus/skills/principle-explicit-errors/SKILL.md @@ -7,16 +7,21 @@ disable-model-invocation: true # Make errors and failures explicit Every failure path must make its disposition visible: fail fast, propagate, -translate into a named domain result, or suppress only a narrow, documented -case whose absence is safe and observable when that assumption changes. +translate into a named domain result, or log the exception with its context. +Never suppress an exception. Silence is not a policy. Each rule below restates standard practice (see Grounding). ## Exceptions -An empty or comments-only handler is not an error policy. It hides failures -from callers, logs, tests, and maintainers. Do not write `catch {}`, -`except: pass`, ignored promise rejections, or equivalent silent fallbacks. -Replace them with an explicit action and keep the original error context. +An empty handler, a comment-only handler, and a handler whose only action +is to discard the error (`catch {}`, `void err`, `except: pass`, +`.catch(() => {})`) hide the failure from callers, logs, tests, and +maintainers. Do not write them. + +If the exception must not propagate — a diagnostic callback, a metric +write, a best-effort side path — log it, then continue. The log names the +operation and includes the original exception. A comment that says why you +caught it is not a log. `void err` is not a log. Mechanical enforcement: `engine/hooks/explicit-failures` (advisory PreToolUse hook, on by default; its README lists the shapes it catches). @@ -85,9 +90,12 @@ The repo context is a batch data pipeline (filings in, CSV grids out), not a long-running service; each line says how the source fits that. - Silent handlers → Tim Peters, PEP 20 "The Zen of Python" (2004): "Errors - should never pass silently. Unless explicitly silenced." - ; Joshua Bloch, *Effective Java* 3rd ed. - (2018), Item 77 "Don't ignore exceptions". A batch job that swallows an + should never pass silently." . This + skill does not take the next sentence ("Unless explicitly silenced") as + permission to drop an exception. Joshua Bloch, *Effective Java* 3rd ed. + (2018), Item 77 "Don't ignore exceptions". When the exception must not + propagate, log it with the original exception attached. A comment, an + empty catch, or `void err` is not that log. A batch job that swallows an error ships a wrong CSV under a green run, so this applies as written. - Fail immediately and visibly → Jim Shore, "Fail Fast", *IEEE Software* 21(5) (2004) . Shore's diff --git a/corpus/skills/principle-explicit-errors/tests/fires_example.md b/corpus/skills/principle-explicit-errors/tests/fires_example.md index 5f9bd33ec..a092a7a2c 100644 --- a/corpus/skills/principle-explicit-errors/tests/fires_example.md +++ b/corpus/skills/principle-explicit-errors/tests/fires_example.md @@ -1,7 +1,11 @@ An agent is adding a fallback around an optional dependency and proposes -`try { loadOptionalModule() } catch {}`. The agent invokes -`/principle-explicit-errors`, names the expected import failure, and replaces -the silent handler with an explicit fallback plus a test for unexpected errors. +`try { loadOptionalModule() } catch {}`. A review bot then "fixes" a +diagnostic callback by replacing `void reportingFailure` with an empty +`catch {}` so the callback cannot change the caller's outcome. The agent +invokes `/principle-explicit-errors`: neither shape is allowed. If the +exception must not propagate, the handler logs the operation and the +original exception, and a test asserts that log. An empty catch, a +comment-only catch, and `void err` are all suppression. A second shape, same skill, no exception in sight. A realized-gains tab passes "every value traces to a source", then a review pass finds a sell diff --git a/corpus/skills/principle-explicit-errors/tests/test_void_discard.py b/corpus/skills/principle-explicit-errors/tests/test_void_discard.py new file mode 100644 index 000000000..6b15c7c8d --- /dev/null +++ b/corpus/skills/principle-explicit-errors/tests/test_void_discard.py @@ -0,0 +1,28 @@ +"""The skill's discard rule is the same check the failure hook runs.""" +from __future__ import annotations + +import os +import sys +import unittest + +HOOK_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "engine", "hooks", "explicit-failures") +) +sys.path.insert(0, HOOK_DIR) + +import detect # noqa: E402 + + +class VoidDiscardTest(unittest.TestCase): + def test_void_only_catch_is_reported(self): + text = "try { a() } catch (err) {\n void err;\n}\n" + hits = detect.scan_js(text) + self.assertEqual(hits, [(1, "catch block that only discards the error with `void`")]) + + def test_logged_catch_is_quiet(self): + text = "try { a() } catch (err) {\n console.error('failed', err);\n}\n" + self.assertEqual(detect.scan_js(text), []) + + +if __name__ == "__main__": + unittest.main()