From 5b44cc57a0e19e7ac0c97c982a51516c3680f284 Mon Sep 17 00:00:00 2001 From: Daniel Strader Date: Sat, 1 Aug 2026 18:40:42 -0700 Subject: [PATCH] feat: project dmcheck table evaluations --- README.md | 11 +- dmcheck/__init__.py | 9 +- dmcheck/cli.py | 16 ++- dmcheck/table_evaluation.py | 216 ++++++++++++++++++++++++++++++ dmcheck/table_event.py | 82 ++++++++++++ llms.txt | 3 + scripts/assert_test_collection.py | 2 +- tests/test_packaging.py | 10 ++ tests/test_table_event.py | 110 ++++++++++++++- tool.json | 1 + 10 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 dmcheck/table_evaluation.py diff --git a/README.md b/README.md index aa6148e..382eaa2 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,15 @@ resolution, so legal Reactions and interrupts are not falsely accused. Transport gaps, unknown versions/types, and zero-compatible streams return typed `incomplete` status and exit 2; malformed v1 events return `invalid` and exit 2. Redacted message content is likewise incomplete because absence of -prose cannot prove absence of a conduct obligation. This unreleased adapter is pinned to table-kit PR #11 and does not -imply host attestation. +prose cannot prove absence of a conduct obligation. This unreleased adapter +is pinned to table-kit PR #11 and does not imply host attestation. + +Add `--table-evaluation` to `run-events` when a portfolio host needs the +shared `table.evaluation/1.0` envelope. The projection has deterministic +evaluation identity, exact event evidence references, per-rule and aggregate +coverage, cursor/input digest, public-policy digest, and status/exit-code +mapping. It is always `self_attested`; dmcheck cannot confer host authority on +its own output. Transcript formats: UTF-8 JSONL of `{ts, author, content}`, or a JSON array of Discord-API-shaped messages (`{timestamp, author: {username}, content}`) in chronological or reverse-chronological order. Authors and content are strings. A supplied timestamp must be a finite nonnegative epoch number or a timezone-aware ISO-8601 string; malformed timestamps are rejected rather than silently disabling time-based rules. Source adapters should preserve immutable `id`, `audience`, `reply_to`/`correlation_id`, and `roll_id` fields so dmcheck can prove which question, roll, or event a response closes; ordinary later GM chatter is not treated as an answer. diff --git a/dmcheck/__init__.py b/dmcheck/__init__.py index 1438406..d7fbd95 100644 --- a/dmcheck/__init__.py +++ b/dmcheck/__init__.py @@ -7,8 +7,11 @@ apply_charter_overrides) from ._version import __version__ from .table_event import (KNOWN_EVENT_TYPES, TABLE_EVENT_SCHEMA_VERSION, - TableEventProjection, evaluate_table_event_path, - load_table_events) + TableEventProjection, + evaluate_table_event_contract_path, + evaluate_table_event_path, load_table_events) +from .table_evaluation import (TABLE_EVALUATION_SCHEMA_VERSION, + project_table_evaluation) __all__ = [ "EvaluationResult", "InputValidationError", "ValidationIssue", "RULES", @@ -18,4 +21,6 @@ "public_charter_digest", "redact_output", "__version__", "KNOWN_EVENT_TYPES", "TABLE_EVENT_SCHEMA_VERSION", "TableEventProjection", "evaluate_table_event_path", "load_table_events", + "TABLE_EVALUATION_SCHEMA_VERSION", "evaluate_table_event_contract_path", + "project_table_evaluation", ] diff --git a/dmcheck/cli.py b/dmcheck/cli.py index a323fd0..f9af6d6 100644 --- a/dmcheck/cli.py +++ b/dmcheck/cli.py @@ -17,7 +17,7 @@ import sys from . import (RULES, __version__, evaluate_paths, evaluate_table_event_path, - load_charter) + evaluate_table_event_contract_path, load_charter) from .core import (invalid_result, public_charter, public_charter_digest, redact_output) from .validation import (InputValidationError, issue, load_craft_input, @@ -38,6 +38,7 @@ }, "transcript": "UTF-8 JSONL of {ts, author, content, id?, audience?, reply_to?, correlation_id?, roll_id?} OR a JSON array of Discord-API-shaped messages", "table_event": "run-events accepts table.event/1.0 JSONL or a JSON array; contract gaps and unsupported variants return incomplete", + "table_evaluation": "run-events --table-evaluation emits self-attested table.evaluation/1.0; host authority is external", "ledger": "optional JSONL of {ts, type: turn|act|event, id?, actor?, text?}; event IDs correlate narration; actor/turn order never establishes action legality", "charter": "schema-versioned JSON config; canonical packaged default is dmcheck/default_charter.json; charter.gm is REQUIRED", "authority": "dmcheck evaluates conduct/communication only; upstream authority and the DM/table decide action legality; srdcheck is advisory", @@ -94,6 +95,8 @@ def main(argv=None): ap.add_argument("--notify-cmd", help="watch: shell command fired per source-observed OPEN finding (finding JSON on stdin)") ap.add_argument("--evaluation-ts", type=float, help="run: explicit Unix-seconds evaluation horizon") + ap.add_argument("--table-evaluation", action="store_true", + help="run-events: emit table.evaluation/1.0 instead of the native result") ap.add_argument("--version", action="version", version="dmcheck %s" % __version__) a = ap.parse_args(argv) @@ -101,6 +104,11 @@ def main(argv=None): if a.schema: print(json.dumps(SCHEMA, indent=1)) return 0 + if a.table_evaluation and a.command != "run-events": + return _print_invalid([ + issue("table_evaluation.command", "/table-evaluation", + "--table-evaluation is valid only with run-events") + ]) if a.command == "rules": print(json.dumps(RULES, indent=1)) return 0 @@ -281,6 +289,12 @@ def main(argv=None): issue("table_event.ledger_conflict", "/ledger", "run-events derives its ledger from the event stream; --ledger is not accepted") ]) + if a.table_evaluation: + result = evaluate_table_event_contract_path( + a.transcript, charter_path=a.charter, gm=a.gm, + dice_authors=a.dice_bot, mode="closed", now=a.evaluation_ts) + print(json.dumps(result, indent=1)) + return result["exit_code"] result = evaluate_table_event_path( a.transcript, charter_path=a.charter, gm=a.gm, dice_authors=a.dice_bot, mode="closed", now=a.evaluation_ts) diff --git a/dmcheck/table_evaluation.py b/dmcheck/table_evaluation.py new file mode 100644 index 0000000..622ea3b --- /dev/null +++ b/dmcheck/table_evaluation.py @@ -0,0 +1,216 @@ +"""Project dmcheck's native result into ``table.evaluation/1.0``. + +The projection is deterministic and always self-attested. A separate +protected host may later bind the same native result to host-owned context; +dmcheck cannot grant that authority to itself. +""" + +import hashlib +import json + +from ._version import __version__ +from .core import public_charter_digest +from .validation import ValidationIssue + + +TABLE_EVALUATION_SCHEMA_VERSION = "table.evaluation/1.0" + + +def _canonical_digest(value): + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _diagnostic(value): + if isinstance(value, ValidationIssue): + value = value.to_dict() + code = value.get("code", "dmcheck.diagnostic") if isinstance(value, dict) else "dmcheck.diagnostic" + message = value.get("message", str(value)) if isinstance(value, dict) else str(value) + result = {"code": str(code)[:256] or "dmcheck.diagnostic", + "message": str(message) or "dmcheck diagnostic"} + pointer = value.get("pointer") if isinstance(value, dict) else None + if isinstance(pointer, str): + result["pointer"] = pointer + return result + + +def _values(value): + if isinstance(value, (list, tuple, set)): + for item in value: + yield from _values(item) + elif value is not None and str(value): + yield str(value) + + +def _evidence_refs(finding): + evidence = finding.get("evidence") if isinstance(finding, dict) else None + refs = [] + if isinstance(evidence, dict): + for key in ("id", "event_id", "source_id", "correlation_id", + "obligation_id", "roll_id", "event_ids", "source_ids", + "correlation_ids", "obligation_ids", "roll_ids"): + for value in _values(evidence.get(key)): + if len(value) <= 256 and value not in refs: + refs.append(value) + return refs + + +def _finding(value, policy_version, policy_digest): + refs = _evidence_refs(value) + if not refs: + raise ValueError("TableEvent finding lacks an exact evidence reference") + severity = "advisory" if value.get("severity") == "advisory" else "finding" + return { + "finding_id": str(value["finding_id"])[:256], + "code": str(value.get("rule") or "dmcheck.finding")[:256], + "severity": severity, + "summary": str(value.get("summary") or value.get("detail") or + "dmcheck finding"), + "evidence_refs": refs, + "effective_policy": dict(value.get("effective_policy") or {}), + "policy_version": policy_version, + "policy_digest": policy_digest, + } + + +def _rule_coverage(result): + eligible = set(result.eligible_rules) + enabled = set((result.charter or {}).get("rules_enabled") or []) + evaluators = [] + for rule in sorted(set("R%d" % number for number in range(1, 9))): + if rule in eligible: + evaluators.append({"id": rule, "required": True, + "status": "evaluated", "eligible": 1, + "evaluated": 1, "skipped": 0, + "skip_reasons": []}) + elif rule not in enabled: + evaluators.append({ + "id": rule, "required": False, "status": "disabled", + "eligible": 0, "evaluated": 0, "skipped": 0, + "skip_reasons": [{"code": "rule.disabled_by_policy", + "message": "%s is disabled by the charter" % rule}], + }) + else: + # In a schema-valid, gap-free TableEvent stream, absence of a roll, + # turn, engine event, player question, or hidden-term policy makes + # the corresponding conduct rule not applicable; it is not an + # unreported coverage gap. Adapter blockers return before here. + evaluators.append({"id": rule, "required": True, + "status": "not_applicable", "eligible": 0, + "evaluated": 0, "skipped": 0, + "skip_reasons": []}) + return evaluators + + +def project_table_evaluation(result, projection): + """Return one schema-shaped self-attested portfolio evaluation.""" + if not projection.session_id or not projection.input_digest: + raise ValueError("a validated TableEvent projection is required") + charter = result.charter or {} + policy_digest = (public_charter_digest(charter) if charter + else _canonical_digest({"policy": "unavailable"})) + policy_version = "dmcheck-charter/%s/%s" % ( + charter.get("schema_version", "unknown"), + charter.get("charter_version", "unknown")) + native_errors = [_diagnostic(item) for item in result.errors] + + if projection.blockers: + compatible = projection.compatible_count + evaluators = [{ + "id": "dmcheck.adapter", "required": True, "status": "error", + "eligible": compatible, "evaluated": 0, "skipped": compatible, + "skip_reasons": [_diagnostic(item) for item in projection.blockers], + }] + else: + evaluators = _rule_coverage(result) + + projected_findings = [] + advisories = [] + projection_errors = [] + for item in result.findings: + try: + projected = _finding(item, policy_version, policy_digest) + except (KeyError, TypeError, ValueError): + projection_errors.append({ + "code": "table_evaluation.finding_evidence_missing", + "message": "a native finding lacked an exact TableEvent evidence reference", + }) + continue + (advisories if projected["severity"] == "advisory" + else projected_findings).append(projected) + + evaluator_skipped = sum(item["skipped"] for item in evaluators) + complete = (result.status in {"clean", "findings"} + and evaluator_skipped == 0 and not projection_errors) + eligible = projection.compatible_count + evaluated = eligible if complete else 0 + skipped = eligible - evaluated + native_errors.extend(projection_errors) + + if not complete: + codes = {item["code"] for item in native_errors} + if any(code in {"table_event.unsupported_schema", + "table_event.unsupported_type"} for code in codes): + status = "unsupported" + elif ("evaluation.failed" in codes + or "table_evaluation.finding_evidence_missing" in codes): + status = "internal_error" + elif result.status == "invalid": + status = "invalid" + else: + status = "incomplete" + elif projected_findings: + status = "findings" + elif advisories: + status = "checked_with_advisories" + else: + status = "checked_clean" + + errors = native_errors + if not complete and not errors: + errors = [{"code": "dmcheck.incomplete", + "message": "dmcheck could not complete the TableEvent evaluation"}] + evaluation_material = { + "tool": "dmcheck", "version": __version__, + "session_id": projection.session_id, + "input_digest": projection.input_digest, + "native_status": result.status, + "finding_ids": [item["finding_id"] for item in result.findings], + "error_codes": [item["code"] for item in errors], + } + evaluation_id = "dmcheck-" + _canonical_digest(evaluation_material).split(":", 1)[1][:40] + return { + "schema_version": TABLE_EVALUATION_SCHEMA_VERSION, + "evaluation_id": evaluation_id, + "tool": {"name": "dmcheck", "version": __version__}, + "subject": {"kind": projection.subject_kind, "id": projection.session_id, + "session_id": (projection.session_id + if projection.subject_kind == "session" else None), + "entity_refs": []}, + "status": status, + "exit_code": 0 if status == "checked_clean" else (1 if status in { + "checked_with_advisories", "findings"} else 2), + "authority_status": "self_attested", + "coverage": { + "complete": complete, "evidence_required": True, + "input": projection.event_count, + "compatible": projection.compatible_count, + "eligible": eligible, "evaluated": evaluated, + "skipped": skipped, "evaluators": evaluators, + }, + "cursor": { + "checked_through_event_id": projection.checked_through_event_id, + "gap_state": ("none" if complete else + ("detected" if any(item["code"] == "table_event.transport_gap" + for item in errors) else "unknown")), + "input_digest": projection.input_digest, + }, + "context": {"roster_digest": None, "policy_digest": policy_digest, + "config_digest": None, "source_set_digest": None, + "session_descriptor_digest": None}, + "findings": projected_findings, + "advisories": advisories, + "warnings": [], + "errors": errors, + } diff --git a/dmcheck/table_event.py b/dmcheck/table_event.py index 6bc7e27..da1515b 100644 --- a/dmcheck/table_event.py +++ b/dmcheck/table_event.py @@ -6,6 +6,7 @@ """ from dataclasses import dataclass, field +import hashlib import json import math import re @@ -43,6 +44,9 @@ class TableEventProjection: blockers: list = field(default_factory=list) campaign_id: str = None session_id: str = None + input_digest: str = None + checked_through_event_id: str = None + subject_kind: str = "session" def _pointer_part(value): @@ -330,6 +334,9 @@ def load_table_events(path): raise InputValidationError(problems) projection = TableEventProjection(event_count=len(events), blockers=blockers) + canonical = json.dumps(events, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + projection.input_digest = "sha256:" + hashlib.sha256(canonical).hexdigest() current = [(index, event) for index, event in enumerate(events) if isinstance(event, dict) and event.get("schema_version") == TABLE_EVENT_SCHEMA_VERSION] @@ -338,6 +345,9 @@ def load_table_events(path): if current: projection.campaign_id = current[0][1]["campaign_id"] projection.session_id = current[0][1]["session_id"] + last_id = current[-1][1].get("event_id") + if isinstance(last_id, str) and last_id: + projection.checked_through_event_id = last_id seen = set() expected = 1 sequence_problems = [] @@ -458,3 +468,75 @@ def evaluate_table_event_path(event_path, charter_path=None, gm=None, evaluation_ts=evaluation_ts) return evaluate(projection.transcript, charter, projection.ledger, mode=mode, now=now) + + +def _invalid_input_projection(path): + try: + with open(path, "rb") as stream: + raw = stream.read() + except (OSError, TypeError): + raw = b"" + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + event_count = 0 + session_id = None + checked = None + try: + events = _read_events(path) + event_count = len(events) + sessions = {item.get("session_id") for item in events + if isinstance(item, dict) + and isinstance(item.get("session_id"), str) + and item.get("session_id")} + if len(sessions) == 1: + session_id = next(iter(sessions)) + for item in reversed(events): + if isinstance(item, dict) and isinstance(item.get("event_id"), str): + checked = item["event_id"] or None + break + except InputValidationError: + pass + return TableEventProjection( + event_count=event_count, input_digest=digest, + session_id=session_id or ("input-" + digest.split(":", 1)[1][:24]), + checked_through_event_id=checked, + subject_kind="session" if session_id else "artifact") + + +def evaluate_table_event_contract_path(event_path, charter_path=None, gm=None, + dice_authors=None, mode="closed", now=None): + """Evaluate a stream and return ``table.evaluation/1.0`` projection.""" + from .table_evaluation import project_table_evaluation + + if mode not in ("closed", "live"): + result = invalid_result([ + issue("evaluation.mode", "/mode", "mode must be 'closed' or 'live'") + ], mode=None) + return project_table_evaluation(result, _invalid_input_projection(event_path)) + charter = None + projection = None + problems = [] + try: + charter = load_charter(charter_path, gm=gm, dice_authors=dice_authors) + except InputValidationError as exc: + problems.extend(exc.issues) + try: + projection = load_table_events(event_path) + except InputValidationError as exc: + problems.extend(exc.issues) + if projection is None: + projection = _invalid_input_projection(event_path) + if problems: + result = invalid_result(problems, mode=mode, charter=charter) + elif projection.blockers: + try: + evaluation_ts = normalize_timestamp(now) if now is not None else None + result = EvaluationResult( + "incomplete", mode, messages=len(projection.transcript), + errors=projection.blockers, charter=charter, + evaluation_ts=evaluation_ts) + except InputValidationError as exc: + result = invalid_result(exc.issues, mode=mode, charter=charter) + else: + result = evaluate(projection.transcript, charter, projection.ledger, + mode=mode, now=now) + return project_table_evaluation(result, projection) diff --git a/llms.txt b/llms.txt index 2cae035..7e8b738 100644 --- a/llms.txt +++ b/llms.txt @@ -34,3 +34,6 @@ is incomplete; terminal output retains the typed fail-closed envelope. run-events strictly projects table.event/1.0 JSON/JSONL. Transport gaps, unknown versions/types, and zero-compatible streams are incomplete (exit 2), never clean. action.declared is not an authoritative engine resolution. +run-events --table-evaluation emits deterministic table.evaluation/1.0 with +exact evidence refs and coverage. It is always self_attested; only a protected +external host may claim host_attested. diff --git a/scripts/assert_test_collection.py b/scripts/assert_test_collection.py index 09087c5..6fa656f 100644 --- a/scripts/assert_test_collection.py +++ b/scripts/assert_test_collection.py @@ -6,7 +6,7 @@ import pytest -EXPECTED_TESTS = 196 +EXPECTED_TESTS = 202 ROOT = Path(__file__).resolve().parent.parent diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 79a13a1..3172651 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -306,6 +306,16 @@ def test_cold_installed_wheel_runs_table_event_adapter(self): self.assertIn("table_event.transport_gap", {item["code"] for item in result["errors"]}) self.assertNotIn("Traceback", proc.stdout + proc.stderr) + contract_proc = subprocess.run( + [str(dmcheck), "run-events", str(stream), "--gm", "gm-dan", + "--table-evaluation"], cwd=elsewhere, + capture_output=True, text=True) + self.assertEqual(contract_proc.returncode, 2, contract_proc.stderr) + contract_result = json.loads(contract_proc.stdout) + self.assertEqual(contract_result["schema_version"], + "table.evaluation/1.0") + self.assertEqual(contract_result["status"], "incomplete") + self.assertEqual(contract_result["authority_status"], "self_attested") def test_cold_installed_server_json_entrypoint_discovers(self): """Install the candidate wheel, then launch the console script named diff --git a/tests/test_table_event.py b/tests/test_table_event.py index 3432f89..f2ec28b 100644 --- a/tests/test_table_event.py +++ b/tests/test_table_event.py @@ -2,7 +2,10 @@ import json -from dmcheck import evaluate_table_event_path, load_table_events +from dmcheck import (evaluate_table_event_contract_path, + evaluate_table_event_path, load_charter, + load_table_events, project_table_evaluation) +from dmcheck.core import EvaluationResult from dmcheck.cli import main @@ -209,3 +212,108 @@ def test_run_events_rejects_separate_ledger(tmp_path, capsys): "--ledger", "events.jsonl"]) == 2 output = json.loads(capsys.readouterr().out) assert output["errors"][0]["code"] == "table_event.ledger_conflict" + + +def test_table_evaluation_projects_clean_and_finding_statuses(tmp_path): + clean_events = [ + event(1, "roll.observed", {"total": 19, "roll_kind": "attack"}, + actor="fighter-vesh", role="agent"), + event(2, "narration.obligation", + {"obligation_id": "consume-1", "kind": "consume_roll"}, + actor=None, role="system", cause="evt-001"), + event(3, "narration.observed", + {"content": "It lands.", + "resolves_obligation_ids": ["consume-1"]}), + ] + clean = evaluate_table_event_contract_path( + write_events(tmp_path, clean_events), gm=["gm-dan"]) + assert clean["schema_version"] == "table.evaluation/1.0" + assert clean["status"] == "checked_clean" + assert clean["coverage"]["complete"] is True + assert clean["authority_status"] == "self_attested" + + finding_events = [ + event(1, "narration.observed", + {"content": "You enter.", "resolves_obligation_ids": []}), + event(2, "message.observed", + {"content": "Are they safe?", "content_redacted": False}, + actor="rogue-brae", role="player", audience=["gm-dan"]), + ] + finding = evaluate_table_event_contract_path( + write_events(tmp_path, finding_events), gm=["gm-dan"]) + assert finding["status"] == "findings" + assert finding["findings"][0]["evidence_refs"][0] == "evt-002" + + +def test_table_evaluation_gap_and_redaction_are_incomplete(tmp_path): + for payload, event_type, expected in ( + ({"expected_sequence": 1, "observed_sequence": 2, + "recoverable": False}, "transport.gap", "table_event.transport_gap"), + ({"content": "", "content_redacted": True}, + "message.observed", "table_event.content_redacted"), + ): + result = evaluate_table_event_contract_path( + write_events(tmp_path, [event(1, event_type, payload, + actor=None, role="system")]), + gm=["gm-dan"]) + assert result["status"] == "incomplete" + assert result["exit_code"] == 2 + assert result["coverage"]["complete"] is False + assert expected in {item["code"] for item in result["errors"]} + + +def test_table_evaluation_distinguishes_unsupported_and_invalid(tmp_path): + future = event(1, "weather.changed", {"weather": "fog"}) + unsupported = evaluate_table_event_contract_path( + write_events(tmp_path, [future]), gm=["gm-dan"]) + assert unsupported["status"] == "unsupported" + + malformed = event(1, "roll.observed", {"total": "bad", "roll_kind": ""}) + invalid = evaluate_table_event_contract_path( + write_events(tmp_path, [malformed]), gm=["gm-dan"]) + assert invalid["status"] == "invalid" + assert invalid["subject"]["kind"] == "session" + + +def test_table_evaluation_is_deterministic(tmp_path): + path = write_events(tmp_path, [ + event(1, "narration.observed", + {"content": "Begin.", "resolves_obligation_ids": []}), + ]) + first = evaluate_table_event_contract_path(path, gm=["gm-dan"]) + second = evaluate_table_event_contract_path(path, gm=["gm-dan"]) + assert first == second + assert first["evaluation_id"].startswith("dmcheck-") + assert first["cursor"]["input_digest"].startswith("sha256:") + + +def test_run_events_can_emit_table_evaluation(tmp_path, capsys): + path = write_events(tmp_path, [ + event(1, "transport.gap", + {"expected_sequence": 1, "observed_sequence": 2, + "recoverable": False}, actor=None, role="system"), + ]) + assert main(["run-events", str(path), "--gm", "gm-dan", + "--table-evaluation"]) == 2 + output = json.loads(capsys.readouterr().out) + assert output["schema_version"] == "table.evaluation/1.0" + assert output["status"] == "incomplete" + + +def test_table_evaluation_missing_evidence_ref_fails_typed_not_traceback(tmp_path): + projection = load_table_events(write_events(tmp_path, [ + event(1, "narration.observed", + {"content": "Begin.", "resolves_obligation_ids": []}), + ])) + native = EvaluationResult( + "findings", "closed", messages=1, + findings=[{"finding_id": "future-finding", "rule": "R1", + "summary": "future finding", "severity": "finding", + "evidence": {}, "effective_policy": {}}], + eligible_rules=["R1"], charter=load_charter(gm=["gm-dan"])) + projected = project_table_evaluation(native, projection) + assert projected["status"] == "internal_error" + assert projected["exit_code"] == 2 + assert projected["findings"] == [] + assert projected["errors"][0]["code"] == \ + "table_evaluation.finding_evidence_missing" diff --git a/tool.json b/tool.json index d3b014d..a70f40c 100644 --- a/tool.json +++ b/tool.json @@ -6,6 +6,7 @@ "commands": { "dmcheck run --gm [--charter f] [--ledger f] [--evaluation-ts n]": "referee or exactly replay the session; findings as JSON", "dmcheck run-events --gm [--charter f] [--evaluation-ts n]": "project table.event/1.0 evidence; gaps and unsupported events return incomplete", + "dmcheck run-events --gm --table-evaluation": "emit deterministic self-attested table.evaluation/1.0 with exact evidence references and coverage", "dmcheck watch --gm [--ledger f] [--follow]": "follow transcript and ledger JSONL; emit stable finding lifecycle and source-health events", "dmcheck rules": "the active rule set plus the retired R5 compatibility id, with definitions", "dmcheck --schema": "machine-readable I/O contract"