Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions tablekit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`")


Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tablekit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions tests/test_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 5 additions & 3 deletions tests/test_tablekit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion tool.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down