diff --git a/README.md b/README.md index 6990161..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,18 +120,20 @@ 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. +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. 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` @@ -225,6 +248,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: @@ -325,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. @@ -348,15 +373,15 @@ 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 ``` 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 @@ -381,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` @@ -659,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/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..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 @@ -61,6 +64,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/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/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 @@ -3039,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", @@ -3220,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`. @@ -3346,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")]) @@ -3393,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) @@ -3401,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 @@ -3450,8 +3466,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)" 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())