diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5b4607..36d36f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v5 with: repository: chaoz23/srdcheck - ref: 27dee98cb7cb86e6ef86e34568d08954179f9a82 + ref: 63a355b9bf7ef2051fe82bf7eca03ccfdcb6bd11 path: srdcheck-src - uses: actions/setup-python@v6 with: diff --git a/SKILL.md b/SKILL.md index 6a9cb75..37e388c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -33,11 +33,13 @@ deterministic: same transcript, same findings, every time. ## Exit codes ARE the verdict `0` = clean (silence) · `1` = findings · `2` = unusable input/charter (the -honest lane — fix the input, don't retry blind). **Every envelope — clean, -findings, and the honest lane — prints JSON on `stdout`**, never a traceback. -`stderr` carries only argparse usage errors, which also exit 2 but emit no -JSON. So: exit 2 with an envelope on stdout is the honest lane; exit 2 with -empty stdout means the call was malformed — fix it and retry. Read stdout. +honest lane — fix the input, don't retry blind) · `3` = usage error (you +called it wrong; fix the call and retry). **Every envelope — clean, findings, +and the honest lane — prints JSON on `stdout`**, never a traceback. `stderr` +carries only the exit-3 usage errors, which emit no JSON. + +Exit 2 and exit 3 are disjoint on purpose: 2 is a verdict you route to a +human, 3 is a mistake you fix yourself. Read stdout for the verdict. ## Invocation diff --git a/dmcheck/cli.py b/dmcheck/cli.py index f9af6d6..9461b37 100644 --- a/dmcheck/cli.py +++ b/dmcheck/cli.py @@ -77,8 +77,25 @@ def _print_invalid(problems, mode="closed"): return result.exit_code +class _UsageExitsThree(argparse.ArgumentParser): + """Usage errors exit 3, not argparse's default 2. + + FAMILY.md clause 1: exit 2 is the honest lane -- a first-class + cannot-adjudicate verdict that a consuming agent routes to a human WITHOUT + retrying. A malformed invocation is the opposite: the caller should fix the + call and retry. Sharing one code made the two indistinguishable except by + whether stdout happened to carry JSON, so an agent escalated its own bad + calls to a human as if they were rulings. srdcheck's exit 3 is the family + precedent. See #15. + """ + + def error(self, message): + self.print_usage(sys.stderr) + self.exit(3, f"{self.prog}: error: {message}\n") + + def main(argv=None): - ap = argparse.ArgumentParser(prog="dmcheck", description=__doc__) + ap = _UsageExitsThree(prog="dmcheck", description=__doc__) ap.add_argument("command", nargs="?", choices=["run", "run-events", "rules", "charter", "init", "watch", "lint-charter", "explain", "craft"], default="run") ap.add_argument("transcript", nargs="?") ap.add_argument("--charter", help="charter JSON (default: packaged default)") diff --git a/scripts/assert_test_collection.py b/scripts/assert_test_collection.py index 6fa656f..34ef677 100644 --- a/scripts/assert_test_collection.py +++ b/scripts/assert_test_collection.py @@ -6,7 +6,7 @@ import pytest -EXPECTED_TESTS = 202 +EXPECTED_TESTS = 205 ROOT = Path(__file__).resolve().parent.parent diff --git a/tests/test_core.py b/tests/test_core.py index 86a8da8..22b3fe3 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -60,6 +60,33 @@ def test_exit_code(self): self.assertEqual(self.code, 1) +class TestUsageErrorsAreDisjointFromTheHonestLane(unittest.TestCase): + """FAMILY.md clause 1: exit 2 is the honest lane, a cannot-adjudicate + verdict a consuming agent routes to a human WITHOUT retrying. A malformed + invocation is the opposite -- fix the call and retry. They must not share + an exit code, or an agent escalates its own bad calls as if they were + rulings. srdcheck's exit 3 is the family precedent. See #15.""" + + def _code(self, argv): + from contextlib import redirect_stderr, redirect_stdout + import io + from dmcheck.cli import main as cli_main + with redirect_stderr(io.StringIO()), redirect_stdout(io.StringIO()): + try: + return cli_main(argv) + except SystemExit as exc: # argparse exits rather than returns + return exc.code + + def test_unknown_flag_is_a_usage_error(self): + self.assertEqual(self._code(["--zzz-not-a-real-flag"]), 3) + + def test_unknown_command_is_a_usage_error(self): + self.assertEqual(self._code(["not-a-real-command"]), 3) + + def test_unreadable_input_is_still_the_honest_lane(self): + self.assertEqual(self._code(["run", "/nonexistent/probe.json"]), 2) + + class TestGuards(unittest.TestCase): def test_no_gm_declared_is_unusable(self): ch = load_charter(CH) diff --git a/tool.json b/tool.json index 753df5f..e034862 100644 --- a/tool.json +++ b/tool.json @@ -13,7 +13,7 @@ "dmcheck --schema": "machine-readable I/O contract" }, "lifecycle": "Each finding has a deterministic finding_id. session_end returns the typed closed evaluation plus incomplete obligations and source coverage.", - "exit_codes": {"0": "clean", "1": "findings present", "2": "charter or input unusable"}, + "exit_codes": {"0": "clean", "1": "findings present", "2": "charter or input unusable (the honest lane; route to a human, do not retry)", "3": "usage error (fix the call and retry)"}, "result_status": ["clean", "findings", "invalid", "incomplete"], "schemas": ["dmcheck/charter.schema.json", "dmcheck/transcript.schema.json", "dmcheck/ledger.schema.json", "dmcheck/evaluation-result.schema.json"], "mcp": {