Files
boshu2__agentops/scripts/check-doc-claims-tracked.sh
Bo 2ef932b617 RPI flow: premortem on risky plans, finding class in the law, adjudicated splits, orphaned evidence, doc claims gated (#1104)
## RPI flow: premortem on risky plans, finding class in the law,
adjudicated splits, orphaned evidence, doc claims gated

Provenance: running the loop for real across three trains on 2026-09-03
(#1099, #1100, #1101). The spine held; the flow around it did not. A
risky-surface design shipped with no premortem and needed six repair
passes. The open finding set grew three rounds running inside one class
while the id-counting law watched. A same-family PASS and a cross-family
FAIL had no tie-break until the caller named one afterwards. Every
harness edit orphaned bound evidence with no warning until verify
failed. A doc called an egress log published while the repository's
`*.log` rule kept it out of the tree, and the same overclaim class came
back four rounds.

**What changes.**
- **Premortem at plan exit.** When the write scope hits a risky surface,
one fresh judge reads the frozen plan before Implement; a blocking
finding returns NOT_PLANNED with the findings. The caller may declare
`premortem: skip`; the report says so.
- **A split is recorded, never elected and never closed inside the
loop.** The law stands: a risky surface converges only when both legs
PASS, and no finding leaves the open set because someone chose a judge.
Plan declares `binding_judge` as the caller's disposition for a split
that survives repair; it is bound in the plan identity, carried in the
report, and never mutates the verdict. On a risky split that survives
the repair budget (never after a law stop), one council leg receives a
bounded packet marked untrusted and returns per-finding rulings, exactly
one per id, which the traversal validates and records under
`council.rulings` for the caller's next intent; the verdict and the open
finding set stay exactly as repair left them. A council closure
mechanism was built, drew a new finding class in three consecutive judge
rounds (path containment, digest exclusion, presence-as-evidence,
partial closure downgrading a FAIL), and was cut under the train's own
class rule rather than hardened again. A non-risky split stays worst-of.
- **Finding class in the convergence law.** Findings carry an optional
stable `class`. A class carried by a resolved id that reappears on a new
id with no surviving prior id carrying it is `class_reopened`, whether
or not a classless round sits between; a blank class is an invalid
round; a round can carry both a reopened id and a reopened class and
stops on either. Repair stops, the traversal returns NOT_PROVEN, and the
caller goes back to Plan, because the design is wrong, not the patch.
Same rule in `run_once.py` and `workflows/rpi.js`, driven by one shared
fixture file, with executed canaries in the cathedral gate.
- **Plan identity, and the premortem's risky test.** On a risky scope
the Plan carries `binding_judge` and the evidence it expects to orphan;
`plan_digest`, when the plan declares one, must equal the script's
digest over the acceptance, the write scope, the binding judge, and the
intent-source digest, or the traversal stops before Implement. The risky
test over a declared scope is glob intersection: witness paths, the
risky regexes over the glob with wildcards instantiated, and any literal
prefix that reaches a risky root; a bare `**` or `*` is risky.
- **Intent is verified before anything is built.** An `intent-snapshot`
receipt re-derives the digest and compares the snapshot bytes to the
caller intent before premortem or Implement; absent, mis-hashed, or
wrong stops the traversal with `plan_identity_mismatch`. The Python
reference refuses on the same rule. Declared scopes are canonicalized
(repeated `./` collapsed; absolute paths and `..` refused at plan exit),
and the four spellings of `tests/**` share one plan digest while
`tests/` does not.
- **Orphaned evidence is a receipt, not a surprise.** After Implement
and after every repair round the workflow runs
`scripts/evidence-orphans.sh` over the runtime-derived path union, keyed
on the round so a same-set repair still reruns it and appends every
scorecard and fixture set whose bound evaluator or skill digests cover
them to the check receipts the validator reads, each with a cause
(`changed_path`, `digest_drift`, `both`, `skill_changed`). Plan budgets
that recapture up front. On today's tree the receipt already lists the
08-16 and 08-26 sets whose bound harness digests moved long ago.
- **Doc claims are gated.** The stronger scan already caught one stale
claim in the tree (a 2026-06-22 harvest note calling evidence committed
after Train 2 had removed that tree); the sentence now says where the
evidence went. `docs.claims-tracked` (blocking) requires backticked repo
paths in `evals/` and `docs/evals/` markdown to be tracked, and
sentences that say published, tracked, or committed to name files that
exist.
- **Risky scope is a glob intersection, not a heuristic.** One
segment-wise `globsIntersect` over one list of risky segment globs
answers both the declared-scope question at Plan exit and the
changed-path question for the cross-family rule; two heuristic designs
missed in opposite directions and were replaced rather than patched.
- **Contracts.** rpi gains a judgment dispatch table: risky scope routes
to premortem, a risky split to council, an irreversible landing to
one-way-door as a caller choice. plan, implement, validate, premortem,
council, and learn carry their halves; learn proposes a deterministic
check for any finding class that repeats.
`docs/architecture/rpi-traversal.md` is the source of record and was
updated with the flow.

**Evidence on the tip.** Regen check clean; Go bar; full gate green with
a HEAD-built binary (the new gate fires and passes); CI's literal bats
command green; rpi unit tests; cathedral gate with executed canaries for
the class law; routing goldens; token budgets; gate-tightening ratchet;
coverage 1 of 12 unchanged.

**Judgment, stated plainly.** Six judging rounds, each on a frozen tip.
The caller elected Fable 5.1 as the binding validator; its round-6
verdict is PASS with no verdict-driving findings (lows: a council
payload outside the schema throws a raw error instead of recording
`invalid-rulings`; two stale comments; a dead parameter; a dead fixture
element; the contracts say exactly one ruling per finding while the code
enforces at most one). The cross-family Codex read of the same tip
failed on three input-validation items, which the final commit closes:
control characters are now refused on the original scope value with the
full Unicode control class and the caller scope is canonicalized in
place so one spelling reaches every prompt; the intent receipt no longer
forgives a trailing-newline difference; and the Python reference accepts
a digest string only from a verifier callback and matches digests with
`fullmatch`. One half of one Codex item stays open and is disclosed: the
workflow does not itself hash the caller intent's bytes to compare with
the plan's declared digest (it relies on the receipt's exact-bytes match
plus digest equality); binding that locally means every probe fixture
carries a real digest of its intent text, which is the next pass. Also
open, pre-existing and outside this train: the risky-surface list names
`lib/**` while the preamble every gate sources lives under
`scripts/lib/`. Across the rounds the loop behaved as designed once: the
council closure mechanism drew a fresh finding class three rounds
running and was cut under the train's own class rule instead of hardened
a fourth time.
2026-09-05 23:03:51 +00:00

319 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# check-doc-claims-tracked.sh — docs.claims-tracked gate.
#
# WHY: on 2026-09-03 a doc sentence said an egress log was "published" while
# the repository's `*.log` ignore rule kept that exact file out of the tree,
# and the fresh verifier accepted the absent file because nothing checked the
# doc sentence against the working tree. Four separate judge rounds on that
# same day filed the identical finding class: "a doc sentence outruns the
# tree." A doc claiming a path is published, tracked, committed, or that it
# "lives at" some path is a factual claim about the repository, and nothing
# mechanical held it to the repository.
#
# WHAT: for every Markdown file under `evals/` and `docs/evals/` (this
# includes `evals/skill-probes/*.md` — no separate handling is needed, it is
# already inside `evals/`), scan every backtick-quoted inline token. A token
# is a CANDIDATE claim path when it:
# * contains a `/` and no whitespace;
# * does not start with `<`, `$`, `~`, or `http` (a placeholder, an
# interpolated variable, a home-relative path, or a URL, never a claim
# about THIS repository's tree);
# * does not contain `*` or `{` (a glob pattern, not one file);
# * starts with one of `evals/`, `docs/evals/`, `scripts/`, or `tests/` —
# the four trees this gate can reason about.
# A fence opens on a run of at least three backticks or tildes and closes only
# on a run of the SAME character, at least as long, with nothing but whitespace
# after it: a shorter run, the other character, or a trailing info string does
# not close it.
#
# A fenced code block is skipped ONLY when it quotes a COMMAND, because a
# command line is an example invocation rather than a claim about the tree:
# * its info string is a shell (`bash`, `sh`, `shell`, `console`, `zsh`), or
# * it has NO info string and its first non-blank line starts with `$`, with
# `./`, or with a known command word.
# Every other fence is SCANNED — `text`, `json`, and any other data fence is
# exactly where a scorecard or a probe README shows its own layout, and
# exempting all fences let those claims outrun the tree unchecked.
#
# A code span's trailing sentence punctuation (`.`, `,`, `;`, `:`) is stripped
# before the token is treated as a path, so "lives at `scripts/x.sh`." resolves
# `scripts/x.sh` rather than a file named `scripts/x.sh.`.
#
# For each candidate path, relative to the repository root:
# * exists on disk but is NOT tracked by git -> offender: untracked
# * does NOT exist on disk, and EITHER the same line uses one of the words
# "published", "tracked", "committed", or "lives at" (case-insensitive) OR
# the token sits inside a scanned DATA fence -> offender: missing.
# A data fence is a record of what IS: a scorecard block's
# `"status": "published"` and the path it names sit on different lines, so
# matching claim words per line let the sentence outrun the tree. Inside a
# data block every named path is a claim about the tree, full stop.
# * anything else (tracked-and-present, or absent-with-no-claim-word, which
# reads as a forward reference rather than a claim) is not an offender.
#
# Output: one line per offender, `file:line: path <state>`.
#
# Exit codes:
# 0 - clean (or nothing to scan)
# 1 - one or more offenders found
# 2 - fail-closed error (git missing/unusable, python3 missing, a scanned
# file could not be read as UTF-8 text, or a `git ls-files` lookup that
# answered neither "tracked" (0) nor "untracked" (1) — an unanswered
# question is never a negative answer)
#
# Usage:
# bash scripts/check-doc-claims-tracked.sh
# bash scripts/check-doc-claims-tracked.sh <repo-dir> # check another checkout
#
# shellcheck disable=SC1007
. "$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/preamble.sh"
require_cmd git
require_cmd python3
PROG="check-doc-claims-tracked"
TARGET="${1:-$REPO_ROOT}"
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
sed -n '2,58p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
fi
if ! git -C "$TARGET" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
printf '[%s] FAIL: %s is not a git repository — refusing to certify unscannable input\n' "$PROG" "$TARGET" >&2
exit 2
fi
work=""
with_tmpdir work claims-tracked
file_list="$work/files.txt"
: > "$file_list"
find_errors="$work/find-errors.txt"
: > "$find_errors"
for base in "evals" "docs/evals"; do
if [ -d "$TARGET/$base" ]; then
# A swallowed enumeration error certifies whatever it could not read. An
# unreadable directory is a scan that did not happen, never a clean one, so
# find's exit code and its stderr are both load-bearing here.
set +e
portable_find "$TARGET/$base" -type f -name '*.md' >> "$file_list" 2>>"$find_errors"
find_rc=$?
set -e
if [ "$find_rc" -ne 0 ]; then
printf '[%s] FAIL: could not enumerate %s (find exit %s)\n' "$PROG" "$TARGET/$base" "$find_rc" >&2
[ -s "$find_errors" ] && cat "$find_errors" >&2
exit 2
fi
fi
done
if [ -s "$find_errors" ]; then
printf '[%s] FAIL: could not enumerate every file under %s\n' "$PROG" "$TARGET" >&2
cat "$find_errors" >&2
exit 2
fi
sort -u -o "$file_list" "$file_list"
scanned=$(wc -l < "$file_list" | tr -d ' ')
if [ "$scanned" -eq 0 ]; then
printf '[%s] OK: no Markdown files under evals/ or docs/evals/ in %s — nothing to check\n' "$PROG" "$TARGET"
exit 0
fi
set +e
output="$(python3 - "$TARGET" "$file_list" <<'PY'
import os
import re
import subprocess
import sys
repo_root = sys.argv[1]
with open(sys.argv[2], "r", encoding="utf-8") as _fl:
files = [line.rstrip("\n") for line in _fl if line.strip()]
BACKTICK_RE = re.compile(r"`([^`\n]+)`")
# Inside a DATA fence a repo path is usually a plain JSON or YAML string, not a
# code span: a scorecard writes "artifact": "evals/.../x.log". Scanning only
# backtick spans there left the commonest real shape unchecked.
QUOTED_RE = re.compile(r"\"([^\"\n]+)\"")
PREFIXES = ("evals/", "docs/evals/", "scripts/", "tests/")
CLAIM_WORDS = ("published", "tracked", "committed", "lives at")
FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})\s*(\S*)")
# A fence is exempt only when it quotes a COMMAND. These are the info strings
# that say so outright.
SHELL_INFO = frozenset({"bash", "sh", "shell", "console", "zsh"})
# ...and these are the first words that say so for an info-less fence. An
# allowlist, not a heuristic: "anything that looks like a word" would exempt
# every data fence and reopen the hole this rule closes.
COMMAND_WORDS = frozenset({
"ao", "bash", "bats", "bd", "br", "bv", "cat", "cd", "chmod", "cp", "curl",
"docker", "echo", "export", "find", "git", "go", "grep", "helm", "jq",
"kubectl", "ls", "make", "mkdir", "mv", "node", "npm", "npx", "printf",
"python", "python3", "rg", "rm", "sed", "sh", "shellcheck", "sort", "tar",
"touch", "uv", "yarn", "zsh",
})
# Sentence punctuation that follows a path inside a code span.
TRAILING_PUNCTUATION = ".,;:"
def command_fence(first_line: str) -> bool:
"""True when an info-less fence's first content line is a command."""
stripped = first_line.strip()
if not stripped:
return False
if stripped.startswith("$") or stripped.startswith("./"):
return True
return stripped.split()[0] in COMMAND_WORDS
def is_candidate(tok: str) -> bool:
if not tok or " " in tok or "\t" in tok:
return False
if "/" not in tok:
return False
if tok.startswith(("<", "$", "~")):
return False
if tok.lower().startswith("http"):
return False
if "*" in tok or "{" in tok:
return False
return tok.startswith(PREFIXES)
def is_tracked(relpath: str) -> bool:
result = subprocess.run(
["git", "-C", repo_root, "ls-files", "--error-unmatch", "--", relpath],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# 0 is "tracked" and 1 is "not tracked"; anything else is git failing to
# answer. Reading a failure as "untracked" would report a false offender
# and, worse, would let a real answer be replaced by a broken tool.
if result.returncode not in (0, 1):
print(
f"git ls-files failed for {relpath} (exit {result.returncode})",
file=sys.stderr,
)
sys.exit(2)
return result.returncode == 0
offenders = []
for display_path in files:
try:
with open(display_path, "r", encoding="utf-8") as fh:
lines = fh.readlines()
except (OSError, UnicodeDecodeError) as exc:
print(f"could not read {display_path}: {exc}", file=sys.stderr)
sys.exit(2)
rel_display = os.path.relpath(display_path, repo_root)
# PASS 1: classify each line as skipped, data-fence, or prose, and group the
# prose into blank-line-delimited paragraphs. The claim words are a property
# of the PARAGRAPH, not of the line: "the log is published at the\npath `x`"
# wrapped across two lines and the claim went blind.
KIND_SKIP, KIND_DATA, KIND_PROSE = 0, 1, 2
kinds = [KIND_SKIP] * len(lines)
paragraph_of = [-1] * len(lines)
paragraphs = []
fence_marker = None
fence_length = 0
fence_decided = None
current = -1
for index, line in enumerate(lines):
opener = FENCE_RE.match(line)
if fence_marker is None:
if opener is not None:
fence_marker = opener.group(1)[0]
fence_length = len(opener.group(1))
fence_info = opener.group(2).split(",")[0].strip().lower()
# A shell fence is exempt on its info string alone; an
# info-less fence waits for its first content line.
fence_decided = True if fence_info in SHELL_INFO else (
None if fence_info == "" else False
)
current = -1
continue
else:
# CommonMark closes a fence only with the SAME character, a run at
# least as long as the opener, and nothing but whitespace after it.
# Matching the first character alone let a shorter run, a different
# character, or an info-bearing line close a block, so the scanner's
# idea of "inside a fence" drifted from the renderer's.
if (
opener is not None
and opener.group(1)[0] == fence_marker
and len(opener.group(1)) >= fence_length
and not opener.group(2).strip()
and not line.strip().strip(fence_marker)
):
fence_marker = None
fence_length = 0
fence_decided = None
continue
if fence_decided is None:
fence_decided = command_fence(line)
if not fence_decided:
kinds[index] = KIND_DATA
continue
if not line.strip():
current = -1
continue
if current == -1:
paragraphs.append("")
current = len(paragraphs) - 1
paragraphs[current] += line.lower()
kinds[index] = KIND_PROSE
paragraph_of[index] = current
paragraph_claims = [
any(word in text for word in CLAIM_WORDS) for text in paragraphs
]
# PASS 2: resolve the candidates each line carries.
for lineno, line in enumerate(lines, start=1):
kind = kinds[lineno - 1]
if kind == KIND_SKIP:
continue
# A data block is a record of what IS, so every path it names is a
# claim; in prose the claim is the paragraph the path sits in.
claims = kind == KIND_DATA or paragraph_claims[paragraph_of[lineno - 1]]
raw_tokens = list(BACKTICK_RE.findall(line))
if kind == KIND_DATA:
raw_tokens += QUOTED_RE.findall(line)
for raw_tok in raw_tokens:
# A path at the end of a sentence carries the sentence's
# punctuation inside the span; strip it before resolving.
tok = raw_tok.strip().rstrip(TRAILING_PUNCTUATION)
if not is_candidate(tok):
continue
abs_path = os.path.join(repo_root, tok)
exists = os.path.exists(abs_path) or os.path.islink(abs_path)
if exists:
if not is_tracked(tok):
offenders.append(f"{rel_display}:{lineno}: {tok} untracked")
elif claims:
offenders.append(f"{rel_display}:{lineno}: {tok} missing")
for offender in offenders:
print(offender)
sys.exit(1 if offenders else 0)
PY
)"
rc=$?
set -e
if [ "$rc" -eq 2 ]; then
printf '%s\n' "$output" >&2
printf '[%s] FAIL: could not scan one or more files — see above\n' "$PROG" >&2
exit 2
fi
if [ "$rc" -eq 1 ]; then
printf '[%s] FAIL: doc claims outrun the tree:\n' "$PROG" >&2
printf '%s\n' "$output" >&2
exit 1
fi
printf '[%s] OK: %s Markdown file(s) under evals/ and docs/evals/ make no untracked or missing path claims\n' "$PROG" "$scanned"
exit 0