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
15 changes: 14 additions & 1 deletion FAMILY.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# The check family — shared contract (v2)
# The check family — shared contract (v2.1)

Canonical copy: this file, in [srdcheck](https://github.com/chaoz23/srdcheck)
(the family's reference implementation). Sibling repos link here.
Expand Down Expand Up @@ -76,6 +76,14 @@ Members differ by what they produce, and the contract binds them accordingly.
| CLI `--pipe` | ✅ | — | — |
| MCP server | ✅ | — | — |

**A member declares its own class** in `tool.json` as
`"family_class": "verdict" | "transport" | "adjacent"`. The declaration lives
in the artifact, not in this file's prose, so tooling reads it without
parsing markdown and a member owns its own classification. A member that
declares no class is not assumed to have one: the conformance gate reports
the omission and states which class-gated surfaces it therefore did not
check, rather than guessing.

`--pipe` is one-query-in / one-verdict-out. It binds the verdict class because
that shape is what a verdict tool *is*; a transport that records a session has
no such shape, and requiring it there would produce a surface with no honest
Expand Down Expand Up @@ -114,6 +122,11 @@ repos pin by link, not by copy.

### Changelog

- **v2.1** — Members declare their class machine-readably as `family_class` in
`tool.json`. v2 introduced classes but left them stated only in this file's
members table, so the conformance gate had no way to apply clause 7 per class
and reported `--pipe` as missing on a transport member that v2 exempts. An
undeclared class is reported as such, never guessed.
- **v2** — Introduced member classes (verdict / transport / adjacent) and stated
clause 7 per class, so `--pipe` and an MCP server bind the tools whose shape
they fit. Corrected the preamble's claim to be wholly descriptive. Widened
Expand Down
61 changes: 53 additions & 8 deletions scripts/family_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@
"tablekit": ["report"],
}

# FAMILY.md v2 clause 7 binds agent-first surfaces per member class. --pipe is
# one-query-in/one-verdict-out and an MCP server exposes callable tools; neither
# shape means anything for a session ledger, so both bind the verdict class only.
CLASS_SURFACES = {
"verdict": {"skill_md", "tool_json", "llms_txt", "schema_flag", "pipe", "mcp_server"},
"transport": {"skill_md", "tool_json", "llms_txt", "schema_flag"},
"adjacent": {"skill_md", "tool_json", "llms_txt", "schema_flag"},
}
# Clause 1 (exit codes are the verdict; the honest lane) binds the verdict class
# fully, and the transport class where it emits a verdict. It does not bind the
# adjacent class: those tools are in different domains and predate the lane.
CLAUSE_1_CLASSES = {"verdict", "transport"}

HONEST_MARKERS = (
"honest", "cannot", "can't", "do not retry", "don't retry", "never retry",
"route it to a human", "to a human", "up to the dm", "discretion", "ambiguous",
Expand All @@ -80,6 +93,21 @@ def die(msg: str) -> None:
# ---------------------------------------------------------------- discovery


def read_family_class(repo: Path) -> str | None:
"""Read the member class the repo declares for itself. Reading it from the
artifact keeps this gate off FAMILY.md's prose, which would couple it to
markdown formatting -- brittleness that has already bitten this parser."""
tool_json = repo / "tool.json"
if not tool_json.is_file():
return None
try:
declared = json.loads(tool_json.read_text())
except json.JSONDecodeError:
return None
value = declared.get("family_class") or declared.get("class")
return value if value in CLASS_SURFACES else None


def _project_scripts(pyproject: Path) -> dict[str, str]:
"""Read [project.scripts]. Falls back to a regex on Python 3.10, which has no
tomllib -- the only table needed is a flat name = "module:attr" mapping."""
Expand Down Expand Up @@ -251,6 +279,8 @@ def audit(repo: Path) -> dict:

tool, argv = discover_entry(repo)
parsed = parse_skill(skill)
family_class = read_family_class(repo)
surfaces = CLASS_SURFACES.get(family_class, set())
findings: list[dict] = []

def add(rule, severity, message, evidence):
Expand All @@ -270,12 +300,24 @@ def add(rule, severity, message, evidence):
envelope_p = run(repo, argv, probe_args) if probe_args else None

# --- clause 7: the flags are codified by name -------------------------------
pipe_p = run(repo, argv, ["--pipe"], stdin="")
if "unrecognized arguments: --pipe" in pipe_p["stderr"] or \
"unknown option" in pipe_p["stderr"] or "unknown command" in pipe_p["stderr"]:
add("MISSING_PIPE", "high",
"FAMILY.md clause 7 codifies `--pipe` by name; the CLI rejects it.",
f"`{tool} --pipe` -> exit {pipe_p['code']}: {oneline(pipe_p['stderr'])}")
if family_class is None:
add("UNDECLARED_CLASS", "medium",
"tool.json declares no `family_class`, so FAMILY.md v2's per-class "
"clause-7 surfaces (`--pipe`, MCP server) were NOT evaluated. Add "
'`"family_class": "verdict" | "transport" | "adjacent"`. Reporting '
"this rather than assuming a class: guessing `verdict` would invent "
"breaches for a transport, and guessing `transport` would hide real "
"ones for a verdict tool.",
f"{repo / 'tool.json'}")

if "pipe" in surfaces:
pipe_p = run(repo, argv, ["--pipe"], stdin="")
if "unrecognized arguments: --pipe" in pipe_p["stderr"] or \
"unknown option" in pipe_p["stderr"] or "unknown command" in pipe_p["stderr"]:
add("MISSING_PIPE", "high",
f"FAMILY.md clause 7 codifies `--pipe` by name and binds it to the "
f"{family_class} class; the CLI rejects it.",
f"`{tool} --pipe` -> exit {pipe_p['code']}: {oneline(pipe_p['stderr'])}")

if schema_p["code"] != 0:
add("SCHEMA_NOT_FLAG", "high",
Expand Down Expand Up @@ -308,7 +350,8 @@ def add(rule, severity, message, evidence):
f"{sorted(parsed['documented']) or 'nothing'}")

# --- clause 1: the honest lane must not be overloaded with usage errors ------
bad_code = badflag_p["code"]
# Skipped for the adjacent class, which clause 1 does not bind.
bad_code = badflag_p["code"] if family_class in CLAUSE_1_CLASSES else None
for hc in sorted(parsed["honest"]):
if bad_code == hc and hc not in parsed["usage"]:
# Quote the clause that actually carries the honest-lane language, not
Expand Down Expand Up @@ -344,6 +387,7 @@ def add(rule, severity, message, evidence):

return {
"tool": tool,
"family_class": family_class,
"repo": str(repo),
"entry": argv,
"documented_codes": sorted(parsed["documented"]),
Expand Down Expand Up @@ -390,7 +434,8 @@ def main(argv: list[str] | None = None) -> int:
for r in results:
mark = "PASS" if r["conformant"] else "FAIL"
print(f"\n{mark} {r['tool']} ({r['repo']})")
print(f" documented {r['documented_codes']} · observed {r['observed_codes']}"
print(f" class={r['family_class'] or 'UNDECLARED'} · "
f"documented {r['documented_codes']} · observed {r['observed_codes']}"
f" · honest-lane {r['honest_lane_codes']}")
for f in r["findings"]:
print(f" [{f['severity']}] {f['rule']}")
Expand Down
4 changes: 4 additions & 0 deletions scripts/gen_tool_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ def build():
}
return {
"name": "srdcheck",
# FAMILY.md v2 clause 7 binds agent-first surfaces per member class.
# Declared here so the conformance gate reads it from the artifact
# rather than parsing the contract's prose.
"family_class": "verdict",
"version": __version__,
"description": (
"Deterministic rules verdicts over the SRD for game-running "
Expand Down
1 change: 1 addition & 0 deletions tool.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "srdcheck",
"family_class": "verdict",
"version": "0.9.0",
"description": "Deterministic rules verdicts over the SRD for game-running agents. Exit codes are scoped results: 0 passes the named checked scope, 1 conflicts with it, 2 cannot-adjudicate (unknown content or a DM-authority decision, including when the caller is the agent-DM — an honest refusal, not an error). Applied rules carry citations; boundary refusals do not invent them.",
"status": "alpha; versioned machine schemas and the N/N-1 semantic window are reported by `capabilities`",
Expand Down
Loading