From fb3dd106e0549316bf30e9a9c0dd0146cadcf628 Mon Sep 17 00:00:00 2001 From: vbanas Date: Fri, 16 Oct 2026 12:41:17 +0300 Subject: [PATCH 1/4] fix(decision): a fresh install routes to the Planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a project fresh from `install.sh`, `/xcheck-plan` could never start the Planner. Three refusals met in a loop: - `install.sh` writes no `audit/state.json`, by design (README §5.2). - `xcheck status` and `xcheck next` refuse without one, and the refusal advised running `install.sh` again, which writes none. - `xcheck migrate` refuses an AUDIT.md the Planner has not written. So the first role of every audit had no orchestrated route at all, and xcheck no longer ships a launcher for the uncontained one. Found by running `/xcheck-plan` on a new install. `decision.pre_state_decision` runs only when there is no state document, and answers in one of three ways: - `run-planner` for a proven fresh install whose AUDIT.md is not yet a complete plan. The session runs under the same eight controls and the same budget gates as any other, priced against an empty state. - `stop-migrate` once AUDIT.md converts into a complete plan. This is a human gate: loading the plan is the operator's act, so the orchestrator lists the queued passes and names `xcheck --dry-run migrate` and `xcheck migrate` instead. - Refusal, as before, for every other stateless tree, now with the reason. "Fresh" is three facts, and each fails closed. `audit/.provenance.json` must say `install.sh` made the tree. No findings, pass reports, plans, construals or LEDGER.md rows may exist, because those make it an old audit to convert. And the event journal must record no state transition: a tree whose state document existed and is gone is told to restore it, since planning again would start the audit over and orphan its history. `construal_gate=on` refuses the pre-state Planner rather than skipping the gate. A construal is admitted into the state document, which a new audit does not have, so the gate could only fail. The refusal says how to plan with it off and turn it back on. The same path had two smaller faults: - A failed Planner session raised a traceback in the retry check, which read the last session from a state file that does not exist yet. It now stops on the session's own outcome, with exit 1. - The no-progress message told the operator the charter was exactly where it was. Before migration the session's work is AUDIT.md, so it says that and points at `xcheck status`. The state-file error now names `xcheck migrate` only. README §5.2, §6 Step 1 and §8 describe the route. XCHECK.md and the launcher skills are unchanged, so installed trees need no upgrade. `tests/test_fresh_install.py` runs the real `install.sh`, and a fake agent for the dispatch cases: 17 tests, 15 of which fail on the parent. The other two are controls that a too-wide route would break. selftest 169 -> 170 for the `stop-migrate` row in the gate-message table. Full suite on this machine: 1,737 tests, 5 failures and 1 error, the same set as on the parent. Each one is a machine dependency: no `build` module, no `ruff`, and rows A2 and A3 of the SECURITY.md adversarial matrix. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- ci/check-split.py | 11 ++ ci/tiers.py | 3 + tests/test_fresh_install.py | 289 ++++++++++++++++++++++++++++++++++ tests/test_legacy_selftest.py | 5 +- xcheck/cli.py | 80 +++++++++- xcheck/decision.py | 97 ++++++++++-- xcheck/envelope.py | 16 +- xcheck/migrate.py | 78 ++++++++- xcheck/runner.py | 4 +- xcheck/selftest.py | 4 + xcheck/state.py | 22 ++- 12 files changed, 577 insertions(+), 40 deletions(-) create mode 100644 tests/test_fresh_install.py diff --git a/README.md b/README.md index 6990161..e87636b 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ cd xcheck ./install.sh /path/to/your-project ``` -This creates `your-project/audit/` containing `XCHECK.md`, an `AUDIT.md` template, `templates/`, and empty `findings/`, `passes/`, `plans/`. Nothing is audited yet — the Planner fills `AUDIT.md` in the first session, after which one operator command — `xcheck migrate` — loads that plan into `audit/state.json`, the canonical record. +This creates `your-project/audit/` containing `XCHECK.md`, an `AUDIT.md` template, `templates/`, and empty `findings/`, `passes/`, `plans/`. Nothing is audited yet — the Planner fills `AUDIT.md` in the first session, after which one operator command — `xcheck migrate` — loads that plan into `audit/state.json`, the canonical record. Until then the project has no state document, and that is expected: on a tree `install.sh` created and nothing has been recorded in, `xcheck status` reports `decision: run-planner` and `xcheck next` dispatches the Planner like any other session. Any other tree without `state.json` is still refused. There is no `LEDGER.md` to create: from 0.8 it is a **generated mirror** of `state.json`, rendered whenever state changes, never read back as authority and never hand-edited. @@ -225,6 +225,8 @@ The session catalogs the project's norms (style contracts, specs, ADRs, glossari **Your check afterwards (2 minutes):** open `AUDIT.md`. Does every dimension name a norm source? Are passes reasonably sized (heuristic: ≤3 chapters of text or ≤2 kloc of code per pass)? Cross out passes you don't want — trimming scope here is cheap and needs no gate. +Once the plan is complete, `xcheck status` reports `stop-migrate`: the plan is waiting for you, not for an agent. Load it with `xcheck --dry-run migrate` and then `xcheck migrate`. From then on the queue changes through `xcheck queue-pass`, `amend-pass` and `cancel-pass`, not by editing `AUDIT.md`. + ### Step 2 — Audit passes One session per pass, in queue order: @@ -354,9 +356,9 @@ xcheck selftest decision-logic + invariant self-checks Flags for `loop`: `--step` (pause between sessions), `--max-sessions N` (cost cap), `--budget SECONDS` (wall-clock budget — checked **before each session**, so the loop stops once elapsed time reaches it but a session already running is never interrupted; whichever of `--budget`/`--max-sessions` hits first), `--dry-run` (print the decision and the command without launching). -**Turn priority:** needs-human stop → verification of `fixed` → remediation (unfinished/reopened first, then accepted CF, then accepted findings) → audit passes → triage stop. The loop halts at: triage needed, `⚠ needs-human`, a dispute, a norm-ratification gate, an inconsistent ledger (non-terminal rows with no legal move), a broken/unclosed class dependency, a corrupt ledger or missing coverage report, queue exhausted (or exhausted-with-deferred-debt), a session error, a **non-converging cycle** (`loop_progress_limit` consecutive sessions that repeat the same decision without moving `audit/`), or the **orchestrator's own source changing mid-run** (a self-remediation edited `bin/xcheck`; the loop runs the code image loaded at its start, so restart `loop` to pick up the fix) — each with an explanation of what is expected from you. This is the authoritative, exhaustive list of the **orchestrator's halt points**, not a count of the methodology's human gates: the human gates among these halts (`⚠ needs-human`, a dispute, a norm-ratification gate, and triage) are enumerated canonically in `XCHECK.md` §3 — this list does not re-count them. +**Turn priority:** needs-human stop → verification of `fixed` → remediation (unfinished/reopened first, then accepted CF, then accepted findings) → audit passes → triage stop. Before any of that, a new audit with no `state.json` runs its Planner until the plan is complete. The loop halts at: a new audit's plan ready for `xcheck migrate`, triage needed, `⚠ needs-human`, a dispute, a norm-ratification gate, an inconsistent ledger (non-terminal rows with no legal move), a broken/unclosed class dependency, a corrupt ledger or missing coverage report, queue exhausted (or exhausted-with-deferred-debt), a session error, a **non-converging cycle** (`loop_progress_limit` consecutive sessions that repeat the same decision without moving `audit/`), or the **orchestrator's own source changing mid-run** (a self-remediation edited `bin/xcheck`; the loop runs the code image loaded at its start, so restart `loop` to pick up the fix) — each with an explanation of what is expected from you. This is the authoritative, exhaustive list of the **orchestrator's halt points**, not a count of the methodology's human gates: the human gates among these halts (`⚠ needs-human`, a dispute, a norm-ratification gate, and triage) are enumerated canonically in `XCHECK.md` §3 — this list does not re-count them. -**Your touch points in `loop` mode:** the routine one is install → triage sittings → final review, and on a clean run that is all. But every other halt above also needs you: `⚠ needs-human`, a dispute, a norm-ratification gate, a session error, a re-triage of deferred debt (the `exhausted-with-deferred-debt` stop — `deferred` is not terminal, §3, so accept or reject to fully close), or a repair (corrupt ledger / missing coverage / broken class link / an inconsistent ledger — non-terminal rows with no move), a non-converging cycle (sessions repeating without progress), or a restart after a self-edit to `bin/xcheck`. Between halts the loop runs by itself; it never silently works around a gate that wants a human, and it never keeps spinning on unchanged state or runs on a stale image of its own edited source. +**Your touch points in `loop` mode:** the routine one is install → migrate the plan → triage sittings → final review, and on a clean run that is all. But every other halt above also needs you: `⚠ needs-human`, a dispute, a norm-ratification gate, a session error, a re-triage of deferred debt (the `exhausted-with-deferred-debt` stop — `deferred` is not terminal, §3, so accept or reject to fully close), or a repair (corrupt ledger / missing coverage / broken class link / an inconsistent ledger — non-terminal rows with no move), a non-converging cycle (sessions repeating without progress), or a restart after a self-edit to `bin/xcheck`. Between halts the loop runs by itself; it never silently works around a gate that wants a human, and it never keeps spinning on unchanged state or runs on a stale image of its own edited source. ### orchestrator.conf diff --git a/ci/check-split.py b/ci/check-split.py index 97205d3..d24cc70 100644 --- a/ci/check-split.py +++ b/ci/check-split.py @@ -368,6 +368,17 @@ "REPORT, which must never raise: it is called while building " "a refusal, and a report that explodes turns an addressed " "operator error into a traceback", + + # The fresh-install route. The monolith had no state document, so it had no + # "before the state document exists" to decide in; the Planner of a new audit could + # not be dispatched by anything until this existed. + "pre_state_decision": "fresh install — the decision for a project with no " + "state.json yet: the Planner, or the operator's migrate gate, " + "and only on a tree proved to be a fresh install", + "_budget_stop": "fresh install — the budget gate over a dispatch, shared by both " + "decisions that can dispatch so a pre-state Planner is priced too", + "_status_tail": "fresh install — the quarantine, lock, sandbox and decision lines " + "`status` prints whether or not a state document exists", } # Modules that are NEW work, not split-out monolith code. Their names are exempt diff --git a/ci/tiers.py b/ci/tiers.py index 5d78bcf..0e05c78 100644 --- a/ci/tiers.py +++ b/ci/tiers.py @@ -61,6 +61,9 @@ # the short mutation controls — the ones that plant a defect and read a red, without # starting a container or building a venv "test_mutations", "test_no_bypass", "test_consumer_paths", + # the one door through that boundary: a fresh install, with no state yet, routed to + # the Planner and the migrate gate — and every other stateless tree still refused + "test_fresh_install", # the tier check itself: a guard that only the release tier ran would # let a PR add an unassigned module and find out days later "test_check_tiers", diff --git a/tests/test_fresh_install.py b/tests/test_fresh_install.py new file mode 100644 index 0000000..343790c --- /dev/null +++ b/tests/test_fresh_install.py @@ -0,0 +1,289 @@ +"""A fresh install is planned through the orchestrator — and no other stateless tree is. + +Through 0.9.5 a new audit could not reach its first session. `status` and `next` read +`audit/state.json` and refused without it; `install.sh` writes none by design (README +§5.2); and `xcheck migrate` refuses the unwritten plan the installer leaves, because +writing that plan is the Planner's job. The Planner launcher is a wrapper over +`xcheck next`, so it refused on every new project, and the only way to plan was an +`uncontained-direct` session started by hand — the one route with none of the controls. + +The door this opens is narrow on purpose. A tree with no state document is ALSO what a +Markdown audit from before 0.8 looks like, and `tests/test_no_bypass.py` holds that such +a tree is refused rather than read. So the routes below are paired with refusals: the +same tree minus ONE of the facts that make it fresh, and the control is the fresh tree. + +Nothing here needs a network or a paid call. The installer is the real `install.sh`, +and the dispatch scenarios run `tests/fixtures/fake-agent/agent` as a real child under +the `worktree` profile. +""" + +import json +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from tests.harness import REPO, Fixture, audit_md, finding, ledger, xcheck_submodule + +PLANNER_CHARTER = xcheck_submodule("util").PLANNER_CHARTER +AGENT = REPO / "tests" / "fixtures" / "fake-agent" / "agent" +NO_STATE = "no state file" + + +class FreshInstall(Fixture): + """A project exactly as `install.sh` leaves it, plus an operator profile outside it. + + The installer is run, not imitated: what makes a tree "fresh" is what the real + installer writes, and a hand-built copy would test the copy. `git=True` makes the + project a repository FIRST, so `audit/` is untracked the way it is in a project that + has just been installed into.""" + + def __init__(self, conf=None, git=False): + self.root = Path(tempfile.mkdtemp(prefix="xcheck-fresh-")) + self.profile_dir = None + self.audit = self.root / "audit" + if git: + self.git_init() + p = subprocess.run(["sh", str(REPO / "install.sh"), str(self.root)], + capture_output=True, text=True, timeout=120) + if p.returncode != 0: + raise AssertionError(f"install.sh failed: {p.stdout}\n{p.stderr}") + self.write_operator_profile() + if conf: + self.configure(conf) + + @property + def state_path(self): + return self.audit / "state.json" + + +class AFreshInstallIsPlannedThroughTheOrchestrator(unittest.TestCase): + + def setUp(self): + self.fx = FreshInstall() + self.addCleanup(self.fx.cleanup) + + def test_status_says_the_planner_is_next(self): + code, out = self.fx.run("status") + print(f"\nstatus on a fresh install:\n{out.strip()[:400]}") + self.assertEqual(code, 0, out) + self.assertIn("state: none yet", out) + self.assertIn("does not convert yet", out) + self.assertIn("decision: run-planner", out) + self.assertFalse(self.fx.state_path.exists(), "status wrote a state document") + + def test_next_dispatches_the_planner_charter(self): + code, out = self.fx.decision() + self.assertEqual(code, 0, out) + self.assertIn("=== Planner session ===", out) + self.assertIn(f"charter: {PLANNER_CHARTER}", out) + self.assertFalse(self.fx.state_path.exists(), "a dry run wrote a state document") + + def test_status_json_reports_the_empty_state(self): + """Revision 0 is the one `write_state` never writes, so a machine reader can tell + "no state yet" from every state that exists.""" + code, out = self.fx.run("status", "--json") + self.assertEqual(code, 0, out) + payload = json.loads(out) + self.assertEqual(payload["decision"]["kind"], "run-planner") + self.assertEqual(payload["state_revision"], 0) + self.assertEqual(payload["passes"], {"done": 0, "queued": []}) + + def test_an_unfinished_plan_goes_back_to_the_planner(self): + """F-0077: an under-planned audit routes to the Planner. A plan whose catalogs + are written and whose queue is not is a Planner that stopped early.""" + self.fx.write("AUDIT.md", audit_md(queue_body="")) + code, out = self.fx.run("status") + self.assertEqual(code, 0, out) + self.assertIn("decision: run-planner", out) + + +class ACompletePlanStopsAtTheMigrateGate(unittest.TestCase): + + def setUp(self): + self.fx = FreshInstall() + self.addCleanup(self.fx.cleanup) + self.fx.write("AUDIT.md", audit_md()) + + def test_the_gate_names_the_migration_and_writes_nothing(self): + code, out = self.fx.run("status") + self.assertEqual(code, 0, out) + self.assertIn("plan: audit/AUDIT.md converts", out) + self.assertIn("decision: stop-migrate -> ['P-01']", out) + + code, out = self.fx.decision() + print(f"\nnext on a complete plan:\n{out.strip()[-400:]}") + self.assertEqual(code, 0, f"a human gate is a normal stop: {out}") + self.assertIn("gate: plan ready", out) + self.assertIn("xcheck migrate", out) + self.assertIn("P-01", out) + self.assertNotIn("Planner session", out, "a finished plan was planned again") + self.assertFalse(self.fx.state_path.exists(), + "the orchestrator loaded the plan itself; migration is the " + "operator's act (XCHECK.md §2)") + + def test_after_the_migration_the_state_decides(self): + code, out = self.fx.run("migrate") + self.assertEqual(code, 0, out) + code, out = self.fx.run("status") + self.assertEqual(code, 0, out) + self.assertIn("state: schema 1, revision 1", out) + self.assertIn("decision: run-auditor -> P-01", out) + + +class EveryOtherStatelessTreeIsStillRefused(unittest.TestCase): + """One fact removed per test. Each refusal must hold on BOTH surfaces — `status` + and `next` — and must not reach a Planner session.""" + + def fresh(self, **kw): + fx = FreshInstall(**kw) + self.addCleanup(fx.cleanup) + return fx + + def assertRefused(self, fx, *why): + for args in (("status",), ("next", "--dry-run")): + code, out = fx.run(*args) + print(f"\n{args[0]}: {out.strip()[-300:]}") + self.assertNotEqual(code, 0, f"{args[0]} accepted a tree that is not fresh") + self.assertIn(NO_STATE, out) + self.assertNotIn("Planner session", out) + self.assertNotIn("stop-migrate", out) + for w in why: + self.assertIn(w, out) + self.assertFalse(fx.state_path.exists()) + + def test_CONTROL_the_fresh_tree_itself_is_planned(self): + code, out = self.fresh().decision() + self.assertEqual(code, 0, out) + self.assertIn("=== Planner session ===", out) + + def test_a_tree_with_no_installer_record(self): + fx = self.fresh() + (fx.audit / ".provenance.json").unlink() + self.assertRefused(fx, "install.sh did not create this tree") + + def test_an_unreadable_installer_record(self): + fx = self.fresh() + fx.write(".provenance.json", "not json\n") + self.assertRefused(fx, "install.sh did not create this tree") + + def test_a_complete_plan_without_an_installer_record_is_not_offered_the_gate(self): + """The case `tests/test_consumer_paths.py` is about: a whole Markdown plan, read + as nothing. Offering it the migrate gate would be reading it as a plan.""" + fx = self.fresh() + fx.write("AUDIT.md", audit_md()) + (fx.audit / ".provenance.json").unlink() + self.assertRefused(fx, "xcheck migrate") + + def test_a_tree_holding_a_finding(self): + fx = self.fresh() + fx.write("findings/F-0001-a.md", finding()) + self.assertRefused(fx, "already holds audit records", "findings/") + + def test_a_tree_holding_a_ledger_row(self): + fx = self.fresh() + fx.write("LEDGER.md", ledger([("F-0001", "a finding", "major", "reported")])) + self.assertRefused(fx, "LEDGER.md row") + + def test_a_tree_whose_state_document_was_lost(self): + """A real transition, then the document deleted. The stream remembers what the + tree forgot, and re-planning over it would orphan that history.""" + fx = self.fresh() + fx.write("AUDIT.md", audit_md()) + for args in (("migrate",), ("set-limit", "reopen_limit", "3")): + code, out = fx.run(*args) + self.assertEqual(code, 0, out) + fx.state_path.unlink() + self.assertRefused(fx, "state transition", "Restore audit/state.json") + + def test_the_construal_gate_refuses_rather_than_waving_the_planner_through(self): + """§4 rule 9 admits a construal into `state.json`, which does not exist yet. The + gate cannot be honoured, so it refuses; silently skipping an armed gate would be + the fail-open answer.""" + fx = self.fresh(conf="construal_gate=on\n") + self.assertRefused(fx, "construal_gate") + + +class APlannerSessionRunsBeforeThereIsState(unittest.TestCase): + """A real child through `xcheck next`. The stand-in agent writes the plan inside its + sandbox, and the plan has to come back through the courier with no state document + anywhere on the path.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="xcheck-fresh-agent-")) + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.record = self.tmp / "record.json" + self.spec = self.tmp / "spec.json" + + def project(self, conf="", **behaviour): + self.spec.write_text(json.dumps(dict({"record": str(self.record)}, **behaviour)), + encoding="utf-8") + fx = FreshInstall(git=True, conf=( + f"planner_cmd={AGENT} --spec {self.spec} {{prompt}}\n" + f"sandbox_profile=worktree\nsession_timeout=60\n{conf}")) + self.addCleanup(fx.cleanup) + return fx + + def runs(self): + if not self.record.exists(): + return [] + return json.loads(self.record.read_text(encoding="utf-8")) + + def test_the_plan_lands_and_the_next_decision_is_the_gate(self): + plan = audit_md() + fx = self.project(patch={"path": "audit/AUDIT.md", "text": plan}) + code, out = fx.run_subprocess("next") + print(f"\nnext:\n{out.strip()[-600:]}") + self.assertEqual(code, 0, out) + runs = self.runs() + self.assertEqual(len(runs), 1, "the Planner was not dispatched exactly once") + self.assertIn(PLANNER_CHARTER, " ".join(runs[0]["argv"])) + self.assertEqual((fx.audit / "AUDIT.md").read_text(encoding="utf-8"), plan, + "the plan the session wrote did not reach the project") + self.assertFalse(fx.state_path.exists(), "a session created the state document") + self.assertIn("its work is audit/AUDIT.md", out) + self.assertNotIn("Traceback", out) + + events = [json.loads(ln)["event"] for ln in + (fx.audit / "events.jsonl").read_text(encoding="utf-8").splitlines()] + for kind in ("session_dispatched", "session_finished", "session_receipt"): + self.assertIn(kind, events, f"the session left no `{kind}` in the stream") + + code, out = fx.run("status") + self.assertEqual(code, 0, out) + self.assertIn("decision: stop-migrate -> ['P-01']", out) + + def test_a_failed_session_stops_without_a_traceback(self): + """The retry gate read the last session out of `state.sessions[]`, so a failed + Planner with no state document died on a `StateError` instead of stopping.""" + fx = self.project(patch={"path": "audit/AUDIT.md", "text": "half a plan\n"}, + exit=1) + template = (fx.audit / "AUDIT.md").read_text(encoding="utf-8") + code, out = fx.run_subprocess("next") + print(f"\nnext (failing agent):\n{out.strip()[-500:]}") + self.assertEqual(code, 1, out) + self.assertIn("stop: Planner session returned exit", out) + self.assertNotIn("Traceback", out) + self.assertEqual((fx.audit / "AUDIT.md").read_text(encoding="utf-8"), template, + "a crashed session's half-plan was applied to the project") + code, out = fx.run("status") + self.assertEqual(code, 0, out) + self.assertIn("quarantine: 1 pending bundle(s)", out) + self.assertIn("decision: run-planner", out) + + def test_the_charter_repeat_ceiling_holds_before_migration(self): + """Budgets are evaluated on every dispatch decision, this one included: the + ceiling counts dispatches in the event stream, which exists before state.""" + fx = self.project(conf="budgets=on\ncharter_repeat_limit=1\n", exit=1) + code, out = fx.run_subprocess("next") + self.assertEqual(code, 1, out) + code, out = fx.decision() + print(f"\nnext after one barren Planner:\n{out.strip()[-300:]}") + self.assertEqual(code, 0, out) + self.assertIn("stop-charter-repeats", out) + self.assertEqual(len(self.runs()), 1, "the ceiling let a second Planner run") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_legacy_selftest.py b/tests/test_legacy_selftest.py index 6f82aa9..dc82945 100644 --- a/tests/test_legacy_selftest.py +++ b/tests/test_legacy_selftest.py @@ -42,7 +42,10 @@ # 168 -> 169 in phase 8 (backpressure): one more `report_gate` row. `stop-critical` is a # new human gate, and the F-0116 table exists so a new gate cannot ship with an empty or # duplicated message — a stop that reads like another stop teaches the operator nothing. -EXPECTED_CHECKS = 169 +# 169 -> 170 for the fresh-install route: one more `report_gate` row. `stop-migrate` is +# the gate a new audit reaches once its Planner has written a complete plan, for the same +# reason `stop-critical` got one. +EXPECTED_CHECKS = 170 class LegacySelftest(unittest.TestCase): diff --git a/xcheck/cli.py b/xcheck/cli.py index 7643685..70b874b 100644 --- a/xcheck/cli.py +++ b/xcheck/cli.py @@ -82,7 +82,7 @@ json_output_issues, load_conf ) from xcheck import __version__, budget, envelope, ledger, policy, scope -from xcheck.state import StateError, load_state +from xcheck.state import STATE_FILENAME, StateError, empty_state, load_state from xcheck.views import refuse_on_drift, verify_views, write_views from xcheck.write import VERBS as WRITE_VERBS from xcheck.validate import ( @@ -93,7 +93,8 @@ material_effect_probe, material_snapshot, orchestrator_source_digest, quarantine_bundles, resolve_profile, retry_backoff, retry_decision, run_session ) -from xcheck.decision import metrics_report, role_and_charter, state_and_decision +from xcheck.decision import (metrics_report, pre_state_decision, role_and_charter, + state_and_decision) from xcheck import parallel def _emit_json(payload, schema, what): @@ -143,7 +144,15 @@ def status_payload(project, state, unchecked, checked, kind, detail, lock): def cmd_status(project: Path, conf, as_json=False): - state, unchecked, checked, kind, detail = state_and_decision(project, conf) + # A new audit whose plan is not migrated yet has no state document, and is reported + # as the EMPTY state it is: revision 0, which `write_state` never writes, so a + # `--json` consumer can tell it from any state that exists. + pre = pre_state_decision(project, conf) + if pre is None: + state, unchecked, checked, kind, detail = state_and_decision(project, conf) + else: + kind, detail, report = pre + state, unchecked, checked = empty_state(f"xcheck {__version__}"), [], [] records = list(state.findings) + list(state.class_findings) counts = {} for r in records: @@ -154,6 +163,22 @@ def cmd_status(project: Path, conf, as_json=False): Lock(project / "audit", "?").read()), STATUS_SCHEMA, "status") print(f"project: {project} (xcheck {__version__})") + if pre is not None: + print(f"state: none yet — a new audit has no audit/{STATE_FILENAME} until " + f"`xcheck migrate` loads its plan") + if report.defects: + print(f"plan: audit/AUDIT.md does not convert yet — {len(report.defects)} " + f"defect(s); `xcheck --dry-run migrate` lists them") + elif kind == "stop-migrate": + c = report.counts + print(f"plan: audit/AUDIT.md converts — {c.get('norms', 0)} norm(s), " + f"{c.get('dimensions', 0)} dimension(s), {c.get('units', 0)} unit(s), " + f"{c.get('queue', 0)} queued pass(es)") + else: + print("plan: audit/AUDIT.md converts, but is not a complete plan — the " + "Planner stops at norms, dimensions, a unit map and a non-empty pass " + "queue (XCHECK.md §3)") + return _status_tail(project, conf, state, kind, detail) print(f"state: schema {state.schema_version}, revision {state.state_revision}" + (f", head {state.head_before[:7]}" if state.head_before else "")) print(f"passes: {len(checked)} done, {len(unchecked)} queued {unchecked or ''}") @@ -197,6 +222,14 @@ def cmd_status(project: Path, conf, as_json=False): print(line) for line in budget.derived_report(): print(f" derived: {line}") + _status_tail(project, conf, state, kind, detail) + + +def _status_tail(project, conf, state, kind, detail): + """The lines `status` prints whether or not a state document exists yet: pending + quarantine, the lock, the sandbox and the decision. A Planner session that failed + before the plan was migrated is quarantined like any other, and is just as much + waiting on a human.""" bundles = quarantine_bundles(project) if bundles: # An alarm, not a status line: each of these is a session whose work is sitting @@ -342,6 +375,10 @@ def _dispatch_with_retry(project, conf, role, charter, dry_run, lock, ckey, rc = run_session(project, conf, role, charter, dry_run, lock=lock, construal=ckey, construal_prompt=construal_prompt, attempt=attempt) + # Only a new audit's Planner is dispatched with no state document + # (`pre_state_decision`). Its session went to the event stream alone, and the + # plan it writes reaches state through `xcheck migrate`, never through a verb. + before_migrate = not os.path.lexists(Path(project) / "audit" / STATE_FILENAME) # The session's own account of what it did, recorded beside the session that # made it. Emitted HERE rather than inside `run_session` because this call is # inside the writing lock that `cmd_next` holds, and a state write outside that @@ -353,7 +390,14 @@ def _dispatch_with_retry(project, conf, role, charter, dry_run, lock, ckey, # operator the difference between "the provider fell over" and "the session # ran fine and moved nothing", which is what 136 of the 151 Ouroboros-4 # sessions did while every one of them printed `ok`. - if rc.protocol == envelope.PROTOCOL_NO_PROGRESS: + if rc.protocol == envelope.PROTOCOL_NO_PROGRESS and before_migrate: + # Not "the charter is exactly where it was": a plan was the work, and + # whether it is ready is a fact `status` reads off AUDIT.md. + print(f"protocol: this {role} session ended {rc.outcome} with no state " + f"document to record in — its work is audit/AUDIT.md, which " + f"becomes state only through `xcheck migrate`. `xcheck status` " + f"says whether the plan is ready.") + elif rc.protocol == envelope.PROTOCOL_NO_PROGRESS: print(f"protocol: this {role} session ended {rc.outcome} without recording " f"a canonical transition — no finding, coverage, verdict or typed " f"refusal reached audit/state.json, so the charter is exactly where " @@ -375,7 +419,13 @@ def _dispatch_with_retry(project, conf, role, charter, dry_run, lock, ckey, if k != "session_id"}) if rc == 0 or dry_run: return rc - last = (load_state(Path(project) / "audit").sessions or ({},))[-1] + if before_migrate: + # No `sessions[]` to read the last session from; the result in hand is the + # same account `envelope.store` sent to the event stream. + last = {"session_id": getattr(rc, "session_id", None), + "outcome": getattr(rc, "outcome", None)} + else: + last = (load_state(Path(project) / "audit").sessions or ({},))[-1] outcome = last.get("outcome") or "crash" # TWO answers, AND-ed, both failing closed. `probed` is what the PROJECT says, # over tracked changes, untracked files, audit artifacts and git HEAD; the @@ -449,7 +499,13 @@ def cmd_next(project: Path, conf, dry_run, assume_yes=False): # This is the clearest measure of what moving the control plane bought: # not a fixed sync, no sync, and no mixed-ownership hazard from a write # the orchestrator only made to reconcile two representations. - _state, unchecked, checked, kind, detail = state_and_decision(project, conf) + # A new audit's Planner runs before there is a state document to decide from + # (`pre_state_decision`); every other project decides from its state. + pre = pre_state_decision(project, conf) + if pre is None: + _state, unchecked, checked, kind, detail = state_and_decision(project, conf) + else: + (kind, detail, _report), _state, unchecked, checked = pre, None, [], [] if kind.startswith("stop-") or kind.startswith("done"): # Phase 9: a gate is the most consequential thing the cycle does — it hands # control to a human — so it is the one outcome a machine reader most needs @@ -755,6 +811,18 @@ def report_gate(kind, detail): # one defect as four unrelated findings is the work this detector exists to save. for line in _class_lines(): print(line) + elif kind == "stop-migrate": + # Once per audit: a new audit has no state until its plan is loaded, and loading + # it is the operator's act, never the orchestrator's (XCHECK.md §2). + passes = list(detail) if isinstance(detail, (list, tuple)) else [] + print(f"gate: plan ready — audit/AUDIT.md converts into a complete plan with " + f"{len(passes)} queued pass(es){': ' + ', '.join(passes) if passes else ''}. " + f"A new audit has no machine state until the operator loads its plan, and " + f"that is never automatic. Review the pass queue in audit/AUDIT.md (dropping " + f"or reordering passes now is cheap and needs no gate), then run `xcheck " + f"--dry-run migrate` and `xcheck migrate`. From then on the queue changes " + f"only through `xcheck queue-pass`, `xcheck amend-pass` and `xcheck " + f"cancel-pass`.") elif kind == "stop-needs-human": print(f"gate: ⚠ needs-human on {detail} — the human decides (see the finding file)") elif kind == "stop-norm-ratification": diff --git a/xcheck/decision.py b/xcheck/decision.py index 03c4ef2..31eff9d 100644 --- a/xcheck/decision.py +++ b/xcheck/decision.py @@ -14,7 +14,8 @@ TELEMETRY_SOURCES, TERMINAL, _broken_superseded, conf_flag, conf_number, construal_key ) -from xcheck.state import load_state, next_owner +from xcheck.state import (STATE_FILENAME, StateError, empty_state, load_state, next_owner, + no_state_message) # Imported at module level, not inside the gate: `budget` imports `decision` # only INSIDE its own functions (for `metrics_report`), so this direction of the # edge is safe and the flag name is readable where the gate reads it. @@ -408,6 +409,84 @@ def plan_complete(state): return bool(cat and cat.norms and cat.dimensions and cat.units and state.queue) +def pre_state_decision(project: Path, conf): + """`(kind, detail, report)` for a project with no `audit/state.json` yet, None for + one that has it — `state_and_decision` decides those. + + A new audit has no machine state until its plan is migrated: the Planner runs first + (XCHECK.md §3), and `xcheck migrate` is the operator act that loads its AUDIT.md + (§2). Through 0.9.5 nothing could DISPATCH that first session. `state_and_decision` + refuses a tree with no state document and `install.sh` writes none by design, so the + Planner launcher refused on every fresh install and the only way to plan was an + `uncontained-direct` session started by hand. + + The tree is read only when `migrate.not_fresh_install` proves it is a fresh install; + anything else keeps the refusal, with the reason appended. On a fresh tree: + + - `stop-migrate` when AUDIT.md converts into a COMPLETE plan (`plan_complete`): the + Planner's stop condition is met and loading the plan is the operator's act, never + the orchestrator's (§2). `detail` is the queue the operator is asked to review. + - `run-planner` otherwise: the plan is unwritten, or unfinished (F-0077 — an + under-planned audit routes back to the Planner). + + `report` is the converter's account of AUDIT.md, which `status` prints. Nothing is + written: the conversion is `migrate.convert`, which writes nowhere. + """ + audit = Path(project) / "audit" + path = audit / STATE_FILENAME + if os.path.lexists(path): + return None + # Imported here, never at module scope: the converter reads Markdown, and loading it + # is reserved for the trees it is about (tests/test_no_bypass.py). + from xcheck import migrate + problem = migrate.not_fresh_install(project) + if problem: + raise StateError(f"{no_state_message(path)}. The Planner is not dispatched into " + f"this tree: {problem}.") + state, report = migrate.convert(audit) + if state is not None and plan_complete(state): + return "stop-migrate", [q.id for q in state.queue], report + # §4 rule 9 cannot be honoured here, so it refuses rather than waving the charter + # through. A construal is admitted into `state.json`, which does not exist yet, and + # the construal FILE the gate would ask for makes the tree stop being fresh. + if conf_flag(conf, "construal_gate"): + raise StateError( + f"{no_state_message(path)}. construal_gate is on, and a construal is admitted " + f"into the state document, which a new audit does not have until its plan is " + f"migrated, so the Planner's charter cannot pass the gate. Plan with " + f"`construal_gate=off` in audit/orchestrator.conf, run `xcheck migrate`, then " + f"turn the gate back on.") + kind, detail = "run-planner", None + fired = _budget_stop(project, conf, empty_state("xcheck (no state yet)"), kind, detail) + if fired: + return fired[0], fired[1], report + return kind, detail, report + + +def _budget_stop(project, conf, state, kind, detail): + """The budget ceiling that refuses this dispatch, as `(kind, detail)`, or None. + + One home for both decisions that can dispatch: `state_and_decision`, and + `pre_state_decision` for a Planner that runs before there is state. The hard ceilings + (a dispatch cap, a charter repeated too often) count dispatches in the event stream, + so they hold before migration too.""" + if not kind.startswith("run-") or not conf_flag(conf, budget.FLAG): + return None + from xcheck.envelope import read_events + _role, _charter = role_and_charter(kind, detail) + fired = budget.gates(state, conf, read_events(project), charter=_charter, + cmd=conf.get(f"{(_role or '').lower()}_cmd")) + # PHASE 12: `no-verdict` is a THIRD outcome and is not a stop. Below the telemetry + # coverage floor the statistical gates cannot honestly extrapolate, so they say so + # and the dispatch proceeds — refusing to judge is not the same as judging clear, + # and it is emphatically not grounds to block work on a machine whose only fault + # is that nobody configured a telemetry adapter. `xcheck status` prints the notice + # so the absence of a verdict is visible rather than inferred from silence. + if fired is not None and fired[0] != budget.NO_VERDICT: + return fired + return None + + def state_and_decision(project: Path, conf): """Read the ONE canonical state document, then decide. Returns `(state, unchecked, checked, kind, detail)`. @@ -542,19 +621,9 @@ def state_and_decision(project: Path, conf): # Evaluated on every decision rather than once per run: a ceiling that is only a # precondition gets walked through exactly once, by the session already in flight # when it was checked. - if kind.startswith("run-") and conf_flag(conf, budget.FLAG): - from xcheck.envelope import read_events - _role, _charter = role_and_charter(kind, detail) - fired = budget.gates(state, conf, read_events(project), charter=_charter, - cmd=conf.get(f"{(_role or '').lower()}_cmd")) - # PHASE 12: `no-verdict` is a THIRD outcome and is not a stop. Below the telemetry - # coverage floor the statistical gates cannot honestly extrapolate, so they say so - # and the dispatch proceeds — refusing to judge is not the same as judging clear, - # and it is emphatically not grounds to block work on a machine whose only fault - # is that nobody configured a telemetry adapter. `xcheck status` prints the notice - # so the absence of a verdict is visible rather than inferred from silence. - if fired is not None and fired[0] != budget.NO_VERDICT: - return state, unchecked, checked, fired[0], fired[1] + fired = _budget_stop(project, conf, state, kind, detail) + if fired: + return state, unchecked, checked, fired[0], fired[1] # A held pass is still OWED. Every other branch of the decision stays exactly as it # was — a Verifier, a Remediator or a human gate outranks this and should run — but # `done` over an embargoed queue would certify a plan that has unfinished passes, diff --git a/xcheck/envelope.py b/xcheck/envelope.py index 45270d5..b18e24e 100644 --- a/xcheck/envelope.py +++ b/xcheck/envelope.py @@ -175,7 +175,8 @@ def executable_version(exe, env=None): def current_revision(project): """The state revision this session was dispatched against, or 0 when there is no - readable state (a bare fixture; `next`/`loop` refuse such a project anyway).""" + readable state — a new audit's Planner, dispatched before its plan is migrated, or a + bare fixture; `next`/`loop` refuse any other project with no state.""" try: return load_state(Path(project) / "audit").state_revision except StateError: @@ -792,11 +793,14 @@ def store(project, rec, event=None, live=()): try: state = load_state(audit) except StateError as e: - # A project with no readable state cannot be dispatched by `next`/`loop` at - # all — this path is reachable only by calling the runner directly (a test, a - # manual harness). Say so out loud rather than aborting a session that is - # already under way for a condition its own dispatcher already refuses. - print(f"note: envelope not stored in state.json ({e}); audit/{EVENTS_FILENAME} " + # `next`/`loop` reach this for one dispatch only: a new audit's Planner, which + # runs before its plan is migrated (`decision.pre_state_decision`). Any other + # project with no readable state is refused before dispatch, so for those this + # is a test or a manual harness calling the runner directly. Say so out loud + # rather than aborting a session that is already under way. + why = (e if os.path.lexists(audit / "state.json") + else "there is none until the plan is migrated") + print(f"note: envelope not stored in state.json ({why}); audit/{EVENTS_FILENAME} " f"still carries this session") if event: emit(project, event, rec.get("session_id"), **_event_payload(rec)) diff --git a/xcheck/migrate.py b/xcheck/migrate.py index 02bc84c..efee817 100644 --- a/xcheck/migrate.py +++ b/xcheck/migrate.py @@ -30,11 +30,13 @@ from xcheck.md_prose import (FRONTMATTER_RE, LIMIT_KEYS, _DIMENSIONS_RE, _parse_frontmatter, _section_table) +from xcheck.ledger import EVENTS_FILENAME, read_stream from xcheck.migrate_readers import (_NORMS_RE, _QUEUE_ENTRY_RE, _QUEUE_RE, _UNITMAP_RE, _header_col, _section_bodies, load_construal_files, load_finding_files, parse_ledger, parse_limits) from xcheck.state import (SCHEMA_VERSION, STATE_FILENAME, StateError, _build, _validate, git_head, next_owner, write_state) +from xcheck.upgrade import PROVENANCE, read_provenance from xcheck.util import _read_audit_text from xcheck.views import write_views @@ -475,6 +477,74 @@ def read_legacy(audit_dir: Path, generated_by="xcheck migrate"): return doc, report +def convert(audit_dir, generated_by="xcheck migrate"): + """`(state, report)`: the State `migrate` would write, validated and built, and + written nowhere. `state` is None whenever the report carries a defect. + + One home for "does this tree convert", because two callers ask it: `migrate`, which + then writes, and `decision.pre_state_decision`, which only needs the answer. Two + copies of the question could disagree, and `status` would then call a plan ready + that `migrate` refuses.""" + audit_dir = Path(audit_dir) + doc, report = read_legacy(audit_dir, generated_by) + if doc is None or report.defects: + return None, report + try: + _validate(doc, str(audit_dir / STATE_FILENAME)) + return _build(doc), report + except StateError as e: + report.defect(f"the converted document fails the schema: {e}") + return None, report + + +# What already being an audit RECORD looks like on disk, as opposed to a plan: every +# directory `read_legacy` takes records from. A fresh install has all four, empty. +RECORD_DIRS = ("findings", "passes", "plans", "construals") + + +def not_fresh_install(project): + """Why `project` is NOT a fresh install, or None when it is one. + + Asked only when there is no `audit/state.json`. Such a tree is either a new audit + whose plan is not migrated yet, or an old Markdown audit — and reading the second as + the first would let Markdown back into the control plane, which is the bypass the + state document exists to close (tests/test_no_bypass.py). So "fresh" is proved, not + assumed, and each of its three facts fails closed: + + - `install.sh` left its provenance record: the tree was installed by a xcheck that + keeps state in `state.json`, not carried over from one that kept it in Markdown; + - nothing has been recorded yet: no finding, pass report, plan or construal file, + and no LEDGER.md row; + - the event stream holds no `state_transition`. Only a write verb emits one, after + a durable state write, so one in the stream means a state document existed and + its absence now is a loss to recover, not a beginning. + """ + audit = Path(project) / "audit" + prov = read_provenance(audit) + if not isinstance(prov, dict) or not prov.get("xcheck_version"): + return (f"there is no readable audit/{PROVENANCE}, so install.sh did not create " + f"this tree, and a tree with no state and no installer record is read as " + f"an old audit to convert, never as a new one") + records = [] + for sub in RECORD_DIRS: + n = sum(1 for _ in (audit / sub).glob("*.md")) + if n: + records.append(f"{n} file(s) in {sub}/") + rows = parse_ledger(audit) + if rows: + records.append(f"{len(rows)} LEDGER.md row(s)") + if records: + return (f"it already holds audit records ({', '.join(records)}), so it is an " + f"audit to convert, not a new one") + moved = sum(1 for e in read_stream(project) if e.get("event") == "state_transition") + if moved: + return (f"audit/{EVENTS_FILENAME} records {moved} state transition(s), so a " + f"state document existed here and is gone. Restore audit/{STATE_FILENAME} " + f"from git or a backup: re-planning or re-migrating would start the " + f"audit over and orphan that history") + return None + + def _ledger_next_notes(audit_dir, state, report): """Report every legacy LEDGER row whose `next` disagreed with the owner the status implies. The column is not carried across: `next` was a second place @@ -503,17 +573,13 @@ def migrate(project: Path, dry_run=False): f"overwrite of live state; move the existing document aside if you truly " f"mean to re-convert.") - doc, report = read_legacy(audit_dir) - if doc is not None and not report.defects: + state, report = convert(audit_dir) + if state is not None: try: - _validate(doc, str(target)) - state = _build(doc) _ledger_next_notes(audit_dir, state, report) except StateError as e: report.defect(f"the converted document fails the schema: {e}") state = None - else: - state = None print(report.render(audit_dir, dry_run)) diff --git a/xcheck/runner.py b/xcheck/runner.py index e505ee2..0ecb3fe 100644 --- a/xcheck/runner.py +++ b/xcheck/runner.py @@ -2624,7 +2624,9 @@ def run_session(project: Path, conf, role, charter, dry_run=False, lock=None, co # still there and the prompt still names it. The envelope records # `unknown`, the difference between "briefed with this" and "we do not # know". - print(f" ! context capsule not generated ({e}); the session reads the " + why = (e if os.path.lexists(project / "audit" / "state.json") + else "no state.json until the plan is migrated") + print(f" ! context capsule not generated ({why}); the session reads the " f"full corpus instead") child_prompt = prompt + (capsule_line(session_id) if capsule_digest else "") \ + orchestration_context(orch, session_id) diff --git a/xcheck/selftest.py b/xcheck/selftest.py index c10c401..8926217 100644 --- a/xcheck/selftest.py +++ b/xcheck/selftest.py @@ -977,6 +977,10 @@ def _clk(): # emptied or name the wrong owner (e.g. disputed) with all other checks green. _rg_cases = [ ("stop-triage", ["F-0001"], ["triage needed", "xcheck-triage"]), + # A new audit's one hand-over before there is state: it must name the passes the + # operator is asked to review AND the command that loads them, or it is a stop + # with no move. + ("stop-migrate", ["P-01", "P-02"], ["plan ready", "P-01, P-02", "xcheck migrate"]), ("stop-needs-human", "F-0001", ["needs-human on F-0001", "human decides"]), ("stop-refusal", ("F-0001", "material-missing"), ["refusal on F-0001", "material-missing", "STAYS IN FORCE"]), ("stop-construal", ("Remediator", "finding F-0001", "audit/construals/k.md", "it is `proposed`"), diff --git a/xcheck/state.py b/xcheck/state.py index 9502a36..c65f83b 100644 --- a/xcheck/state.py +++ b/xcheck/state.py @@ -1113,6 +1113,19 @@ def _build(doc): # the public boundary # --------------------------------------------------------------------------- +def no_state_message(path): + """The one wording for a project that has no state document. + + 0.9.5 ended "or `install.sh` to start a new one", and `install.sh` writes no state by + design (README §5.2): an operator who followed the advice reinstalled and was refused + in the same words. State comes from `xcheck migrate` in both cases — a new audit's + plan once the Planner has written it, or an old Markdown audit being converted — so + that is the only command the message names.""" + return (f"{path}: no state file — `xcheck migrate` builds one from audit/AUDIT.md, " + f"once: for a new audit after the Planner has written the plan, for a " + f"Markdown audit from before 0.8 to convert it") + + def load_state(audit_dir): """`bytes -> parse -> schema -> cross-record -> immutable State`, or raise. @@ -1125,8 +1138,7 @@ def load_state(audit_dir): try: raw = path.read_bytes() except FileNotFoundError: - raise StateError(f"{path}: no state file — run `xcheck migrate` to convert an " - f"existing Markdown audit, or `install.sh` to start a new one") + raise StateError(no_state_message(path)) except OSError as e: raise StateError(f"{path}: cannot read ({e.strerror})") try: @@ -1377,7 +1389,11 @@ def write_state(audit_dir, state, bump=True, head_before=None, txn=None): def empty_state(generated_by, head_before=None): - """A valid, empty State — what `install.sh` and the migration start from.""" + """A valid, empty State — what a new audit holds before its plan is migrated. + + Never written by `install.sh`, which leaves state alone (README §5.2). The pre-state + decision (`decision.pre_state_decision`) prices a Planner dispatch against it, since + that dispatch happens before there is a document to read.""" return State(schema_version=SCHEMA_VERSION, state_revision=0, generated_by=generated_by, head_before=head_before, catalogs=Catalogs()) From 3748c6aa548382644b8da5699185a42ee687e73d Mon Sep 17 00:00:00 2001 From: vbanas Date: Fri, 16 Oct 2026 16:08:52 +0300 Subject: [PATCH 2/4] fix(onboarding): pick the AUDIT.md template, name the profile up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new operator went from `install.sh` to `/xcheck-plan` on a real codebase and met four problems in a row: - `install.sh` always installed the text-corpus AUDIT.md. A codebase needed a hand `cp` that only a note under README §5.2 mentioned, so the plan started from `facts`, `style` and `currency`. - Nothing on the install path said an operator profile is required. README §5 and §6 Step 1, bootstrap.md and the installer's output were silent; only the §8 reference explained it. - `xcheck status` printed `decision: run-planner` and nothing about the profile, so `next` was the first command to say it would not run. The launcher skills already tell operators to read the profile there. - The refusal's hint, "`xcheck --policy PATH` with a path that does not exist prints the template", names a command the grammar rejects: `--policy is not valid for no command`. The template is printed only after a command is parsed. The same message spelled the XDG location `$(XDG_CONFIG_HOME)`, which a shell reads as command substitution. What changes: - `install.sh --type code|text` picks the template, before or after the target. No flag still means `text`, so existing callers get what they got, and the output now says which template that was. An unknown type is refused before anything is written. An existing AUDIT.md is never replaced; if it is still an unfilled template, the installer prints the `cp` that switches it. The last lines are the next steps, the profile first. - `operator-profile.sh` writes a profile from docs/operator.conf.example, which is already held byte-identical to the template. `--trust` is required and has no default. `untrusted` also sets `sandbox_profile=container`, without which every writing dispatch refuses. The path is named by the operator or by $XDG_CONFIG_HOME, never derived from the home directory. The file is validated by `policy.load` (with `--project`, the location gate too) and then hard-linked into place, so it never overwrites. It prints the line that points xcheck at the file, in fish syntax under fish. - `status` prints a `policy:` line: the resolved path and digest in the words `require_policy` uses at dispatch, or "none found" with the helper named. - The refusal names the helper and `xcheck --policy PATH status`, and spells `$XDG_CONFIG_HOME`. - README: a new §5.3 for the profile (5.3-5.6 become 5.4-5.7, and their three references move with them). §5.2 documents `--type`, §6 Step 1 points at §5.3, §8 names the helper, and §15 has a row for the refusal. bootstrap.md: the installer command, a new §1a for the profile; the old §1a is now §1b. Three existing checks move with this: - selftest F-0127 inverted the AUDIT.md guard by matching the old one-line form, so the mutation stopped applying and the check went red. It now inverts the new `if [ ! -f ... ]` guard; 169/169. - `test_parallel_retry` compares two `status` outputs whose profiles differ by design (the flag absent, then written as `off`). Only the 16-digit digest on the `policy:` line is masked. - The containment-claim pin goes 209 -> 211 with its ledger entry: both new claims are in README §5.3, and unscoped stays at 0 because the section names `orchestrated` before the first of them. XCHECK.md and the launcher skills are unchanged, so installed trees need no upgrade. This builds on the previous commit, which lets `status` and `next` run on a fresh install that has no state.json yet; the two together are the whole route from install to the first Planner session. `tests/test_operator_onboarding.py` (PR tier) runs the real scripts and CLI: 26 tests, all of which fail on the parent. The refusal test parses the hint and runs it, so a reworded hint is held to the same standard. Full suite on this machine: 1,746 tests, 5 failures and 1 error, the same set as on the parent. Each one is a machine dependency: no `build` module, no `ruff`, and rows A2 and A3 of the SECURITY.md adversarial matrix. Co-Authored-By: Claude Opus 5.5 --- README.md | 47 +++- bootstrap.md | 37 ++- ci/tiers.py | 3 + install.sh | 70 ++++- operator-profile.sh | 185 +++++++++++++ tests/test_operator_onboarding.py | 303 ++++++++++++++++++++++ tests/test_parallel_retry.py | 13 +- tests/test_style_does_not_widen_a_gate.py | 11 +- xcheck/cli.py | 8 + xcheck/policy.py | 11 +- xcheck/selftest.py | 4 +- 11 files changed, 664 insertions(+), 28 deletions(-) create mode 100755 operator-profile.sh create mode 100644 tests/test_operator_onboarding.py diff --git a/README.md b/README.md index e87636b..786329c 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ During triage the agent is the pen, not the decider: it presents findings, recor | `styles/` | `eli5.md`, the single source for how a launcher session words things to a person, and `styles/README.md` explaining it. `ci/render-style.py` copies its marked block into all six `skills/*/SKILL.md`; `--check` compares the six copies byte for byte and exits non-zero on a one-character difference. | | `launchers/` | Per-CLI installer (`install-launchers.sh`). OpenCode commands are generated from `skills/` at install time. | | `bootstrap.md` | From zero to a running cycle: install block, session one-liners, the full cycle step by step. | -| `install.sh` | One-command install of the methodology into a target project. | +| `install.sh` | One-command install of the methodology into a target project; `--type code\|text` picks the `AUDIT.md` template. | +| `operator-profile.sh` | Writes the operator profile, once per machine and outside every audited repository (§5.3). Nothing dispatches without one. | | `.claude-plugin/`, `.codex-plugin/` | Claude Code and Codex plugin manifests over the checked-in `skills/` payload. | ## 5. Installation @@ -119,7 +120,8 @@ cd xcheck ### 5.2 Install the methodology into a target project ```bash -./install.sh /path/to/your-project +./install.sh --type code /path/to/your-project # a codebase +./install.sh --type text /path/to/your-project # a text corpus (the default) ``` This creates `your-project/audit/` containing `XCHECK.md`, an `AUDIT.md` template, `templates/`, and empty `findings/`, `passes/`, `plans/`. Nothing is audited yet — the Planner fills `AUDIT.md` in the first session, after which one operator command — `xcheck migrate` — loads that plan into `audit/state.json`, the canonical record. Until then the project has no state document, and that is expected: on a tree `install.sh` created and nothing has been recorded in, `xcheck status` reports `decision: run-planner` and `xcheck next` dispatches the Planner like any other session. Any other tree without `state.json` is still refused. @@ -128,9 +130,10 @@ There is no `LEDGER.md` to create: from 0.8 it is a **generated mirror** of `sta Notes: -- The default `AUDIT.md` template is text-oriented; for a codebase, copy `templates/AUDIT-code.md` over `audit/AUDIT.md` before planning. +- `--type` picks the template `AUDIT.md` starts from: `code` is `templates/AUDIT-code.md`, `text` is `templates/AUDIT-text.md`, and `text` is what an install without the flag gets. It applies only when `audit/AUDIT.md` does not exist yet. An existing one is never replaced; if it is still an unfilled template of the other type, the installer prints the `cp` that switches it. +- The last lines it prints are the next steps, and the first of them is the operator profile (§5.3). - `install.sh` never touches audit state: `AUDIT.md` and `LEDGER.md` are created once and then left alone, and `state.json`, findings, passes and plans are not its business at all. -- It **does** overwrite `XCHECK.md` and the templates unconditionally — it is an installer, and it assumes those files are still xcheck's. To refresh an *existing* installation, use `xcheck upgrade` (§5.5), which checks that assumption instead of making it. +- It **does** overwrite `XCHECK.md` and the templates unconditionally — it is an installer, and it assumes those files are still xcheck's. To refresh an *existing* installation, use `xcheck upgrade` (§5.6), which checks that assumption instead of making it. - `audit/` can be committed with the project (recommended — remediation commits referencing finding IDs make verification much cheaper) or added to `.gitignore` if the audit must stay out of history. The installer also records what it put there, in two files `xcheck upgrade` later reads as evidence: @@ -142,7 +145,25 @@ The installer also records what it put there, in two files `xcheck upgrade` late If neither `shasum -a 256` nor `sha256sum` is on `PATH`, the install still succeeds and says plainly that it wrote no provenance — a manifest nobody can verify is worse than none, because `upgrade` would trust it. -### 5.3 Install the launchers (optional but recommended) +### 5.3 Create your operator profile (required, once per machine) + +Nothing dispatches without one. The profile governs `orchestrated` sessions, the ones `xcheck next`, `xcheck loop` and the launchers that wrap them (§7) start; each of those refuses before a session starts until a profile resolves, and prints where it looked. `xcheck status` names the profile in force on its `policy:` line, or says there is none. The profile is the operator's own file: which command each role runs, the containment it runs inside, and how far you trust the material. It lives **outside** every audited repository, because a session can write anywhere under `audit/` (§8), so `install.sh` cannot create it for you. + +`operator-profile.sh` writes one from the template: + +```bash +./operator-profile.sh --trust trusted /path/to/operator.conf +export XCHECK_OPERATOR_PROFILE=/path/to/operator.conf # and in your shell's startup file +``` + +- `--trust` is required, and it has no default. Use `trusted` for code you or your organisation wrote and review. Use `untrusted` for anything else; that also sets `sandbox_profile=container`, so docker must work on the machine. `SECURITY.md` says what each boundary does and does not stop. +- You name the path, because xcheck never derives one from your home directory. With `$XDG_CONFIG_HOME` set, you can leave the path out: the profile goes to `$XDG_CONFIG_HOME/xcheck/operator.conf`, where xcheck finds it with no export. +- The role commands come from the template: Codex for the Planner, Auditor and Verifier, and `claude -p` for the Remediator. Edit the file to change them; every key is explained beside it. +- It never overwrites a profile. It validates the file with xcheck's own loader before putting it in place, and with `--project DIR` it refuses a path inside that project. It ends by printing the exact line that points xcheck at the file, in your shell's syntax. + +To do it by hand instead, copy `docs/operator.conf.example`, set `trust_level`, and point at the copy the same way. §8 lists every key. + +### 5.4 Install the launchers (optional but recommended) From the xcheck repo: @@ -159,13 +180,13 @@ Idempotent; re-run after updating the repo. Uninstall = delete the copied files. The checked-in `skills/` directory is also the payload for `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`. `./install-launchers.sh plugin` checks that both manifests and all six skills are present; a marketplace can package the repository directly without a generated payload. Re-running the Codex installer removes the six legacy xcheck files from `~/.codex/prompts/`. -### 5.4 Verify the toolchain +### 5.5 Verify the toolchain ```bash python3 bin/xcheck selftest # expect: PASS (N/N) — all checks green, zero failures ``` -### 5.5 Upgrading an installed copy +### 5.6 Upgrading an installed copy ```bash bin/xcheck --project /path/to/your-project --dry-run upgrade # report only @@ -189,7 +210,7 @@ A refused file keeps its old manifest entry, so it stays refused on the next run If the installed copy is on a **different state schema**, `upgrade` stops without writing anything and names `xcheck migrate`. The schema is what forces a migration, never the release number — that is why the two versions are printed separately. -### 5.6 Compatibility +### 5.7 Compatibility | | Supported | |---|---| @@ -211,6 +232,8 @@ A dry run first is worth 15 minutes: install `audit/` into a throwaway directory ### Step 1 — Plan +Do §5.3 first. Without an operator profile, `/xcheck-plan` and `xcheck next` refuse before any session starts. + Open a session of your planning agent **in the audited project root** and run: - Codex with skills: `$xcheck-plan` @@ -327,7 +350,7 @@ Run them in a session opened **in the audited project root**. A per-invocation c The wrappers are now genuinely thin, and thin in a way the previous version only claimed: preflight → confirm the decision → `xcheck next` → report. They carry no role instructions at all, because they do not play a role. Everything a session is bound by — the lock protocol, the construal gate, the §5 refusal route, session hygiene, the Verifier's independence stance — lives where the dispatched child reads it: `runner.PROMPTS` and `audit/XCHECK.md`. The selftest checks that each of those contracts is present at its new home, so emptying the launchers could not quietly empty the contract. Cross-agent protections are stronger than before, not preserved-as-is: `xcheck next` refuses to dispatch a Verifier over fixes whose `fixed-by` is missing, malformed, or its own session, and the writing lock is held by the orchestrator around the whole transaction rather than by an agent following instructions. -Re-run the launcher installer after any change to the skills (§5.3, `install-launchers.sh` is idempotent), so the installed copies and the generated OpenCode commands never fall behind. +Re-run the launcher installer after any change to the skills (§5.4, `install-launchers.sh` is idempotent), so the installed copies and the generated OpenCode commands never fall behind. **Codex note:** launchers install as skills in `~/.agents/skills/`. Codex can invoke them explicitly with `$xcheck-*` or implicitly from a matching request. For non-interactive use, the orchestrator drives Codex through `codex exec` with the same role charters — see the next section. @@ -350,7 +373,7 @@ xcheck [--project DIR] lint ledger ↔ finding-file consistency ch xcheck [--project DIR] metrics [--json] auditor accuracy, fix durability, distributions xcheck [--project DIR] dashboard [--json] render audit/dashboard.html from the two payloads above xcheck [--project DIR] [--dry-run] migrate one-shot: legacy Markdown tree → state.json -xcheck [--project DIR] [--dry-run] upgrade refresh the installed core + templates (§5.5) +xcheck [--project DIR] [--dry-run] upgrade refresh the installed core + templates (§5.6) xcheck selftest decision-logic + invariant self-checks ``` @@ -383,7 +406,8 @@ path that names nothing is an error — a path typed on the command line was mea nothing resolves, `next` and `loop` **refuse**, printing the order they tried and the file to create. There is deliberately no fallback to defaults: every policy key except the role commands has a safe default, so falling back would run agent sessions under -containment nobody chose. Start from [`docs/operator.conf.example`](docs/operator.conf.example). +containment nobody chose. `operator-profile.sh` writes one from the template (§5.3); by +hand, start from [`docs/operator.conf.example`](docs/operator.conf.example). The profile is read fail-closed — an unknown key, a project key, or a line that is not `KEY=VALUE` is refused by name, because a typo'd `sandbox_profil=container` is a containment setting that silently did nothing. Its sha256 is recorded as `policy_digest` @@ -661,6 +685,7 @@ The methodology and templates are English. **Findings, plans, and reports are wr | Quotes "not found" en masse | The material shifted a lot (e.g. after a class fix) | Normal: validation marks such findings `obsolete` or refreshes locators. Do nothing | | Session exceeded its charter / no coverage report | Protocol violation | The pass does not count — rerun with a smaller charter; if it recurs, reduce the default pass size in AUDIT.md | | Ledger or a frontmatter block diverged from `state.json` | Somebody hand-edited a rendered view (§2) | Nothing repairs it on contact and nothing reads it back — the tool refuses the tree until it is resolved. `xcheck render-views` is the only repair: it overwrites each view from state and lists by name every one that had drifted, so read that list — those are the edits it destroyed — before re-recording the intent through a verb | +| `no operator profile found` from `xcheck next` or a launcher; `policy: none found` in `xcheck status` | This machine has no operator profile, so nothing may dispatch (§5.3) | `./operator-profile.sh --trust trusted\|untrusted /path/to/operator.conf` from the xcheck repository, then the `export` line it prints | | Stale `audit/.lock` | A session died without releasing | `xcheck unlock` diagnoses it; `xcheck unlock --force` removes it (after confirming no writing session is active) | **Can one agent do everything?** Mechanically yes (roles are functions), but you lose the main safeguard: cross-verification by fresh eyes. Minimum worth insisting on: the Verifier is a separate fresh session that never saw the fix. diff --git a/bootstrap.md b/bootstrap.md index f114adb..d616877 100644 --- a/bootstrap.md +++ b/bootstrap.md @@ -6,9 +6,17 @@ running cycle. ## 1. Install -Run once, from the xcheck repo root, per audited project. The block below -copies the text-corpus template by default — swap `AUDIT-text.md` for -`AUDIT-code.md` in the second `cp` line when auditing a codebase. +Run once, from the xcheck repo root, per audited project. The one command +that does it is the installer, which also records provenance for +`xcheck upgrade` (README §5.2): + +```bash +./install.sh --type code /path/to/project # or --type text for a text corpus +``` + +By hand, the block below copies the text-corpus template — swap +`AUDIT-text.md` for `AUDIT-code.md` in the second `cp` line when auditing a +codebase. ```bash # from the xcheck repo root; TARGET = audited project root @@ -30,7 +38,24 @@ audit state. finding's frontmatter block are GENERATED MIRRORS of it (XCHECK.md §2). They appear the moment there is state to render. -## 1a. From the Planner's AUDIT.md to state +## 1a. Operator profile — once per machine + +`xcheck next`, and every launcher that wraps it, dispatches nothing until an +operator profile resolves. That file says which command each role runs and +inside what boundary. It lives outside every audited repository, so the +install above does not create it: + +```bash +# from the xcheck repo root +./operator-profile.sh --trust trusted /path/to/operator.conf # or --trust untrusted +export XCHECK_OPERATOR_PROFILE=/path/to/operator.conf +``` + +README §5.3 explains the two trust levels and the other ways to name the +file; README §8 lists every key. The hand-pasted one-liners in §2 below are +`uncontained-direct` sessions: they never pass through the profile. + +## 1b. From the Planner's AUDIT.md to state `AUDIT.md` is prose a human reads and rules on. Loading it into the canonical record is one explicit operator command, run once after the Planner session: @@ -103,9 +128,9 @@ range, or stop condition and is not a charter; it must not start a session. ## 3. A typical cycle -1. Install (§1) into the target project. +1. Install (§1) into the target project, and write the operator profile if this machine has none (§1a). 2. Planner session produces `AUDIT.md` — norms catalog, dimensions, unit map, pass queue. -3. Human skims the pass queue and trims it: drop or reorder passes; cheap, no gate, just judgment. Then `xcheck migrate` (§1a) loads it into `audit/state.json` — after this point the queue is amended with `xcheck queue-pass`, not by editing `AUDIT.md`. +3. Human skims the pass queue and trims it: drop or reorder passes; cheap, no gate, just judgment. Then `xcheck migrate` (§1b) loads it into `audit/state.json` — after this point the queue is amended with `xcheck queue-pass`, not by editing `AUDIT.md`. 4. Auditor sessions work the queue one charter at a time, filing findings and a pass report per charter. 5. Triage: human sets every `reported` finding to `accepted`, `rejected`, or `deferred`. 6. Remediator sessions take batches of `accepted` findings through validate → census → plan → fix → self-check. diff --git a/ci/tiers.py b/ci/tiers.py index 0e05c78..ee85ba8 100644 --- a/ci/tiers.py +++ b/ci/tiers.py @@ -38,6 +38,9 @@ "test_admin_verbs", # policy: what the operator owns, where it may live, and the trust classification "test_conf_policy", "test_policy_location", "test_trust_level", + # and the way in to it: the operator profile nothing dispatches without, the AUDIT.md + # template an install picks, and a refusal whose commands run when typed + "test_operator_onboarding", # the four shared-boundary gates: canonical state, transaction identity, the journal # door and the dispatch preparation. AST-only and ~0.4s, and on the PR tier rather # than the release tier on purpose — a gate that runs late is a gate that catches late diff --git a/install.sh b/install.sh index 7da5d7d..fd1d80a 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,15 @@ #!/bin/sh -# Install the xcheck methodology into a target project: ./install.sh /path/to/project +# Install the xcheck methodology into a target project: +# ./install.sh [--type code|text] /path/to/project +# +# `--type` picks the template AUDIT.md starts from: `code` for a codebase, `text` for a +# text corpus. It has a default (`text`, what this script always installed) where the +# operator profile's trust level has none, because a wrong template is cheap to undo +# until the Planner runs: one `cp` over a file that holds nothing yet. +# +# This sets up the SUBJECT only. The operator profile — what each role runs, and inside +# what boundary — lives outside every audited repository, so `operator-profile.sh` +# writes it, once per machine; the closing lines below say so. # # Deliberately shell-only and small. This runs in target projects that may have no # Python environment configured, and its simplicity is a feature — an installer that @@ -17,7 +27,41 @@ set -e cd "$(dirname "$0")" SRC=$(pwd) -TARGET=${1:?usage: ./install.sh /path/to/project} + +usage() { + echo "usage: ./install.sh [--type code|text] /path/to/project" + echo " --type code AUDIT.md starts from templates/AUDIT-code.md: a codebase" + echo " --type text AUDIT.md starts from templates/AUDIT-text.md: a text corpus (default)" +} +TYPE= +TARGET= +while [ $# -gt 0 ]; do + case "$1" in + --type) [ $# -ge 2 ] || { usage >&2; exit 2; }; TYPE=$2; shift 2 ;; + --type=*) TYPE=${1#--type=}; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "install: unknown option $1" >&2; usage >&2; exit 2 ;; + *) [ -z "$TARGET" ] || { usage >&2; exit 2; }; TARGET=$1; shift ;; + esac +done +[ -n "$TARGET" ] || { usage >&2; exit 2; } +# Checked before anything is written: a refused install leaves no `audit/` behind. +case "${TYPE:-text}" in + code|text) ;; + *) echo "install: --type $TYPE is not a project type (code or text)" >&2; exit 2 ;; +esac + +# What the operator does next, printed on both ways out of this script. The profile step +# comes first because without it `xcheck next` and every launcher that wraps it refuse +# before a session starts, and nothing on the install path used to say so. +finish() { + echo " $AUDIT_NOTE" + echo "next: 1. once per machine, an operator profile, outside every audited repository." + echo " \`xcheck next\` and the launchers dispatch nothing until one exists:" + echo " $SRC/operator-profile.sh --trust trusted|untrusted /path/to/operator.conf" + echo " 2. plan the audit: README §6, Step 1." +} + mkdir -p "$TARGET/audit/findings" "$TARGET/audit/passes" "$TARGET/audit/plans" "$TARGET/audit/construals" "$TARGET/audit/templates" # The shipped set — the ONE list, used for both copying and the manifest, so a file @@ -27,8 +71,24 @@ SHIPPED="XCHECK.md templates/finding.md templates/class-finding.md templates/pas cp XCHECK.md "$TARGET/audit/XCHECK.md" cp templates/finding.md templates/class-finding.md templates/pass.md templates/plan.md templates/construal.md "$TARGET/audit/templates/" # AUDIT.md and LEDGER.md are the OPERATOR's from the moment they exist: created once, -# never refreshed. `upgrade` will not touch them either. -[ -f "$TARGET/audit/AUDIT.md" ] || cp templates/AUDIT-text.md "$TARGET/audit/AUDIT.md" # use AUDIT-code.md for code projects +# never refreshed. `upgrade` will not touch them either, and neither does `--type`: an +# AUDIT.md that exists is reported on, never replaced — even one that still holds +# nothing but a template, for which the report names the `cp` that switches it. +if [ ! -f "$TARGET/audit/AUDIT.md" ]; then + cp "templates/AUDIT-${TYPE:-text}.md" "$TARGET/audit/AUDIT.md" + AUDIT_NOTE="audit/AUDIT.md from templates/AUDIT-${TYPE:-text}.md" + [ -n "$TYPE" ] || AUDIT_NOTE="$AUDIT_NOTE (the default; --type code for a codebase)" +else + AUDIT_NOTE="audit/AUDIT.md kept: it exists, and it is the operator's" + for t in code text; do + cmp -s "$TARGET/audit/AUDIT.md" "templates/AUDIT-$t.md" || continue + AUDIT_NOTE="audit/AUDIT.md kept: it is the unfilled $t template" + if [ -n "$TYPE" ] && [ "$TYPE" != "$t" ]; then + AUDIT_NOTE="$AUDIT_NOTE, and install.sh never replaces an AUDIT.md. To switch: + cp $SRC/templates/AUDIT-$TYPE.md $TARGET/audit/AUDIT.md" + fi + done +fi [ -f "$TARGET/audit/LEDGER.md" ] || printf '| id | title | severity | status | next | updated |\n|---|---|---|---|---|---|\n' > "$TARGET/audit/LEDGER.md" # ---- provenance ----------------------------------------------------------------- @@ -45,6 +105,7 @@ else echo " audit/MANIFEST.sha256 were NOT written. The install is complete and" echo " usable; \`xcheck upgrade\` will report the copy as unprovenanced and" echo " refuse to refresh files it cannot prove it shipped." + finish exit 0 fi @@ -86,3 +147,4 @@ else echo "install: xcheck $VERSION (schema $SCHEMA, source $SOURCE_REF) -> $TARGET/audit" fi echo " provenance: audit/.provenance.json, manifest: audit/MANIFEST.sha256 ($(wc -l < "$TARGET/audit/MANIFEST.sha256" | tr -d ' ') files)" +finish diff --git a/operator-profile.sh b/operator-profile.sh new file mode 100755 index 0000000..c498070 --- /dev/null +++ b/operator-profile.sh @@ -0,0 +1,185 @@ +#!/bin/sh +# Write an xcheck operator profile, once per machine: +# ./operator-profile.sh --trust trusted|untrusted [--project DIR] [PATH] +# +# `install.sh` sets up the SUBJECT; this sets up the OPERATOR. They are two scripts +# because they write to two places that must never be the same: the profile says what +# each role runs and inside what boundary, so it lives OUTSIDE every audited repository +# (README §8), and `xcheck next` dispatches nothing until one resolves. +# +# It decides nothing for the operator. `trust_level` has no default in xcheck and none +# here: how much containment a repository needs is a fact about that repository. The +# location is named by the operator too, or by $XDG_CONFIG_HOME — like xcheck itself, +# this script never derives a path from the home directory. +# +# What it does: copies docs/operator.conf.example (byte-identical to the template the +# loader prints; `tests/test_conf_policy.py` holds that), sets `trust_level` — and for +# `untrusted` the `sandbox_profile=container` that classification makes mandatory — +# validates the result with xcheck's own loader, and only then links it into place. It +# never overwrites a profile. +set -e +SRC=$(cd "$(dirname "$0")" && pwd) +PY=${PYTHON:-python3} +ME=operator-profile + +usage() { + cat <&2; exit 2; }; TRUST=$2; shift 2 ;; + --trust=*) TRUST=${1#--trust=}; shift ;; + --project) [ $# -ge 2 ] || { usage >&2; exit 2; }; PROJECT=$2; shift 2 ;; + --project=*) PROJECT=${1#--project=}; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "$ME: unknown option $1" >&2; usage >&2; exit 2 ;; + *) [ -z "$DEST" ] || { echo "$ME: one PATH only" >&2; usage >&2; exit 2; } + DEST=$1; shift ;; + esac +done + +case "$TRUST" in + trusted|untrusted) ;; + "") + cat >&2 <&2 + exit 2 ;; +esac + +if [ -z "$DEST" ]; then + if [ -z "$XDG_CONFIG_HOME" ]; then + cat >&2 <&2 + echo " Edit it by hand, or move it away and run this again." >&2 + exit 1 +fi +if [ -n "$PROJECT" ] && [ ! -d "$PROJECT" ]; then + echo "$ME: --project $PROJECT is not a directory" >&2 + exit 2 +fi +command -v "$PY" >/dev/null 2>&1 || { + echo "$ME: $PY not found. xcheck runs on Python 3; set PYTHON to the interpreter it uses." >&2 + exit 2 +} + +DIR=$(dirname "$DEST") +mkdir -p "$DIR" +DIR=$(cd "$DIR" && pwd) +DEST=$DIR/$(basename "$DEST") + +# Built and validated beside the destination, so the location check below judges the +# directory the profile will really live in, and the final link is one rename away. +TMP=$(mktemp "$DIR/.operator.conf.XXXXXX") +trap 'rm -f "$TMP"' EXIT + +if [ "$TRUST" = untrusted ]; then + sed -e "s/^trust_level=\$/trust_level=$TRUST/" \ + -e 's/^sandbox_profile=$/sandbox_profile=container/' \ + "$SRC/docs/operator.conf.example" > "$TMP" +else + sed -e "s/^trust_level=\$/trust_level=$TRUST/" "$SRC/docs/operator.conf.example" > "$TMP" +fi +grep -q "^trust_level=$TRUST\$" "$TMP" || { + echo "$ME: docs/operator.conf.example has no empty trust_level= line to set; it no" >&2 + echo " longer matches what this script expects, and nothing was written." >&2 + exit 1 +} + +# xcheck's own loader, so a profile this script writes is one `xcheck next` accepts: the +# location gate, the unknown-key and project-key refusals, and the trust level. With no +# --project there is no subject to measure the location against; xcheck applies the +# same gate again at every dispatch, against the project it is dispatching for. +"$PY" -I - "$SRC" "$TMP" "$PROJECT" <<'EOF' || exit 1 +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[1]) +from xcheck import policy +path, project = Path(sys.argv[2]), sys.argv[3] +if not project: + project = path.parent / ".no-project-named" +try: + level = policy.trust_level(policy.load(path, project)) +except policy.PolicyError as e: + sys.exit(f"operator-profile: refused, nothing written.\n{e}") +if not level: + sys.exit("operator-profile: the loader read no trust_level; nothing written.") +EOF + +# A hard link, not a move: it fails if DEST appeared since the check above, so a +# profile somebody wrote in the meantime is never replaced. +ln "$TMP" "$DEST" 2>/dev/null || { + echo "$ME: $DEST appeared while this ran; it was left alone and nothing was written." >&2 + exit 1 +} +rm -f "$TMP" + +echo "$ME: wrote $DEST (trust_level=$TRUST)" +if [ "$TRUST" = untrusted ]; then + echo " sandbox_profile=container: every session runs in a container, so docker must" + echo " work on this machine." +fi +echo " Role commands, from the template. Edit the file to change them:" +grep '^[a-z]*_cmd=' "$DEST" | sed 's/^/ /' + +XDG_DEST= +if [ -n "$XDG_CONFIG_HOME" ] && [ -d "$XDG_CONFIG_HOME" ]; then + XDG_DEST=$(cd "$XDG_CONFIG_HOME" && pwd)/xcheck/operator.conf +fi +if [ "$DEST" = "$XDG_DEST" ]; then + echo " xcheck finds it through \$XDG_CONFIG_HOME, so there is nothing to export." +else + echo " xcheck reads it only where it is told to. Run this, and add it to your" + echo " shell's startup file:" + case "${SHELL##*/}" in + fish) echo " set -gx XCHECK_OPERATOR_PROFILE \"$DEST\"" ;; + *) echo " export XCHECK_OPERATOR_PROFILE=\"$DEST\"" ;; + esac +fi +if [ -n "$XCHECK_OPERATOR_PROFILE" ] && [ "$XCHECK_OPERATOR_PROFILE" != "$DEST" ]; then + echo " note: XCHECK_OPERATOR_PROFILE is set to $XCHECK_OPERATOR_PROFILE in this shell," + echo " and xcheck reads it first, so this profile is not in force until it points here." +fi +if [ -n "$PROJECT" ]; then + echo "next: $SRC/bin/xcheck --project $PROJECT status (its policy: line names the file)" +else + echo "next: from the audited project's root, \`xcheck status\` names the profile in force" + echo " on its policy: line; then plan with /xcheck-plan." +fi diff --git a/tests/test_operator_onboarding.py b/tests/test_operator_onboarding.py new file mode 100644 index 0000000..3168a30 --- /dev/null +++ b/tests/test_operator_onboarding.py @@ -0,0 +1,303 @@ +"""From a checkout to a first dispatch: the template an install picks, and the operator +profile nothing runs without. + +A new operator met two gaps, one after the other, on a real codebase: + +- `install.sh` always installed the text-corpus AUDIT.md. A codebase needed a hand `cp` + that only a note under README §5.2 mentioned, so the plan began from `facts`, + `style` and `currency`. +- Nothing on the install path said an operator profile is required. `xcheck next` + refused, `xcheck status` said nothing about it, and the refusal's own hint — + "`xcheck --policy PATH` with a path that does not exist prints the template" — named + a command the CLI rejects: `--policy is not valid for no command`. + +So: `install.sh --type code|text`, `operator-profile.sh`, a refusal whose commands run, +and a `status` line naming the profile in force, or saying there is none. + +Every script here is the real one, run as a child process. A developer's own profile +must never decide a test, so each child gets an environment with neither +`$XCHECK_OPERATOR_PROFILE` nor `$XDG_CONFIG_HOME`. +""" + +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from tests.harness import REPO, XCHECK, Fixture, xcheck_submodule + +policy = xcheck_submodule("policy") +INSTALL = REPO / "install.sh" +HELPER = REPO / "operator-profile.sh" +TEMPLATE = {t: (REPO / "templates" / f"AUDIT-{t}.md").read_bytes() for t in ("code", "text")} +PROFILE_SOURCES = (policy.OPERATOR_PROFILE_ENV, policy.XDG_CONFIG_ENV) + + +def clean_env(**over): + """The caller's environment minus both profile sources, plus `over` (None drops).""" + env = {k: v for k, v in os.environ.items() if k not in PROFILE_SOURCES} + env.update(PYTHON=sys.executable, SHELL="/bin/sh") + for k, v in over.items(): + if v is None: + env.pop(k, None) + else: + env[k] = v + return env + + +def trusted(text, level="trusted"): + return text.replace(f"\n{policy.TRUST_KEY}=\n", f"\n{policy.TRUST_KEY}={level}\n") + + +class Scratch(unittest.TestCase): + + def tmp(self, what): + d = Path(tempfile.mkdtemp(prefix=f"xcheck-onboarding-{what}-")) + self.addCleanup(shutil.rmtree, d, ignore_errors=True) + return d + + def sh(self, script, *args, expect=0, **env): + p = subprocess.run(["sh", str(script), *map(str, args)], capture_output=True, + text=True, timeout=120, env=clean_env(**env)) + out = p.stdout + p.stderr + self.assertEqual(p.returncode, expect, out) + return out + + def install(self, *args, expect=0): + return self.sh(INSTALL, *args, expect=expect) + + def helper(self, *args, expect=0, **env): + return self.sh(HELPER, *args, expect=expect, **env) + + +class InstallPicksTheTemplate(Scratch): + + def audit_md(self, project): + return (project / "audit" / "AUDIT.md").read_bytes() + + def test_type_code_installs_the_code_template(self): + project = self.tmp("project") + out = self.install("--type", "code", project) + self.assertEqual(self.audit_md(project), TEMPLATE["code"]) + self.assertIn("AUDIT-code.md", out) + + def test_the_flag_may_follow_the_target(self): + project = self.tmp("project") + self.install(project, "--type=code") + self.assertEqual(self.audit_md(project), TEMPLATE["code"]) + + def test_no_flag_is_still_text_and_names_the_flag(self): + # The default is unchanged, so every existing caller installs what it did + # before. What changes is that the output says which template that was. + project = self.tmp("project") + out = self.install(project) + self.assertEqual(self.audit_md(project), TEMPLATE["text"]) + self.assertIn("--type code", out) + + def test_an_unknown_type_installs_nothing(self): + project = self.tmp("project") + out = self.install("--type", "rust", project, expect=2) + self.assertFalse((project / "audit").exists(), "a refused install wrote audit/") + self.assertIn("code", out) + self.assertIn("text", out) + + def test_a_missing_target_is_a_usage_error(self): + out = self.install("--type", "code", expect=2) + self.assertIn("usage:", out) + + def test_an_unfilled_template_is_kept_and_the_switch_is_named(self): + # AUDIT.md is the operator's from the moment it exists, so a second install + # never replaces it — even one that still holds nothing but a template. It + # says how to switch instead. + project = self.tmp("project") + self.install(project) + out = self.install("--type", "code", project) + self.assertEqual(self.audit_md(project), TEMPLATE["text"]) + self.assertIn("unfilled text template", out) + self.assertRegex(out, r"cp \S*templates/AUDIT-code\.md \S*audit/AUDIT\.md") + + def test_an_edited_plan_is_kept_without_a_switch_offered(self): + project = self.tmp("project") + self.install(project) + (project / "audit" / "AUDIT.md").write_text("# a plan somebody wrote\n") + out = self.install("--type", "code", project) + self.assertEqual(self.audit_md(project), b"# a plan somebody wrote\n") + self.assertIn("operator's", out) + self.assertNotIn("cp ", out) + + def test_the_install_names_the_profile_step(self): + out = self.install("--type", "code", self.tmp("project")) + self.assertIn("operator-profile.sh --trust", out) + + +class OperatorProfileHelper(Scratch): + + def test_it_writes_the_template_with_the_trust_level_set(self): + dest = self.tmp("operator") / "nested" / "operator.conf" + out = self.helper("--trust", "trusted", dest) + self.assertEqual(dest.read_text(encoding="utf-8"), + trusted(policy.OPERATOR_PROFILE_TEMPLATE), + "anything but the trust line differs from the template") + keys = policy.load(dest, self.tmp("subject")) + self.assertEqual(policy.trust_level(keys), policy.TRUSTED) + self.assertIn(f'export {policy.OPERATOR_PROFILE_ENV}="{dest}"', out) + self.assertEqual(sorted(p.name for p in dest.parent.iterdir()), ["operator.conf"], + "the staging file was left behind") + + def test_untrusted_also_sets_the_container_profile(self): + # `untrusted` without `container` refuses every writing dispatch, so a helper + # that set only the first would write a profile that cannot run a Planner. + dest = self.tmp("operator") / "operator.conf" + out = self.helper("--trust", "untrusted", dest) + self.assertEqual(dest.read_text(encoding="utf-8"), + trusted(policy.OPERATOR_PROFILE_TEMPLATE, "untrusted").replace( + "\nsandbox_profile=\n", "\nsandbox_profile=container\n")) + self.assertIn("docker", out) + + def test_the_trust_level_is_required_and_explained(self): + dest = self.tmp("operator") / "operator.conf" + out = self.helper(dest, expect=2) + self.assertFalse(dest.exists()) + for level in policy.TRUST_LEVELS: + self.assertIn(f"--trust {level}", out) + + def test_an_unknown_trust_level_is_refused(self): + # The text, not only the exit code: `sh` exits 2 on a script it cannot open, + # so a code alone passes against a tree with no helper at all. + dest = self.tmp("operator") / "operator.conf" + out = self.helper("--trust", "mostly", dest, expect=2) + self.assertIn("--trust mostly is not a trust level", out) + self.assertFalse(dest.exists()) + + def test_it_never_overwrites_a_profile(self): + dest = self.tmp("operator") / "operator.conf" + dest.write_text("planner_cmd=mine {prompt}\n", encoding="utf-8") + self.helper("--trust", "trusted", dest, expect=1) + self.assertEqual(dest.read_text(encoding="utf-8"), "planner_cmd=mine {prompt}\n") + + def test_a_path_inside_the_named_project_is_refused_by_the_loader(self): + project = self.tmp("subject") + dest = project / "operator.conf" + out = self.helper("--trust", "trusted", "--project", project, dest, expect=1) + self.assertIn("may not live inside the repository being audited", out) + self.assertEqual(list(project.iterdir()), [], "the refused profile left a file") + + def test_no_path_and_no_xdg_invents_no_location(self): + out = self.helper("--trust", "trusted", expect=2) + self.assertIn("/path/to/operator.conf", out) + + def test_under_xdg_the_path_is_optional_and_needs_no_export(self): + xdg = self.tmp("xdg") + out = self.helper("--trust", "trusted", XDG_CONFIG_HOME=str(xdg)) + dest = xdg / policy.OPERATOR_PROFILE_RELATIVE + self.assertTrue(dest.is_file(), out) + self.assertNotIn("export ", out) + with mock.patch.dict(os.environ, {policy.XDG_CONFIG_ENV: str(xdg)}): + os.environ.pop(policy.OPERATOR_PROFILE_ENV, None) + self.assertEqual(policy.resolve(), dest, "xcheck does not find what it wrote") + + def test_a_fish_shell_gets_a_line_fish_can_run(self): + dest = self.tmp("operator") / "operator.conf" + out = self.helper("--trust", "trusted", dest, SHELL="/usr/bin/fish") + self.assertIn(f'set -gx {policy.OPERATOR_PROFILE_ENV} "{dest}"', out) + + +class TheRefusalNamesCommandsThatRun(Scratch): + """The refusal is the one text every new operator reads, so what it says to type + has to work when typed.""" + + def test_the_template_command_it_names_prints_the_template(self): + # Before: the hint read "`xcheck --policy PATH` with a path that does not exist + # prints the template", and that command exits 2 with `--policy is not valid + # for no command`. The hint is parsed here rather than restated, so a reworded + # hint is held to the same standard. + hint = re.search(r"`xcheck (--policy PATH[^`]*)`", policy.no_profile_refusal()) + self.assertIsNotNone(hint, "the refusal no longer names a template command") + argv = hint.group(1).split() + argv[argv.index("PATH")] = str(self.tmp("operator") / "not-yet.conf") + project = self.tmp("project") + self.install(project) + p = subprocess.run([sys.executable, str(XCHECK), "--project", str(project), *argv], + capture_output=True, text=True, timeout=120, env=clean_env()) + self.assertIn(policy.OPERATOR_PROFILE_TEMPLATE, p.stdout + p.stderr) + self.assertNotIn("is not valid for", p.stdout + p.stderr) + + def test_it_names_the_helper_and_the_helper_is_executable(self): + self.assertIn("operator-profile.sh", policy.no_profile_refusal()) + self.assertTrue(os.access(HELPER, os.X_OK), f"{HELPER} is not executable") + + def test_the_xdg_location_is_spelled_as_a_variable(self): + # `$(XDG_CONFIG_HOME)` is command substitution in every POSIX shell. + refusal = policy.no_profile_refusal() + self.assertNotIn("$(XDG_CONFIG_HOME)", refusal) + self.assertIn(f"${policy.XDG_CONFIG_ENV}/{policy.OPERATOR_PROFILE_RELATIVE}", + refusal) + + +class StatusNamesThePolicy(unittest.TestCase): + """`status` is the read-only question an operator asks first, and the launcher + skills tell it to look there for the profile. Before this it printed + `decision: run-planner` on a machine where `next` would refuse.""" + + def setUp(self): + self.fx = Fixture() + self.addCleanup(self.fx.cleanup) + + def policy_line(self, out): + lines = [ln for ln in out.splitlines() if ln.startswith("policy:")] + self.assertEqual(len(lines), 1, out) + return lines[0] + + def test_a_resolved_profile_is_named_with_its_digest(self): + code, out = self.fx.run("status") + self.assertEqual(code, 0, out) + self.assertEqual(self.policy_line(out), + f"policy: {self.fx.profile} " + f"sha256:{policy.profile_digest(self.fx.profile)[:16]}") + + def test_no_profile_says_so_and_how_to_write_one(self): + self.fx.profile.unlink() + with mock.patch.dict(os.environ): + os.environ.pop(policy.XDG_CONFIG_ENV, None) + code, out = self.fx.run("status") + self.assertEqual(code, 0, out) + line = self.policy_line(out) + self.assertIn("none", line) + self.assertIn("operator-profile.sh", line) + + +class TheDocsSayItBeforeTheFirstSession(unittest.TestCase): + + README = (REPO / "README.md").read_text(encoding="utf-8") + BOOTSTRAP = (REPO / "bootstrap.md").read_text(encoding="utf-8") + + def between(self, text, start, end): + i = text.index(start) + return text[i:text.index(end, i)] + + def test_installation_has_the_profile_step_before_the_launchers(self): + section = self.between(self.README, "### 5.3 ", "### 5.4 ") + self.assertIn("operator profile", section.splitlines()[0]) + self.assertIn("./operator-profile.sh --trust", section) + self.assertIn("Install the launchers", self.between(self.README, "### 5.4 ", "\n")) + + def test_install_documents_the_type_flag(self): + self.assertIn("./install.sh --type code", self.between(self.README, "### 5.2 ", + "### 5.3 ")) + self.assertIn("./install.sh --type code", self.BOOTSTRAP) + + def test_quickstart_step_one_points_at_the_profile(self): + self.assertIn("§5.3", self.between(self.README, "### Step 1", "### Step 2")) + + def test_bootstrap_has_the_profile_step_before_the_planner(self): + section = self.between(self.BOOTSTRAP, "## 1a.", "## 1b.") + self.assertIn("./operator-profile.sh --trust", section) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_parallel_retry.py b/tests/test_parallel_retry.py index a3942f5..2567d71 100644 --- a/tests/test_parallel_retry.py +++ b/tests/test_parallel_retry.py @@ -17,6 +17,7 @@ import io import json import os +import re import shutil import subprocess import sys @@ -266,12 +267,22 @@ def run_status(self, fx, conf): self.assertEqual(p.returncode, 0, p.stdout + p.stderr) return p.stdout + @staticmethod + def without_profile_digest(out): + # `status` names the operator profile with its digest, and the two runs write two + # different profiles — the flag absent, then the flag written as `off` — so that + # digest differs by construction. Only the 16 hex digits are masked: the path, + # and every byte the decision path prints, still have to agree. + return re.sub(r"^(policy: .* sha256:)[0-9a-f]{16}$", r"\1", out, + flags=re.M) + def test_the_decision_output_is_identical_with_the_flag_absent_and_off(self): fx = Fixture(two_pass_doc()) self.addCleanup(fx.cleanup) absent = self.run_status(fx, "auditor_cmd=true {prompt}\n") off = self.run_status(fx, "auditor_cmd=true {prompt}\nparallel_passes=off\n") - self.assertEqual(absent, off) + self.assertEqual(self.without_profile_digest(absent), + self.without_profile_digest(off)) print("\nflag absent vs parallel_passes=off: byte-identical " f"({len(absent)} bytes)\n{absent.strip()}") diff --git a/tests/test_style_does_not_widen_a_gate.py b/tests/test_style_does_not_widen_a_gate.py index 248651a..8d8a8cd 100644 --- a/tests/test_style_does_not_widen_a_gate.py +++ b/tests/test_style_does_not_widen_a_gate.py @@ -324,7 +324,16 @@ # claim, which is where the scan needs it. Nothing in the 0.9.5 entry makes a NEW # containment claim; the phases it describes are about the journal, the dispatchers and # the metrics, none of which widen what a profile promises. -PRE_RUN_CLAIMS = 209 +# Moved 209 -> 211 on 2026-10-16, same debt paid. README.md went 45 -> 47 and every other +# document is byte-for-byte the same count; diffed by TEXT, the two new claims are both in +# the new §5.3, which tells an operator to create the profile before the first session: +# "the containment it runs inside" (what the profile holds) and "that also sets +# `sandbox_profile=container`" (what `operator-profile.sh --trust untrusted` writes). +# Neither widens a guarantee; both say where an existing one is configured. Unscoped +# stayed at 0: the section's first paragraph names `orchestrated` BEFORE the first claim. +# The first run of this gate after the section was written reported 2 unscoped, and the +# fix was that clause, not a looser gate. +PRE_RUN_CLAIMS = 211 PRE_RUN_UNSCOPED = 0 PRE_RUN_OFFENDERS = 0 # 2 -> 0 in the same phase, and DOWNWARD, which is the direction that needs no defence: diff --git a/xcheck/cli.py b/xcheck/cli.py index 70b874b..29228f4 100644 --- a/xcheck/cli.py +++ b/xcheck/cli.py @@ -258,6 +258,14 @@ def print_sandbox(conf, state): Two different questions, so two lines. "What would run now" comes from the conf; "what was actually enforced" comes from the envelope's capability report, which is the only one of the two that is evidence.""" + # The operator profile every line below is read from, in the same words + # `require_policy` prints at dispatch. Without it this command answered + # `decision: run-planner` on a machine where `next` refuses before any session, and + # the launchers send an operator HERE to see which profile is in force. + resolved = policy.resolve() + print(f"policy: {resolved} sha256:{policy.profile_digest(resolved)[:16]}" if resolved + else "policy: none found — `xcheck next` and `xcheck loop` refuse until one " + "resolves; `operator-profile.sh` in the xcheck repository writes one") writing = resolve_profile(conf, "Remediator") reading = resolve_profile(conf, "Auditor") line = (f"sandbox: {writing.name} for writing roles, {reading.name} for reading " diff --git a/xcheck/policy.py b/xcheck/policy.py index c532bc0..7a74dd9 100644 --- a/xcheck/policy.py +++ b/xcheck/policy.py @@ -389,9 +389,14 @@ def no_profile_refusal(explicit=None): f"Create one and point at it:\n" f" export {OPERATOR_PROFILE_ENV}=/path/to/operator.conf\n" f" xcheck --policy /path/to/operator.conf \n" - f"or set {XDG_CONFIG_ENV} and write $({XDG_CONFIG_ENV})/{OPERATOR_PROFILE_RELATIVE}.\n" - "`xcheck --policy PATH` with a path that does not exist prints the template to " - "start from.") + f"or set {XDG_CONFIG_ENV} and write ${XDG_CONFIG_ENV}/{OPERATOR_PROFILE_RELATIVE}.\n" + # The hint used to name a bare `xcheck --policy PATH`, which the grammar rejects + # (`--policy is not valid for no command`): the template is printed after a + # command is parsed. `tests/test_operator_onboarding.py` runs what this says. + "`operator-profile.sh`, beside `install.sh` in the xcheck repository, writes one " + "from the template and prints the line that points at it. To start from the " + "template by hand, `xcheck --policy PATH status` prints it while nothing exists " + "at PATH.") # PHASE 1 (third audit, release blocker). The previous run moved policy OUT of the diff --git a/xcheck/selftest.py b/xcheck/selftest.py index 8926217..60fcf57 100644 --- a/xcheck/selftest.py +++ b/xcheck/selftest.py @@ -3454,8 +3454,8 @@ def _install_preserves(repo_root): _sh50.copytree(_rp50_root / "templates", mrepo / "templates") ish = mrepo / "install.sh" ish.write_text(ish.read_text(encoding="utf-8").replace( - '[ -f "$TARGET/audit/AUDIT.md" ] || cp templates/AUDIT-text.md', - 'cp templates/AUDIT-text.md'), encoding="utf-8") + 'if [ ! -f "$TARGET/audit/AUDIT.md" ]; then', + 'if true; then'), encoding="utf-8") mut127 = _install_preserves(mrepo) ok = (clean127 == [] and any("AUDIT.md" in p and "preserved" in p for p in mut127)) print(f"{'PASS' if ok else 'FAIL'}: re-running install.sh preserves audit state byte-for-byte and refreshes XCHECK.md+templates; an inverted AUDIT.md guard is caught (F-0127)" From ca6897d40d669ea263d6d1f373f8a04b81272b03 Mon Sep 17 00:00:00 2001 From: vbanas Date: Fri, 16 Oct 2026 19:23:39 +0300 Subject: [PATCH 3/4] fix(templates): show the catalog headers migrate reads by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Planner given templates/AUDIT-code.md wrote a full plan for a real codebase (19 norms, 10 dimensions, 74 units, 90 passes) that `xcheck migrate` refused: the Unit map table has no 'unit' column (header: id | material | size | responsibility) The converter finds each catalog column by its exact header name, but the templates showed the header of only one of the three tables it reads. §1 Norms catalog and §3 Unit map held a comment and nothing else, so a Planner had to guess `id | source | scope` and `unit | material | responsibility`. This one got the first right and named the unit column `id`. It could not find out: `xcheck` is not on PATH in the Planner's sandbox. The comment above _UNITMAP_COLS in migrate_readers.py already described the templates as carrying `unit | material | approx. size | responsibility`. - Both templates show `| id | source | scope |` and `| unit | material | approx. size | responsibility |`, with a note that the converter reads these names and that norm ids are N1, N2, ... A fresh install's `--dry-run migrate` goes from 9 defects to the 7 dimension rows a Planner fills in; `status` still routes it to the Planner. - The selftest's template check (F-0130) holds all three catalog headers, not only Dimensions. A live mutation names the unit column `id`, as the real plan did, and expects it caught. 170/170. - _NORMS_RE and _UNITMAP_RE move from migrate_readers to md_prose, beside _DIMENSIONS_RE, because nothing outside the converter may import migrate_readers (test_no_bypass). - test_operator_onboarding fills each table under the header the template shows and expects the plan to convert, with one norm, dimension, unit and pass. On the parent it fails with "no Norms catalog table" and "no Unit map table". A plan already written keeps its header. One with `| id |` in its Unit map loads once that cell reads `unit`. Co-Authored-By: Claude Opus 5.5 --- templates/AUDIT-code.md | 17 +++++++-- templates/AUDIT-text.md | 17 +++++++-- tests/test_operator_onboarding.py | 57 +++++++++++++++++++++++++++++-- xcheck/md_prose.py | 7 ++++ xcheck/migrate.py | 8 ++--- xcheck/migrate_readers.py | 16 ++++----- xcheck/selftest.py | 32 +++++++++++------ 7 files changed, 124 insertions(+), 30 deletions(-) diff --git a/templates/AUDIT-code.md b/templates/AUDIT-code.md index fc3a526..5fcd6ea 100644 --- a/templates/AUDIT-code.md +++ b/templates/AUDIT-code.md @@ -7,7 +7,13 @@ + Do not read norms fully — catalog them. + Keep the column names below: `xcheck migrate` finds each column by + its header, and a plan that renames one does not load. Ids are N1, + N2, …, and each dimension's `norm source` in §2 names them. --> + +| id | source | scope | +|---|---|---| ## 2. Dimensions + batch small ones. + Keep the column names below: `xcheck migrate` finds each column by + its header, and a plan that renames `unit`, `material` or + `responsibility` does not load. A unit id such as U01 is what §4 and + every finding name the unit by. --> + +| unit | material | approx. size | responsibility | +|---|---|---|---| ## 4. Pass queue + Do not read norms fully — catalog them. + Keep the column names below: `xcheck migrate` finds each column by + its header, and a plan that renames one does not load. Ids are N1, + N2, …, and each dimension's `norm source` in §2 names them. --> + +| id | source | scope | +|---|---|---| ## 2. Dimensions + session (heuristic: ≤3 units) — split large units, batch small ones. + Keep the column names below: `xcheck migrate` finds each column by + its header, and a plan that renames `unit`, `material` or + `responsibility` does not load. A unit id such as U01 is what §4 and + every finding name the unit by. --> + +| unit | material | approx. size | responsibility | +|---|---|---|---| ## 4. Pass queue `) or surviving only mid-paragraph is caught # (round-4/8); - # * a ("table", heading_re, cols) tuple is a TABLE HEADER — the section named by + # * a ("table", heading_re, cols, name) tuple is a TABLE HEADER — the section named by # heading_re must hold a GFM table (`_section_table`, the whole-table form declared # for F-0112) whose header cells EQUAL cols by whole-cell normalized equality # (`_norm_cell`), NOT a substring, so the dimensions header commented out of the @@ -3043,14 +3044,20 @@ def _audit126(td, specs): # specs: list of (fid, status, attempts) # AUDIT §5 limit values are NOT needles — compared against the XCHECK.md §10 parsed # defaults below; the coverage polarity is the separate COVERED/NOT-COVERED check; # frontmatter fields are the parsed-schema check further down. + # All three catalog tables, because `xcheck migrate` finds every column by its header + # name: a template that showed only the dimensions header left a Planner to guess the + # other two, and a real one named the unit column `id`, so its plan did not load. _DIM_COLS = ["key", "what it catches", "norm source"] + _NORM_COLS = ["id", "source", "scope"] + _UNIT_COLS = ["unit", "material", "approx. size", "responsibility"] + _AUDIT_TABLES = [("table", _NORMS_RE, _NORM_COLS, "norms catalog"), + ("table", _DIMENSIONS_RE, _DIM_COLS, "dimensions"), + ("table", _UNITMAP_RE, _UNIT_COLS, "unit map")] _REQ_TEMPLATES = { "AUDIT-code.md": ["## 1. Norms catalog", "## 2. Dimensions", "## 3. Unit map", - "## 4. Pass queue", "## 5. Limits", - ("table", _DIMENSIONS_RE, _DIM_COLS)], + "## 4. Pass queue", "## 5. Limits", *_AUDIT_TABLES], "AUDIT-text.md": ["## 1. Norms catalog", "## 2. Dimensions", "## 3. Unit map", - "## 4. Pass queue", "## 5. Limits", - ("table", _DIMENSIONS_RE, _DIM_COLS)], + "## 4. Pass queue", "## 5. Limits", *_AUDIT_TABLES], "finding.md": ["## Evidence", "## Why this is a defect", "## How to verify the fix", "## Validation", "## Refusal", "## Objection", "## Remediation", "## Admitted scope", "### Covers", "### Does not cover", @@ -3224,13 +3231,13 @@ def _template_problems(root): # equality — NOT a substring — so the dimensions header commented out of # the document (``) is # not a table row and is caught (F-0130 round-8 reopen). - _t, heading_re, cols = n + _t, heading_re, cols, name = n header, _rows = _section_table(text, heading_re) got = [_norm_cell(c) for c in header] if header else None want = [_norm_cell(c) for c in cols] if got != want: problems.append( - f"{fname}: dimensions table header {got!r} != required {cols!r}") + f"{fname}: {name} table header {got!r} != required {cols!r}") else: # No silent substring fallback (F-0130 round-8 human ruling): an # unclassified structural needle is a LOUD failure, not `n not in text`. @@ -3350,6 +3357,10 @@ def _mut_templates(td, path, repls): mut130_tablehdr = _mut_templates(td, "AUDIT-text.md", [("| key | what it catches | norm source |", "")]) + with tempfile.TemporaryDirectory() as td: # the unit map's key column named `id`, as a real Planner wrote it when the template showed no header — `xcheck migrate` then refused the plan + mut130_unitcol = _mut_templates(td, "AUDIT-code.md", + [("| unit | material | approx. size | responsibility |", + "| id | material | approx. size | responsibility |")]) with tempfile.TemporaryDirectory() as td: # a section HEADING surviving only inside a paragraph — no standalone ATX line (round-8 ruling poison) mut130_headprose = _mut_templates(td, "finding.md", [("## Objection", "See ## Objection note inline")]) @@ -3397,6 +3408,7 @@ def _mut_templates(td, path, repls): and any("finding.md" in p and "duplicate key 'status'" in p for p in mut130_dup) and any("finding.md" in p and "bad-form field 'unit'" in p for p in mut130_badform) and any("AUDIT-text.md" in p and "dimensions table header" in p for p in mut130_tablehdr) + and any("AUDIT-code.md" in p and "unit map table header" in p for p in mut130_unitcol) and any("finding.md" in p and "section header" in p and "Objection" in p for p in mut130_headprose) and any("finding.md" in p and "section header" in p and "How to verify the fix" in p for p in mut130_headfence) and any("AUDIT-text.md" in p and "dimensions table header" in p for p in mut130_tablefence) @@ -3405,8 +3417,8 @@ def _mut_templates(td, path, repls): and any("finding.md" in p and "section header" in p and "Verification" in p for p in mut130_fenceclose) and any("AUDIT-text.md" in p and "dimensions table header" in p for p in mut130_tablecomment) and any("finding.md" in p and "section header" in p and "How to verify the fix" in p for p in mut130_headcomment)) - print(f"{'PASS' if ok else 'FAIL'}: the seven shipped templates/ files carry the required sections, fields and tables as ORDINARY MARKDOWN LINES — a LINE-BASED drift check, NOT a CommonMark parser (F-0130 §8 rule 5 exception): it does NOT detect structure hidden inside raw HTML (`
...`) or other embeddings, and targets ACCIDENTAL drift in our own templates (a renamed/missing section, a dropped/wrong field, a corrupted plain-Markdown table), not deliberate obfuscation of them. Within that scope, section headings are matched as real ATX lines and table headers as whole pipe-table header cells (`_section_table` cells EQUAL the declared columns, not a substring — NO `n not in text` branch survives for structural content, F-0130 round-8), every raw-line structural recognizer reading them through the ONE CommonMark leading-indent rule (`_atx_indent_ok`: 0-3 leading spaces is a heading/table/fence, 4+ columns is indented code) with NON-RENDERED blocks — fenced code blocks AND HTML comment blocks — stripped FIRST by the ONE `_strip_nonstructure` (fence delimiters CommonMark-exact — a closing fence is a same-char run of >= length followed ONLY by whitespace, so a ```not-a-close interior line does not end the block; an HTML comment block opens on a `` line, a MID-LINE `` span — even one running across lines — is blanked with its visible prefix kept (F-0153 reopen), mutually exclusive with fences) so a ```-fenced, 4-column-indented, OR ``-commented EXAMPLE/copy of a heading/table is not read as document structure (F-0130 round-9/10/11); the three templates whose record lives in state.json (construal/pass/plan) must carry NO frontmatter block — a typed one looks recorded and is read by nothing — while the finding/CF frontmatter KEY SET is checked EXACTLY (missing AND extra → reject) against CANONICAL_SCHEMA (finding.md carries the full §2 record incl blocked/norm-ruling, class-finding.md the full schema incl members + the CF-only `created-by` provenance field), with NO duplicate singleton key AND NO bad-form field (the parser's own dup + badform signals, not a second judgement); fixed-default §2 VALUES (status=reported, attempts=0, the null defaults) are checked against the §2 record; and AUDIT-*.md §5 limits are checked against the XCHECK.md §10 defaults table, reading ONLY the §5 body with HTML comments stripped — so a stripped OR html-commented `## How to verify the fix`, a collapsed COVERED polarity, an `id` deleted from frontmatter (even with a body decoy), a non-canonical `future-field` added, blocked/norm-ruling renamed out of the CF, a flipped `status: reported`->`closed`, a deleted nullable `class:`, a corrupted `max_findings_per_pass` default (even with a decoy `15` hidden in a comment), two contradicting `status:` singletons that last-wins-collapse to a canonical record, a §2-undeclared `unit: {{a: b}}` flow-mapping form that parses to a mangled value under a canonical key, a dimensions table header commented out of the document (``, byte sequence surviving in prose), a section heading surviving only inside a paragraph, a real section heading wrapped in a ```-fenced code block, a real dimensions table header+delimiter wrapped in a ```-fenced code block, a real heading indented 4 columns into code (` ## ...`), a real heading hidden behind a fence whose ```not-a-close interior run fails to close it, a real dimensions table header+delimiter wrapped in a multi-line HTML comment, and a real section heading wrapped in a multi-line HTML comment are each caught — while a heading with 0-3 leading and trailing spaces stays accepted (F-0130)" - + ("" if ok else f" -> clean={clean130} sec={mut130_sec} html={mut130_html} cov={mut130_cov} fm={mut130_fm} cf={mut130_cf} val={mut130_val} del={mut130_del} lim={mut130_lim} extra={mut130_extra} limdecoy={mut130_limdecoy} dup={mut130_dup} badform={mut130_badform} tablehdr={mut130_tablehdr} headprose={mut130_headprose} headfence={mut130_headfence} tablefence={mut130_tablefence} headindent={mut130_headindent} headok={mut130_headok} fenceclose={mut130_fenceclose} tablecomment={mut130_tablecomment} headcomment={mut130_headcomment}")) + print(f"{'PASS' if ok else 'FAIL'}: the seven shipped templates/ files carry the required sections, fields and tables as ORDINARY MARKDOWN LINES — a LINE-BASED drift check, NOT a CommonMark parser (F-0130 §8 rule 5 exception): it does NOT detect structure hidden inside raw HTML (`
...`) or other embeddings, and targets ACCIDENTAL drift in our own templates (a renamed/missing section, a dropped/wrong field, a corrupted plain-Markdown table), not deliberate obfuscation of them. Within that scope, section headings are matched as real ATX lines and table headers as whole pipe-table header cells (`_section_table` cells EQUAL the declared columns, not a substring — NO `n not in text` branch survives for structural content, F-0130 round-8), every raw-line structural recognizer reading them through the ONE CommonMark leading-indent rule (`_atx_indent_ok`: 0-3 leading spaces is a heading/table/fence, 4+ columns is indented code) with NON-RENDERED blocks — fenced code blocks AND HTML comment blocks — stripped FIRST by the ONE `_strip_nonstructure` (fence delimiters CommonMark-exact — a closing fence is a same-char run of >= length followed ONLY by whitespace, so a ```not-a-close interior line does not end the block; an HTML comment block opens on a `` line, a MID-LINE `` span — even one running across lines — is blanked with its visible prefix kept (F-0153 reopen), mutually exclusive with fences) so a ```-fenced, 4-column-indented, OR ``-commented EXAMPLE/copy of a heading/table is not read as document structure (F-0130 round-9/10/11); the three templates whose record lives in state.json (construal/pass/plan) must carry NO frontmatter block — a typed one looks recorded and is read by nothing — while the finding/CF frontmatter KEY SET is checked EXACTLY (missing AND extra → reject) against CANONICAL_SCHEMA (finding.md carries the full §2 record incl blocked/norm-ruling, class-finding.md the full schema incl members + the CF-only `created-by` provenance field), with NO duplicate singleton key AND NO bad-form field (the parser's own dup + badform signals, not a second judgement); fixed-default §2 VALUES (status=reported, attempts=0, the null defaults) are checked against the §2 record; and AUDIT-*.md §5 limits are checked against the XCHECK.md §10 defaults table, reading ONLY the §5 body with HTML comments stripped — so a stripped OR html-commented `## How to verify the fix`, a collapsed COVERED polarity, an `id` deleted from frontmatter (even with a body decoy), a non-canonical `future-field` added, blocked/norm-ruling renamed out of the CF, a flipped `status: reported`->`closed`, a deleted nullable `class:`, a corrupted `max_findings_per_pass` default (even with a decoy `15` hidden in a comment), two contradicting `status:` singletons that last-wins-collapse to a canonical record, a §2-undeclared `unit: {{a: b}}` flow-mapping form that parses to a mangled value under a canonical key, a dimensions table header commented out of the document (``, byte sequence surviving in prose), a unit map header whose `unit` column is named `id`, a section heading surviving only inside a paragraph, a real section heading wrapped in a ```-fenced code block, a real dimensions table header+delimiter wrapped in a ```-fenced code block, a real heading indented 4 columns into code (` ## ...`), a real heading hidden behind a fence whose ```not-a-close interior run fails to close it, a real dimensions table header+delimiter wrapped in a multi-line HTML comment, and a real section heading wrapped in a multi-line HTML comment are each caught — while a heading with 0-3 leading and trailing spaces stays accepted (F-0130)" + + ("" if ok else f" -> clean={clean130} sec={mut130_sec} html={mut130_html} cov={mut130_cov} fm={mut130_fm} cf={mut130_cf} val={mut130_val} del={mut130_del} lim={mut130_lim} extra={mut130_extra} limdecoy={mut130_limdecoy} dup={mut130_dup} badform={mut130_badform} tablehdr={mut130_tablehdr} unitcol={mut130_unitcol} headprose={mut130_headprose} headfence={mut130_headfence} tablefence={mut130_tablefence} headindent={mut130_headindent} headok={mut130_headok} fenceclose={mut130_fenceclose} tablecomment={mut130_tablecomment} headcomment={mut130_headcomment}")) failed += 0 if ok else 1 # F-0127: install.sh promises (README §5.2) an idempotent upgrade that refreshes From 20348240e68fa0f519b4aee9cd3e7148d1ce88ea Mon Sep 17 00:00:00 2001 From: vbanas Date: Fri, 16 Oct 2026 19:26:48 +0300 Subject: [PATCH 4/4] fix(md_prose): keep a comment off the no-bypass import grep ca6897d's comment beside _NORMS_RE and _UNITMAP_RE put the module name migrate_readers and the word "import" on one line. test_no_bypass greps xcheck/ for that pair outside the converter, so it failed on a comment. The comment says the same thing without naming the module there. Co-Authored-By: Claude Opus 5.5 --- xcheck/md_prose.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xcheck/md_prose.py b/xcheck/md_prose.py index 33e8db4..0e2b89c 100644 --- a/xcheck/md_prose.py +++ b/xcheck/md_prose.py @@ -476,10 +476,10 @@ def _norm_cell(c): _DIMENSIONS_RE = re.compile(r"^##\s+\d+\.\s+Dimensions\b", re.I) -# The other two catalog sections. They sit here beside Dimensions, not in -# migrate_readers, because the converter is not their only reader: the selftest holds -# the shipped templates to the column names the converter reads, and nothing outside -# the converter may import migrate_readers (tests/test_no_bypass.py). +# The other two catalog sections. They sit here beside Dimensions, not in the +# converter's legacy reader, because the converter is not their only reader: the +# selftest holds the shipped templates to the column names the converter reads, and +# no module outside the converter may load that reader (tests/test_no_bypass.py). _NORMS_RE = re.compile(r"^##\s+\d+\.\s+Norms catalog\b", re.I) _UNITMAP_RE = re.compile(r"^##\s+\d+\.\s+Unit map\b", re.I)