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: 27dee98cb7cb86e6ef86e34568d08954179f9a82
ref: 63a355b9bf7ef2051fe82bf7eca03ccfdcb6bd11
path: srdcheck-src
- uses: actions/setup-python@v6
with:
Expand Down
12 changes: 7 additions & 5 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 18 additions & 1 deletion dmcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion scripts/assert_test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest


EXPECTED_TESTS = 202
EXPECTED_TESTS = 205
ROOT = Path(__file__).resolve().parent.parent


Expand Down
27 changes: 27 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tool.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down