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()
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()