diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 8d54ce5..e95497a 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,36 @@ +## instructions_gate.py — check 7 (register citations) and content WARNings on the index (2026-08-06) + +**Check 7: a citation that calls a register item OPEN must be right.** Four instruction files said CI +was still owed, citing R-168, four days after it closed; one contradicted itself. A trim is a VOLUME +operation and does not validate content — this class, at least, is mechanical. + +Three design decisions, each earned during the build: + +- **The trigger is an OPENNESS CLAIM, not any citation.** Requiring every mention of a non-open item + to carry "CLOSED" would fire on ~30 legitimate provenance citations (`(R-161)`, `R-117 spike §6.3`) + that cite an item as the source of a fact. A gate that noisy gets switched off — R-29's own lesson. + **This is a deliberate deviation from the task's literal wording**, taken to keep the gate alive. +- **The state marker is not self-closing.** Real rows read `**SHIPPED — and the alarm is + DEMONSTRATED**`. The first parser required `**SHIPPED**` and therefore read **R-168 — the row this + check exists for — as OPEN**. A gate that cannot convict its own founding case is decoration. +- **The exemption is scoped to the citation's CLAUSE.** A line-wide test pardoned `OPEN R-25b` + because "shipped" appeared earlier in the same line, in a title and a filename. Found only because + the red-proof failed to go red. + +**Check 6 gains content WARNings on `MEMORY.md`** — version literals, host addresses, expired +statements, and stale-open citations — all WARN, never FAIL. Claude writes that file between +sessions, so a hard failure would refuse a human's push over a line no human typed; the warning is +read by the same model that will next edit the file, which makes the loop self-correcting. + +**The expired-statement class carries the sharpest lesson.** A scan on 2026-08-06 reported three +expired statements in the index and **every one was false** — each matched the ISO date inside a +markdown link TARGET, i.e. a filename — while **missing the one real expired claim** in the same +file, whose deadline was written `~08-02` and contained no ISO date at all. Link targets are now +stripped before any content judgement, and the deadline pattern matches the bare `MM-DD` form. + +First run over the real index: 32 version literals, 4 host addresses, 0 expired, 0 stale citations. +Suite 39 -> 60 assertions. Red-proofs against real files both ways, quoted in the ledger. + ## install_workspace.py + rules_report.py — the workspace survives the machine (2026-08-06, R-229) The workspace-root `CLAUDE.md` and the `InstructionsLoaded` hook shape every session and existed on diff --git a/scripts/instructions_gate.py b/scripts/instructions_gate.py index 02cd747..93f653e 100644 --- a/scripts/instructions_gate.py +++ b/scripts/instructions_gate.py @@ -40,7 +40,26 @@ CHECKS (each names the file and the reason; a missing input is a FAILURE, never a directory that is not a git repo, so the copy is the only version-controlled record of it; nothing but this check enforces that they agree. 6. The auto-memory index (/.claude-memory/MEMORY.md) is within its line and byte - limits, and every top-level topic file is referenced by it. + limits, every top-level topic file is referenced by it, and its CONTENT is warned about + (version literals, host addresses, expired statements, stale-open register citations). + 7. Every R-nnn cited in a CLAUDE.md or a .claude/rules/*.md that is CALLED OPEN really is open. + +WHY 7 EXISTS (2026-08-06). Four instruction files asserted that CI was still owed, citing R-168, +four days after it closed — and one of the four contradicted itself, its release section already +saying R-168 mails the failure. A trim is a VOLUME operation: it does not validate content, and the +sentence survived because it read as settled. This class, at least, is mechanical. + + - THE TRIGGER IS AN OPENNESS CLAIM, NOT ANY CITATION. Requiring every mention of a non-open item + to carry "CLOSED" would fire on ~30 legitimate provenance citations — "(R-161)", "R-117 spike + §6.3" — which cite an item as the SOURCE OF A FACT, not as outstanding work. A gate that noisy + is switched off, which is the fate R-29 documented. + - THE EXEMPTION IS SCOPED TO THE CITATION'S CLAUSE. A line-wide test pardoned a stale claim + because the words "shipped" appeared elsewhere on the same line, in a title and a filename. + - A CITATION OF AN ITEM IN NEITHER REGISTER FAILS. A reference to nothing cannot be checked by + anyone, which is worse than a stale one. + - THE STATE MARKER IS NOT SELF-CLOSING. Real rows read `**SHIPPED — and the alarm is + DEMONSTRATED**`. An earlier parser required `**SHIPPED**` and therefore read R-168 — the row + this check exists for — as open. A gate that cannot convict its own founding case is decoration. WHY 6 EXISTS, AND WHY ITS THREE OUTCOMES DIFFER (2026-08-06). @@ -276,6 +295,52 @@ def check_memory(workspace_root, failures, warnings, tally): % (index, nbytes, MAX_MEMORY_BYTES) ) + # --- content classes, as WARNINGS (see memory_content_warnings for why not failures) --- + cw = memory_content_warnings(text, today_tuple()) + tally.append( + " memory index content : %d version literal(s), %d host address(es), %d expired " + "statement(s) [WARN only]" + % (len(cw["versions"]), len(cw["addresses"]), len(cw["expired"])) + ) + # Fourth class, beyond the three the CLAUDE.md files are failed on: a stale-open register + # citation. Added because TWO of the three false statements found in this file on 2026-08-06 + # were exactly this ("R-193 decision open", "OPEN R-25b" — both closed), and both were found by + # hand. Check 7 polices the hand-written files for it; nothing policed this one. + reg = register_state(workspace_root) + if reg: + stale_cites = [] + for lineno, raw in enumerate(text.split("\n"), 1): + for item in sorted(set(CITE_RE.findall(raw))): + cl = clause_around(raw, item) + if not (OPENNESS_RE.search(cl) and not SAYS_CLOSED_RE.search(cl)): + continue + if reg.get(item, "missing") != "open": + stale_cites.append((lineno, item, raw.strip()[:90])) + if stale_cites: + tally.append(" memory stale citations : %d [WARN only]" % len(stale_cites)) + for lineno, item, ctx in stale_cites[:6]: + warnings.append( + "%s:%d: calls %s open, but the register does not.\n %s" + % (index, lineno, item, ctx) + ) + + for kind, label, why in ( + ("expired", "EXPIRED statement", "it is read as current fact"), + ("versions", "version literal", "the fleet is not uniform, so it is stale within a day"), + ("addresses", "host address", "operations/nodes.md is the single home for these"), + ): + for lineno, hit, ctx in cw[kind][:6]: + warnings.append( + "%s:%d: %s %r — %s. Not a failure: Claude writes this file between sessions, so " + "this warning is aimed at the model that will next edit it, not at whoever is " + "pushing.\n %s" % (index, lineno, label, hit, why, ctx) + ) + if len(cw[kind]) > 6: + warnings.append( + "%s: ... and %d more %s(s) — full list from the tally counts above." + % (index, len(cw[kind]) - 6, label) + ) + referenced = memory_referenced(text) on_disk = set( n for n in os.listdir(store) @@ -307,6 +372,181 @@ def check_memory(workspace_root, failures, warnings, tally): ) +# A date inside a markdown link TARGET is a filename, not a claim: `[x](vacation-...-2026-07-20.md)`. +# Matching those produced THREE false "expired statement" findings on 2026-08-06 — every one of the +# three reported that day — while missing the one real expired claim in the same file, whose deadline +# was written `~08-02` and carried no ISO date at all. A scan that reports the wrong three and misses +# the right one is worse than no scan. +LINK_TARGET_RE = re.compile(r"\]\([^)]*\)") +# Deadline language, including the bare MM-DD form the real one used. +DEADLINE_RE = re.compile( + r"\b(till|until|by)\s+~?\s*(\d{4}-\d{2}-\d{2}|\d{2}-\d{2})\b", re.I +) +LOOPBACK = ("127.0.0.1", "0.0.0.0", "255.255.255.255") +IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") + + +def memory_content_warnings(text, today): + """The three content classes the CLAUDE.md files are FAILED on, applied to the index as WARNINGS. + + WARN and not FAIL, deliberately: Claude writes this file between sessions, so a hard failure + would refuse a human's push over a line no human typed. The warning IS the mechanism — it is read + by the same model that writes the file, which makes the loop self-correcting rather than a chore + for a person. + """ + out = {"versions": [], "addresses": [], "expired": []} + for i, raw in enumerate(text.split("\n"), 1): + # strip link targets before ANY content judgement — a filename is not an assertion + line = LINK_TARGET_RE.sub("]()", raw) + + for m in VERSION_RE.finditer(line): + out["versions"].append((i, m.group(), raw.strip()[:90])) + for m in IPV4_RE.finditer(line): + if m.group() not in LOOPBACK: + out["addresses"].append((i, m.group(), raw.strip()[:90])) + for m in DEADLINE_RE.finditer(line): + token = m.group(2) + if len(token) == 5: # MM-DD — assume the current year, which is how it was written + when = (today[0], int(token[:2]), int(token[3:])) + else: + when = tuple(int(x) for x in token.split("-")) + if when < today: + out["expired"].append((i, m.group(0), raw.strip()[:90])) + return out + + +def register_state(workspace_root): + """Map R-nnn -> 'open' | 'closed' | 'historical' | 'missing'. + + OPEN-ITEMS.md is the authority on what is OPEN and says so in its own header. An item is open + when it OWNS A ROW there whose State cell is not a closed marker. An item with no row of its own + is not open work — it may still be mentioned inside another row, and it is usually in ROADMAP.md, + which keeps the full history. Anything in neither file is a reference to nothing. + """ + base = os.path.join(workspace_root, "felhom.eu", "documentation", "backlog") + reg = os.path.join(base, "OPEN-ITEMS.md") + hist = os.path.join(base, "ROADMAP.md") + if not os.path.exists(reg): + return None + + with io_open(reg) as fh: + reg_text = fh.read() + hist_text = "" + if os.path.exists(hist): + with io_open(hist) as fh: + hist_text = fh.read() + + state = {} + for line in reg_text.split("\n"): + m = re.match(r"\|\s*\*\*(R-\d+[a-z]?)\*\*\s*\|", line) + if not m: + continue + cells = [c.strip() for c in line.rstrip().strip("|").split("|")] + # The State column is index 2 in BOTH table shapes here (`ID|What|State` and + # `ID|What|State|Blocked on|Next action|Owner`). Scan from there rather than taking the + # last cell: a row whose prose contains a `|` splits into extra cells and would otherwise + # be read from the wrong one. + # + # THE MARKER IS NOT SELF-CLOSING. Real rows read `**SHIPPED — and the alarm is + # DEMONSTRATED**`, not `**SHIPPED**`. An earlier version of this parser required the + # closing `**` immediately after the word and therefore read R-168 — the row this whole + # check exists for — as OPEN. A gate that cannot convict its own founding case is decoration. + state[m.group(1)] = "open" + for cell in cells[2:]: + if not cell.startswith("**"): + continue + head = cell[: cell.find("**", 2) + 2] if cell.find("**", 2) > 0 else cell + if re.search(r"\b(CLOSED|SHIPPED|DONE|COMPLETE)\b", head) and not re.search( + r"\bopen\b", head, re.I + ): + state[m.group(1)] = "closed" + break + + mentioned = set(re.findall(r"\bR-\d+[a-z]?\b", reg_text)) | set( + re.findall(r"\bR-\d+[a-z]?\b", hist_text) + ) + for item in mentioned: + state.setdefault(item, "historical") + return state + + +# Language that CLAIMS an item is still outstanding. The check fires on these, not on every +# citation — see check 7's rationale in the module docstring. +OPENNESS_RE = re.compile( + r"\b(still owed|is owed|are owed|still open|stays open|remains open|still pending|" + r"pending\b|still needed|not yet|awaiting|awaits|outstanding|unresolved|to be done|" + r"OPEN\s+R-\d)", re.I +) +SAYS_CLOSED_RE = re.compile(r"\b(CLOSED|DONE|SHIPPED|RESOLVED|COMPLETE)\b", re.I) +CITE_RE = re.compile(r"\bR-\d+[a-z]?\b") +# Clause boundaries. The exemption is scoped to the clause holding the citation, NOT the whole line: +# `- [Customer RESET shipped](…) — …; OPEN R-25b` contains "shipped" twice, in a title and a +# filename, and a line-wide exemption pardoned the stale claim at the end of it. Found by this +# check's own red-proof, which failed to go red. +CLAUSE_SPLIT_RE = re.compile(r"[;.]\s+|\s+—\s+|\s+\|\s+") + + +def clause_around(line, item): + """The clause of `line` containing `item` — what "the same sentence" means for the exemption.""" + for part in CLAUSE_SPLIT_RE.split(line): + if re.search(r"\b%s\b" % re.escape(item), part): + return part + return line + + +def check_citations(root, workspace_root, failures, warnings, tally): + """Check 7 — a citation that calls a register item OPEN must be right. + + Scope: /CLAUDE.md and /.claude/rules/*.md. Effective text only, so a citation + parked in an HTML comment for history is not policed. + """ + state = register_state(workspace_root) + if state is None: + tally.append(" register citations : n/a (no OPEN-ITEMS.md — not this workspace)") + return + + targets = [] + claude_md = os.path.join(root, "CLAUDE.md") + if os.path.exists(claude_md): + targets.append(claude_md) + rules_dir = os.path.join(root, ".claude", "rules") + if os.path.isdir(rules_dir): + targets += [ + os.path.join(rules_dir, n) for n in sorted(os.listdir(rules_dir)) if n.endswith(".md") + ] + + cited = 0 + for path in targets: + with io_open(path) as fh: + eff = effective(fh.read()) + for lineno, line in enumerate(eff.split("\n"), 1): + items = CITE_RE.findall(line) + if not items: + continue + cited += len(items) + for item in sorted(set(items)): + cl = clause_around(line, item) + claims_open = bool(OPENNESS_RE.search(cl)) and not SAYS_CLOSED_RE.search(cl) + st = state.get(item, "missing") + if st == "missing": + failures.append( + "%s:%d: cites %s, which appears in NEITHER OPEN-ITEMS.md nor ROADMAP.md. " + "A reference to nothing is worse than a stale one — it cannot be checked " + "by anyone.\n %s" % (path, lineno, item, line.strip()[:110]) + ) + elif claims_open and st != "open": + failures.append( + "%s:%d: claims %s is still open, but the register says it is %s. Four " + "instruction files asserted CI was still owed (R-168) four days after it " + "closed, and one contradicted itself about it — a trim is a volume " + "operation and does not validate content. Either drop the claim or say " + "CLOSED in the same sentence.\n %s" + % (path, lineno, item, "not an open row" if st == "historical" else st, + line.strip()[:110]) + ) + tally.append(" register citations : %d cited, %d register items known" % (cited, len(state))) + + def io_open(path): return open(path, "r", encoding="utf-8") @@ -338,6 +578,7 @@ def main(argv): check_rules(root, failures, tally) check_workspace_copy(os.path.dirname(root), failures, tally) check_memory(os.path.dirname(root), failures, warnings, tally) + check_citations(root, os.path.dirname(root), failures, warnings, tally) for line in tally: print(line) diff --git a/scripts/test_instructions_gate.py b/scripts/test_instructions_gate.py index f4bbc2c..9f79354 100644 --- a/scripts/test_instructions_gate.py +++ b/scripts/test_instructions_gate.py @@ -295,6 +295,165 @@ def test_memory_store_without_index_fails(): check("message says unreachable knowledge", "unreachable knowledge" in out) +# --- check 7: register citations ---------------------------------------------------------------- +# +# The founding case is "CI is still owed (R-168)" shipped four days after R-168 closed, in four +# files, one of which contradicted itself. The trigger is an OPENNESS CLAIM, not any citation: +# requiring every mention of a non-open item to carry "CLOSED" would fire on ~30 legitimate +# provenance citations like "(R-161)" or "R-117 spike §6.3", and a gate that noisy gets switched +# off — which is the fate R-29 documented. + + +def make_register(tmp, rows): + """rows: list of (item, what, state-cell). Mirrors the real 6-column table shape.""" + d = os.path.join(tmp, "felhom.eu", "documentation", "backlog") + os.makedirs(d, exist_ok=True) + body = ["| ID | What | State | Blocked on | Next action | Owner |", "|---|---|---|---|---|---|"] + for item, what, st in rows: + body.append("| **%s** | %s | %s | — | — | operator |" % (item, what, st)) + with open(os.path.join(d, "OPEN-ITEMS.md"), "w", encoding="utf-8") as fh: + fh.write("\n".join(body) + "\n") + with open(os.path.join(d, "ROADMAP.md"), "w", encoding="utf-8") as fh: + fh.write("| R-900 | a historical item, closed long ago and pruned from OPEN-ITEMS |\n") + + +REG_ROWS = [ + ("R-168", "~~CI: no runner exists~~", "**SHIPPED — and the alarm is DEMONSTRATED** (2026-08-02)"), + ("R-161", "volume-persistence gate", "**REDUCED SCOPE — open** (operator ruling)"), +] + + +def test_citation_claiming_open_about_a_closed_item_fails(): + """The founding case, verbatim.""" + with tempfile.TemporaryDirectory() as tmp: + make_register(tmp, REG_ROWS) + root = make_repo(tmp, "# Repo\n\nBoth facts are why CI is still owed (`OPEN-ITEMS.md` R-168).\n") + rc, out = run_gate(root) + check("stale-open citation exits non-zero", rc != 0, "rc=%d" % rc) + check("message names the item", "R-168" in out) + check("message says the register disagrees", "register says it is closed" in out) + + +def test_citation_that_says_closed_passes(): + """Explicitly required by the spec: a correct citation must survive.""" + with tempfile.TemporaryDirectory() as tmp: + make_register(tmp, REG_ROWS) + root = make_repo( + tmp, "# Repo\n\na bypass is noticed even though it is not blocked (R-168, CLOSED 2026-08-02).\n" + ) + rc, out = run_gate(root) + check("citation saying CLOSED exits 0", rc == 0, "rc=%d" % rc) + + +def test_bare_provenance_citation_of_a_closed_item_passes(): + """Load-bearing negative: citing a closed item as the SOURCE OF A FACT is not a claim it is open.""" + with tempfile.TemporaryDirectory() as tmp: + make_register(tmp, REG_ROWS) + root = make_repo(tmp, "# Repo\n\nThe canonical runner shape (R-168) is what survives.\n") + rc, out = run_gate(root) + check("provenance citation of a closed item exits 0", rc == 0, "rc=%d" % rc) + + +def test_openness_claim_about_an_actually_open_item_passes(): + with tempfile.TemporaryDirectory() as tmp: + make_register(tmp, REG_ROWS) + root = make_repo(tmp, "# Repo\n\n**R-161 stays open at reduced scope** — enforcement is by convention.\n") + rc, out = run_gate(root) + check("openness claim about an OPEN item exits 0", rc == 0, "rc=%d" % rc) + + +def test_closed_word_elsewhere_on_the_line_does_not_pardon_a_stale_claim(): + """Regression. The exemption is scoped to the citation's CLAUSE, not the whole line. + + `- [Customer RESET shipped](customer-reset-shipped-2026-07-17.md) — …; OPEN R-25b` contains + "shipped" twice — once in a title, once in a filename — and a line-wide exemption pardoned the + stale claim sitting at the end of it. Found by this check's own red-proof failing to go red, + which is the only reason it was found at all.""" + with tempfile.TemporaryDirectory() as tmp: + make_register(tmp, REG_ROWS) + root = make_repo( + tmp, + "# Repo\n\n- [Customer RESET shipped](customer-reset-shipped-2026-07-17.md) — " + "teardown first; OPEN R-168\n", + ) + rc, out = run_gate(root) + check("a distant 'shipped' does not pardon the claim", rc != 0, "rc=%d" % rc) + check("the stale item is still named", "R-168" in out) + + +def test_citation_of_an_item_in_no_register_fails(): + with tempfile.TemporaryDirectory() as tmp: + make_register(tmp, REG_ROWS) + root = make_repo(tmp, "# Repo\n\nSee R-4242 for the rationale.\n") + rc, out = run_gate(root) + check("citation of a nonexistent item exits non-zero", rc != 0, "rc=%d" % rc) + check("message says it is in neither register", "NEITHER" in out) + + +def test_citation_in_an_html_comment_is_not_policed(): + """Comments are stripped before injection, so a historical citation parked there costs nothing.""" + with tempfile.TemporaryDirectory() as tmp: + make_register(tmp, REG_ROWS) + root = make_repo(tmp, "# Repo\n\n\n") + rc, out = run_gate(root) + check("citation inside a comment is not policed", rc == 0, "rc=%d" % rc) + + +def test_citation_check_covers_rule_files_too(): + with tempfile.TemporaryDirectory() as tmp: + make_register(tmp, REG_ROWS) + root = make_repo( + tmp, "# Repo\n", + rules={"g.md": '---\npaths: ["**/*.go"]\n---\n\nCI is still owed (R-168).\n'}, + ) + rc, out = run_gate(root) + check("a rule file's stale citation also fails", rc != 0, "rc=%d" % rc) + check("message names the rule file", "g.md" in out) + + +# --- check 6: content WARNings on the memory index ------------------------------------------------ + + +def test_memory_expired_statement_warns_but_does_not_fail(): + with tempfile.TemporaryDirectory() as tmp: + make_memory(tmp, "# Index\n- [a](a.md) — demo boxes REMOTE till ~08-02\n", topics={"a.md": "# A\n"}) + root = make_repo(tmp, "# Repo\n") + rc, out = run_gate(root) + check("expired statement in the index does NOT fail", rc == 0, "rc=%d" % rc) + check("expired statement WARNs", "EXPIRED statement" in out) + + +def test_memory_date_inside_a_link_target_is_not_an_expired_statement(): + """THE load-bearing negative. On 2026-08-06 a scan matched the ISO date in the link TARGET and + reported three expired statements, every one of them false, while missing the one real expired + claim in the same file — whose deadline was written `~08-02` with no ISO date at all.""" + with tempfile.TemporaryDirectory() as tmp: + make_memory(tmp, "# Index\n- [Customer RESET](customer-reset-shipped-2026-07-17.md) — teardown first\n", + topics={"customer-reset-shipped-2026-07-17.md": "# X\n"}) + root = make_repo(tmp, "# Repo\n") + rc, out = run_gate(root) + check("a dated FILENAME is not an expired statement", rc == 0 and "EXPIRED" not in out, "rc=%d" % rc) + + +def test_memory_future_deadline_does_not_warn(): + with tempfile.TemporaryDirectory() as tmp: + make_memory(tmp, "# Index\n- [a](a.md) — rig stays up until 2026-12-31\n", topics={"a.md": "# A\n"}) + root = make_repo(tmp, "# Repo\n") + rc, out = run_gate(root) + check("a future deadline does not warn", "EXPIRED" not in out) + + +def test_memory_version_literal_and_address_warn_but_do_not_fail(): + with tempfile.TemporaryDirectory() as tmp: + make_memory(tmp, "# Index\n- [a](a.md) — ctrl 0.162.0 on 192.168.0.180\n", topics={"a.md": "# A\n"}) + root = make_repo(tmp, "# Repo\n") + rc, out = run_gate(root) + check("version literal in the index does NOT fail", rc == 0, "rc=%d" % rc) + check("version literal WARNs", "version literal" in out) + check("host address WARNs", "host address" in out) + check("loopback is not reported", "127.0.0.1" not in out) + + def main(): print("test_instructions_gate") for fn in sorted(