diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f2e48e..a3fadc0 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: 0fbc4b9d161a9ca7e378a73ad88421ff6b278af3 + ref: 63a355b9bf7ef2051fe82bf7eca03ccfdcb6bd11 path: srdcheck-src - uses: actions/setup-python@v6 with: diff --git a/SKILL.md b/SKILL.md index dd5719d..07b05ac 100644 --- a/SKILL.md +++ b/SKILL.md @@ -33,8 +33,13 @@ tracking. ## Exit codes `0` = recorded/clean · `1` = a check failed (e.g. `beat`: the cue would be -dropped in transit; `qc`: findings) · `2` = can't do that (`init`: file -exists; `consumed`: id not open). Read the code; the message says which. +dropped in transit; `qc`: findings) · `2` = can't adjudicate — either a refusal +(`init`: file exists; `consumed`: id not open) **or incomplete coverage** +(`qc`, `report`: not enough of the session was evaluated to judge it) · `3` = +usage error (unknown command or flag). + +Exit 2 and exit 3 are disjoint on purpose: **2 is an answer you route to a +human, 3 is a mistake you fix yourself.** Read the code; the message says which. ## Invocation diff --git a/tablekit/cli.py b/tablekit/cli.py index aa32f41..6df136e 100644 --- a/tablekit/cli.py +++ b/tablekit/cli.py @@ -22,7 +22,7 @@ import time from . import contracts, detector, pairs, report, ux, uxr -from .config import ConfigError, load as load_config +from .config import ConfigError, UsageError, load as load_config from .events import (SCHEMA, Ledger, PAIR_KINDS, PAIR_OUTCOMES, ROUTE_STATUSES, SchemaError, write_atomic) from .legacy_events import LegacyMigrationError, migrate_ledger @@ -74,20 +74,20 @@ def _flag(args, name, default=None, takes_value=True): if not positions: return default if len(positions) > 1: - raise ConfigError(f"{name}: flag may be supplied only once") + raise UsageError(f"{name}: flag may be supplied only once") i = positions[0] args.pop(i) if not takes_value: return True if i >= len(args) or args[i].startswith("--"): - raise ConfigError(f"{name}: expected a value") + raise UsageError(f"{name}: expected a value") return args.pop(i) def _unknown_options(args): unknown = [value for value in args if value.startswith("-")] if unknown: - raise ConfigError( + raise UsageError( f"unknown option(s): {', '.join(unknown)}; see `tablekit --help`") @@ -706,9 +706,14 @@ def main(argv=None): fn = COMMANDS.get(cmd) if not fn: print(f"unknown command {cmd!r}\n\n{USAGE}", file=sys.stderr) - return 2 + return 3 try: return fn(args) + except UsageError as e: + # Checked before ConfigError: UsageError subclasses it, and a malformed + # call must not wear the honest lane's exit code. + print(f"{cmd}: {e}", file=sys.stderr) + return 3 except (ConfigError, SchemaError, LegacyMigrationError) as e: print(f"{cmd}: {e}", file=sys.stderr) return 2 diff --git a/tablekit/config.py b/tablekit/config.py index 134e18a..d4ff7eb 100644 --- a/tablekit/config.py +++ b/tablekit/config.py @@ -57,6 +57,22 @@ class ConfigError(ValueError): pass +class UsageError(ConfigError): + """A malformed invocation -- an unknown command or flag. + + FAMILY.md clause 1 keeps this disjoint from exit 2. Exit 2 is the honest + lane: a cannot-adjudicate verdict a consuming agent routes to a human + WITHOUT retrying. A bad call is the opposite -- the caller fixes it and + retries. Sharing one code made them indistinguishable, so an agent + escalated its own mistakes as if they were rulings. Exits 3, matching + srdcheck. See #1. + + Subclasses ConfigError so existing handlers still catch it; cli.main() + checks for it first and returns 3. + """ + + + #: Who throws the dice for a seat. `self` is the default and the right one for #: almost everyone — taking the dice off a player removes the best moment in #: the game. `dm` is an opt-out that matters to a real minority: accessibility, diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 23f2dc1..0b9fdd3 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -165,6 +165,38 @@ def test_schema_flag_matches_the_subcommand(self): self.assertEqual(json.loads(flag_out), json.loads(cmd_out)) +class DisjointExitCodeTests(unittest.TestCase): + """FAMILY.md clause 1: exit 2 is the honest lane -- an answer a consuming + agent routes to a human without retrying. A malformed call is the opposite + and must not wear the same code, or an agent escalates its own mistakes as + if they were rulings (chaoz23/table-kit#1). Exit 3 matches srdcheck.""" + + def _code(self, argv): + with redirect_stdout(io.StringIO()): + return main(argv) + + def test_unknown_command_is_a_usage_error(self): + self.assertEqual(self._code(["not-a-real-command"]), 3) + + def test_unknown_flag_is_a_usage_error(self): + self.assertEqual(self._code(["--zzz-not-a-real-flag"]), 3) + + def test_unknown_subcommand_option_is_a_usage_error(self): + self.assertEqual(self._code(["report", "--zzz-bad-option"]), 3) + + def test_a_real_refusal_stays_on_exit_two(self): + with tempfile.TemporaryDirectory() as tmp: + cwd = os.getcwd() + try: + os.chdir(tmp) + self.assertEqual(self._code(["init"]), 0) + # Second init refuses because the file exists. That is a verdict, + # not a bad call, so it must NOT have moved to 3. + self.assertEqual(self._code(["init"]), 2) + finally: + os.chdir(cwd) + + class LegacyMigrationTests(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/tests/test_tablekit.py b/tests/test_tablekit.py index 8756d2e..40be09a 100644 --- a/tests/test_tablekit.py +++ b/tests/test_tablekit.py @@ -757,8 +757,10 @@ def test_help_and_version(self): self.assertEqual(cli.main([]), 0) self.assertEqual(cli.main(["--version"]), 0) - def test_unknown_command_refuses(self): - self.assertEqual(cli.main(["frobnicate"]), 2) + def test_unknown_command_is_a_usage_error(self): + # Exit 3, not 2. FAMILY.md clause 1 keeps malformed calls disjoint from + # the honest lane, which an agent routes to a human without retrying. + self.assertEqual(cli.main(["frobnicate"]), 3) def test_unknown_duplicate_and_missing_flags_refuse_before_write(self): cases = [ @@ -768,7 +770,7 @@ def test_unknown_duplicate_and_missing_flags_refuse_before_write(self): ] for args in cases: with self.subTest(args=args): - self.assertEqual(self.run_cli(*args), 2) + self.assertEqual(self.run_cli(*args), 3) self.assertFalse(os.path.exists(self.led.path)) def test_empty_global_values_do_not_silently_select_defaults(self): diff --git a/tool.json b/tool.json index eec1169..9799279 100644 --- a/tool.json +++ b/tool.json @@ -34,7 +34,8 @@ "exit_codes": { "0": "clean", "1": "findings worth looking at", - "2": "refused — bad input or not enough data" + "2": "cannot adjudicate — refused, or not enough data to judge (route to a human)", + "3": "usage error — unknown command or flag (fix the call and retry)" }, "related": { "srdcheck": "rules verdicts",