← Files APODICTIC Development EditorARCHIVED FILE

scripts/retcon_plan.py

29.4 KB · Nov 3, 2026 · 18:57 UTC

↓ Download file

#!/usr/bin/env python3
"""retcon-plan — structural integrity for the Retcon Planning coaching track (Coaching Deepening).

`validate.sh retcon-plan <run_folder> [--strict]` (or explicit files) shells out here. A returning
author planning a retroactive-continuity revision records each commitment as a structured
`apodictic.retcon_item.v1` block in a Retcon Plan artifact. Two axes are kept orthogonal:
`mutability` (the commitment budget — locked=observed canon the reader has used; costly=exposed
consequences; free=unused latent) and `retcon_type` (the fair-play axis — dramatic=recontextualize
for meaning; evidential=change the evidence the reader reasoned from). This validator owns the
retcon-planning contract — and mechanizes the two disciplines the method insists on.

  R1 invalid item     a retcon_item block fails its schema (bad enum / id / missing field / JSON).
  R2 duplicate id     two items share an RX-NN id.
  R3 evidential lock  retcon_type=evidential AND mutability=locked — changing a clue the reader has
                      already reasoned from (fair-play violation). The signature check.
                      Override: <!-- override: retcon-evidential RX-NN — <rationale> --> (per id).
  R4 dangling target  an item's target_id is not declared in the '## Retcon Targets' list.
  W1 blast unaccounted a locked/costly item with an empty blast_radius — a costly retcon planned
                      without naming what it endangers (advisory; ERROR under --strict).
  W2 firewall drift   intervention_class/disposition reads like invented prose, not a class —
                      the Firewall line (advisory; ERROR --strict). Override: retcon-firewall RX-NN.

Door-B Selection step (F1) — ranked candidate latent readings as apodictic.retcon_reading.v1 blocks:
  R5 invalid reading  a retcon_reading block fails its schema, or `scores` is missing one of the five
                      named 1-5 dimensions / carries an out-of-range value (range enforced in code,
                      since the subset schema checker can't express nested required keys or bounds).
  R6 duplicate id     two readings share a CR-NN id.
  R7 dangling target  a reading's implied_targets names a target not declared in '## Retcon Targets'.
  W3 uncosted reading a surfaced reading carries no coincidence_note — the non-insane-coincidence-rate
                      over-fitting guard (advisory; ERROR --strict). The signature F1 check.
  W4 unpruned shortlist  more than 3 candidate readings (the Selection step returns the top 1-3).

F3 (Pass-8 auto-seeding): a retcon_item may carry an optional `source` finding-ref (F-<ORIGIN>-<NN>,
primarily a Pass-8 Reveal-Economy finding) recording what it was seeded from. Its format is checked
here (R1, via the schema), shown in the item line, and resolved against the ledger by finding-trace
(E6 dangling retcon source) — the cross-artifact provenance check.

Reuses apodictic_artifacts (one block grammar + the schema engine). Each artifact is optional; an
empty/absent one is a no-op. See docs/retcon-planning.md.

  retcon_plan.py retcon-plan <run_folder|files...> [--strict]
  retcon_plan.py --self-test

Exit: 0 clean / WARN-only, 1 ERROR (or WARN under --strict), 2 usage.
"""
import glob
import os
import re
import sys

from override_marker import override_targets  # SSoT: code-span-stripped, boundary-matched override scan

try:
    import apodictic_artifacts as art
except ImportError:
    art = None


def _has_block(text, btype):
    """True if `text` carries a real apodictic:<btype> block (a parsed carrier, not a prose mention).

    Classifying on parsed blocks — not a raw substring — keeps a file that merely *names* the marker
    in prose from being misrouted/skipped (the 2026-06-20 resolver-hardening sweep). Gated by
    validate.sh validator-conventions (M2)."""
    if art is None:
        return ("apodictic:%s" % btype) in (text or "")
    return any(bt == btype for bt, _o, _e in art.parse_blocks(text or ""))

_SCHEMA_ID = "apodictic.retcon_item.v1"
_READING_SCHEMA_ID = "apodictic.retcon_reading.v1"
_PLAN_GLOB = "*_Retcon_Plan_*.md"
_MUTABLE_COSTLY = ("locked", "costly")
# The Door-B Selection rubric (F1): five named dimensions, integer 1-5, higher is better.
# coincidence_resistance: 5 = no forced coincidences; 1 = rubber-reality over-fit (the essay's
# failure mode). The subset schema checker can't express nested required keys or numeric bounds,
# so dimension completeness + range live here in code (part of R5), as the R3 gate does.
_SCORE_DIMS = ("canon_coherence", "payoff_density", "agency_preservation",
               "genre_fit", "coincidence_resistance")
_SCORE_MIN, _SCORE_MAX = 1, 5

# A target declared as a list item in the Retcon Targets section: "- T1: ..." / "- **T1** ...".
_TARGET_DECL_RE = re.compile(r"^\s*(?:[-*]|[0-9]+\.)\s+\*{0,2}(T[0-9]+)\b", re.MULTILINE)
# Override markers naming an item id ("<!-- override: <slug> RX-01 — ... -->") route through the
# shared override_marker SSoT — code spans stripped, slug boundary-matched.
# W2 firewall-drift heuristics: a long quoted span (likely invented prose), or a directive to
# write specific content rather than name a class.
_INVENTED_QUOTE_RE = re.compile(r"[\"“][^\"”]{25,}[\"”]|['‘][^'’]{25,}['’]")
# Verb must directly govern the object ("write the line", "draft a scene") so the manuscript-
# revision noun "the draft" / "the draft's opening scene" does not false-trigger.
_GHOSTWRITE_RE = re.compile(
    r"\b(?:write|draft|ghost-?write|pen)\s+(?:the|a|an|some|her|his|their)\s+"
    r"(?:line|sentence|paragraph|dialogue|prose|passage|scene)\b",
    re.IGNORECASE)
# Content-invention by specifying what happens in a NEW unit: "add/insert a scene where <events>".
# The "where <events>" clause is the tell (it names the content); a content-ADDING verb is required,
# so "remove/cut the scene where …" (legitimate classes) do not match.
_INVENTED_SCENE_RE = re.compile(
    r"\b(?:add|create|insert|write|draft|put\s+in)\s+(?:a|an|the|another|some)\s+"
    r"(?:scene|beat|moment|flashback|line|exchange|chapter|passage|paragraph)\s+where\b",
    re.IGNORECASE)


def _read(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return fh.read()
    except (OSError, UnicodeDecodeError):
        return None


def _section(text, heading):
    """The substring under the first '## <heading>' up to the next level-2 heading; '' if absent."""
    rx = re.compile(r"^##\s+.*" + re.escape(heading), re.IGNORECASE | re.MULTILINE)
    m = rx.search(text)
    if not m:
        return ""
    rest = text[m.end():]
    nxt = re.search(r"^##\s", rest, re.MULTILINE)
    return rest[:nxt.start()] if nxt else rest


def declared_targets(text):
    """Set of target ids (T1, T2, …) declared as list items under the Retcon Targets section.
    Falls back to the whole doc if no such section heading is present."""
    scope = _section(text, "Retcon Targets") or text
    return set(_TARGET_DECL_RE.findall(scope))


def _overrides(text, slug):
    """The set of RX-NN ids overridden for `slug` — via the shared SSoT, so a marker quoted inside a
    code span is not honored as a live directive."""
    return {t[0] for t in override_targets(text, slug, r"(RX-[0-9]+)")}


def parse_items(text):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:retcon_item block."""
    items = []
    if not text or art is None:
        return items
    schema = art.load_schema(_SCHEMA_ID)
    idx = 0
    for btype, obj, jerr in art.parse_blocks(text):
        if btype != "retcon_item":
            continue
        idx += 1
        where = "retcon_item #%d" % idx
        if jerr:
            items.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
            continue
        errs = art.validate_obj(obj, schema, where)
        items.append((obj, errs, idx))
    return items


def _score_errors(obj, where):
    """The five-dimension completeness + 1-5 range checks the subset schema checker cannot express
    (nested required keys, numeric bounds). Part of R5. Returns a list of error strings."""
    errs = []
    if not isinstance(obj, dict):
        return errs  # a non-dict block is already reported by validate_obj ('expected a JSON object')
    scores = obj.get("scores")
    if not isinstance(scores, dict):
        return errs  # a non-object `scores` is already reported by the schema's type:object check
    for dim in _SCORE_DIMS:
        if dim not in scores:
            errs.append("%s: scores missing '%s' (need all of: %s)"
                        % (where, dim, ", ".join(_SCORE_DIMS)))
            continue
        v = scores[dim]
        if not isinstance(v, int) or isinstance(v, bool):
            errs.append("%s: scores.%s=%r must be an integer %d-%d"
                        % (where, dim, v, _SCORE_MIN, _SCORE_MAX))
        elif not (_SCORE_MIN <= v <= _SCORE_MAX):
            errs.append("%s: scores.%s=%d out of range %d-%d"
                        % (where, dim, v, _SCORE_MIN, _SCORE_MAX))
    return errs


def parse_readings(text):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:retcon_reading block.
    Door-B Selection step (F1): one scored candidate latent reading per block."""
    readings = []
    if not text or art is None:
        return readings
    schema = art.load_schema(_READING_SCHEMA_ID)
    idx = 0
    for btype, obj, jerr in art.parse_blocks(text):
        if btype != "retcon_reading":
            continue
        idx += 1
        where = "retcon_reading #%d" % idx
        if jerr:
            readings.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
            continue
        errs = art.validate_obj(obj, schema, where) + _score_errors(obj, where)
        readings.append((obj, errs, idx))
    return readings


def _score_total(obj):
    """Sum of the five Selection dimensions (max 25). Higher = a stronger reading."""
    s = obj.get("scores") or {}
    return sum(s[d] for d in _SCORE_DIMS
               if isinstance(s.get(d), int) and not isinstance(s.get(d), bool))


def plan(text, strict=False):
    """Run the Retcon Plan integrity checks. Returns (code, lines)."""
    lines, errs, warns = [], [], []
    items = parse_items(text)
    readings = parse_readings(text)
    if not items and not readings:
        return 0, ["retcon-plan: no retcon_item or retcon_reading blocks found — nothing to check"]

    # R1 — schema/JSON validity (per block)
    for _obj, schema_errs, _idx in items:
        for e in schema_errs:
            errs.append("R1 invalid item: %s" % e)

    valid = [(obj, idx) for obj, schema_errs, idx in items if obj is not None and not schema_errs]

    # R2 — duplicate id
    seen = {}
    for obj, idx in valid:
        seen.setdefault(obj.get("id"), []).append(idx)
    by_id = {}
    for rid, where in sorted(seen.items()):
        if len(where) > 1:
            errs.append("R2 duplicate id: %s appears %d times (ids must be unique)" % (rid, len(where)))
        by_id[rid] = next(o for o, _ in valid if o.get("id") == rid)

    targets = declared_targets(text)
    ev_overrides = _overrides(text, "retcon-evidential")
    fw_overrides = _overrides(text, "retcon-firewall")

    for rid, obj in sorted(by_id.items()):
        # R3 — evidential retcon of locked canon (fair play)
        if obj.get("retcon_type") == "evidential" and obj.get("mutability") == "locked":
            if rid in ev_overrides:
                warns.append("R3 evidential-lock (override): %s changes evidence in locked canon "
                             "(override marker present)" % rid)
            else:
                errs.append("R3 evidential-lock: %s is evidential AND locked — changing a clue the "
                            "reader has already reasoned from (fair-play violation). Recontextualize "
                            "for meaning (dramatic), or override: "
                            "<!-- override: retcon-evidential %s — <rationale> -->" % (rid, rid))
        # R4 — dangling target
        tid = obj.get("target_id")
        if tid not in targets:
            errs.append("R4 dangling target: %s.target_id=%s not declared in '## Retcon Targets'" % (rid, tid))
        # W1 — unaccounted blast radius on a locked/costly item
        if obj.get("mutability") in _MUTABLE_COSTLY and not (obj.get("blast_radius") or []):
            warns.append("W1 blast unaccounted: %s is %s but names no blast_radius — what does this "
                         "retcon endanger?" % (rid, obj.get("mutability")))
        # W2 — firewall drift (invented prose where a class belongs)
        blob = "%s %s" % (obj.get("intervention_class") or "", obj.get("disposition") or "")
        if ((_INVENTED_QUOTE_RE.search(blob) or _GHOSTWRITE_RE.search(blob)
             or _INVENTED_SCENE_RE.search(blob)) and rid not in fw_overrides):
            warns.append("W2 firewall drift: %s reads like invented prose, not an intervention "
                         "class — plan the class; the author writes the tissue" % rid)

    # ---- Door-B Selection step (F1): ranked candidate readings ----
    # R5 — schema / JSON / score-rubric validity (per reading)
    for _obj, reading_errs, _idx in readings:
        for e in reading_errs:
            errs.append("R5 invalid reading: %s" % e)

    valid_readings = [(o, i) for o, rerrs, i in readings if o is not None and not rerrs]

    # R6 — duplicate reading id
    seen_r = {}
    for obj, idx in valid_readings:
        seen_r.setdefault(obj.get("id"), []).append(idx)
    reading_by_id = {}
    for crid, where in sorted(seen_r.items()):
        if len(where) > 1:
            errs.append("R6 duplicate reading id: %s appears %d times (ids must be unique)"
                        % (crid, len(where)))
        reading_by_id[crid] = next(o for o, _ in valid_readings if o.get("id") == crid)

    for crid, obj in sorted(reading_by_id.items()):
        # R7 — dangling implied target (mirrors R4; implied_targets lists DECLARED targets)
        for tid in (obj.get("implied_targets") or []):
            if tid not in targets:
                errs.append("R7 dangling reading target: %s.implied_targets has %s, not declared in "
                            "'## Retcon Targets'" % (crid, tid))
        # W3 — uncosted reading (the non-insane-coincidence-rate guard; the signature F1 check)
        if not (obj.get("coincidence_note") or "").strip():
            warns.append("W3 uncosted reading: %s carries no coincidence_note — show which incidental "
                         "details the reading makes load-bearing (the over-fitting guard)" % crid)

    # W4 — unpruned shortlist (the Selection step returns the top 1-3, not a flat menu)
    if len(valid_readings) > 3:
        warns.append("W4 unpruned shortlist: %d candidate readings surfaced — the Selection step "
                     "returns the top 1-3; prune to the ranked shortlist" % len(valid_readings))

    # Report
    head = "retcon-plan: %d item(s)%s; %d target(s) declared" % (
        len(items), "" if len(valid) == len(items) else " (%d well-formed)" % len(valid), len(targets))
    if readings:
        head += "; %d reading(s)%s" % (
            len(readings),
            "" if len(valid_readings) == len(readings) else " (%d well-formed)" % len(valid_readings))
    lines.append(head)
    for obj, _idx in valid:
        lines.append("  %-7s target=%-3s kind=%-14s mut=%-6s type=%s%s"
                     % (obj.get("id"), obj.get("target_id"), obj.get("kind"),
                        obj.get("mutability"), obj.get("retcon_type"),
                        "  source=%s" % obj.get("source") if obj.get("source") else ""))
    # Ranked candidate readings: highest score total first (the Selection step, made visible)
    for obj, _idx in sorted(valid_readings, key=lambda t: (-_score_total(t[0]), t[0].get("id"))):
        s = obj["scores"]
        lines.append("  %-7s score=%2d/25 (coh=%d pay=%d agy=%d gen=%d coin=%d)%s"
                     % (obj.get("id"), _score_total(obj), s["canon_coherence"], s["payoff_density"],
                        s["agency_preservation"], s["genre_fit"], s["coincidence_resistance"],
                        " -> %s" % ",".join(obj["implied_targets"]) if obj.get("implied_targets") else ""))
    for e in errs:
        lines.append("  ERROR: %s" % e)
    for w in warns:
        lines.append("  %s: %s" % ("ERROR (--strict)" if strict else "WARN", w))

    if errs or (strict and warns):
        lines.append("retcon-plan: FAIL (%d error(s)%s)"
                     % (len(errs), ", %d strict warn(s)" % len(warns) if (strict and warns) else ""))
        return 1, lines
    if warns:
        lines.append("WARN: retcon-plan: %d advisory gap(s) — see W1-W4 above" % len(warns))
    else:
        lines.append("retcon-plan: PASS (commitment-budget + fair-play + target integrity%s)"
                     % (" + ranked selection" if readings else ""))
    return 0, lines


# ---------------------------------------------------------------- resolution

def _newest(paths):
    return max(paths, key=os.path.getmtime) if paths else None


def resolve(paths):
    if len(paths) == 1 and os.path.isdir(paths[0]):
        return _newest(glob.glob(os.path.join(paths[0], _PLAN_GLOB)))
    for p in paths:
        body = _read(p) or ""
        if _has_block(body, "retcon_item") or _has_block(body, "retcon_reading"):
            return p
    return paths[0] if paths else None


def run(paths, strict=False):
    path = resolve(paths)
    if not path:
        return 2, ["retcon-plan: no Retcon Plan artifact found (need a *_Retcon_Plan_*.md or a file "
                   "with apodictic:retcon_item blocks)"]
    text = _read(path)
    if text is None:
        return 2, ["retcon-plan: cannot read %s" % path]
    return plan(text, strict=strict)


# ---------------------------------------------------------------- self-test

def run_self_test():
    import json as _j
    import tempfile
    import shutil
    rc = {"v": 0}
    made = []

    def chk(name, cond):
        print("  %s: %s" % (name, "OK" if cond else "FAIL"))
        if not cond:
            rc["v"] = 1

    # non-UTF8 artifact: _read must degrade to the unreadable path (None), never
    # a traceback (the disposition_check adjacent-exception class, swept repo-wide)
    import tempfile as _tf
    _fd, _nu = _tf.mkstemp(suffix=".md")
    with os.fdopen(_fd, "wb") as _fh:
        _fh.write(b"\xff\xfenot utf-8\xff")
    chk("non_utf8_read_returns_none", _read(_nu) is None)
    os.unlink(_nu)

    def item(rid, target="T1", kind="setup-debt", mut="free", rtype="dramatic",
             iclass="plant a recontextualizable detail", disp="author seeds it",
             blast=None, locations=None, source=None):
        obj = {"schema": _SCHEMA_ID, "id": rid, "target_id": target, "kind": kind,
               "mutability": mut, "retcon_type": rtype, "intervention_class": iclass,
               "disposition": disp}
        if blast is not None:
            obj["blast_radius"] = blast
        if locations is not None:
            obj["locations"] = locations
        if source is not None:
            obj["source"] = source
        return "<!-- apodictic:retcon_item\n%s\n-->" % _j.dumps(obj)

    def reading(crid, scores=None, note="treats one incidental detail as load-bearing",
                implied=None, label="the sister was complicit all along"):
        obj = {"schema": _READING_SCHEMA_ID, "id": crid, "reading": label,
               "scores": scores if scores is not None else {
                   "canon_coherence": 4, "payoff_density": 3, "agency_preservation": 4,
                   "genre_fit": 4, "coincidence_resistance": 4}}
        if note is not None:
            obj["coincidence_note"] = note
        if implied is not None:
            obj["implied_targets"] = implied
        return "<!-- apodictic:retcon_reading\n%s\n-->" % _j.dumps(obj)

    TARGETS = "## Retcon Targets\n- T1: the sister was complicit\n- T2: the prologue is the theme\n\n"

    # clean single item (free + dramatic, declared target)
    chk("clean_single", plan(TARGETS + item("RX-01"))[0] == 0)

    # R1 bad enum / id / missing field / JSON
    chk("r1_bad_mutability", plan(TARGETS + item("RX-01", mut="soft"))[0] == 1)
    chk("r1_bad_rtype", plan(TARGETS + item("RX-01", rtype="evidentiary"))[0] == 1)
    chk("r1_bad_id", plan(TARGETS + item("RX-1"))[0] == 1)
    chk("r1_missing_field",
        plan(TARGETS + item("RX-01").replace('"disposition"', '"disp"'))[0] == 1)
    code, lines = plan(TARGETS + '<!-- apodictic:retcon_item\n{"schema":"apodictic.retcon_item.v1"\n-->')
    chk("r1_bad_json", code == 1 and any("R1 invalid item" in ln for ln in lines))

    # R2 duplicate id
    code, lines = plan(TARGETS + item("RX-01") + "\n" + item("RX-01", kind="contradiction"))
    chk("r2_duplicate_id", code == 1 and any("R2 duplicate" in ln for ln in lines))

    # R3 — evidential + locked => ERROR (the signature gate)
    code, lines = plan(TARGETS + item("RX-01", mut="locked", rtype="evidential",
                                      blast=["Protected: Ch.12"]))
    chk("r3_evidential_lock", code == 1 and any("R3 evidential-lock" in ln for ln in lines))
    # dramatic + locked is fine (recontextualize for meaning)
    chk("r3_dramatic_lock_ok",
        plan(TARGETS + item("RX-01", mut="locked", rtype="dramatic", blast=["x"]))[0] == 0)
    # evidential + free is fine (unused latent)
    chk("r3_evidential_free_ok",
        plan(TARGETS + item("RX-01", mut="free", rtype="evidential"))[0] == 0)
    # per-id override downgrades R3 to advisory
    ov = "<!-- override: retcon-evidential RX-01 — the 'clue' was never load-bearing -->\n"
    code, lines = plan(TARGETS + ov + item("RX-01", mut="locked", rtype="evidential", blast=["x"]))
    chk("r3_override", code == 0 and any("evidential-lock (override)" in ln for ln in lines))

    # R4 dangling target
    code, lines = plan(TARGETS + item("RX-01", target="T9"))
    chk("r4_dangling_target", code == 1 and any("R4 dangling target" in ln and "T9" in ln for ln in lines))

    # W1 — locked/costly with no blast_radius => advisory, ERROR --strict
    code_w, lines_w = plan(TARGETS + item("RX-01", mut="costly", rtype="dramatic"))
    chk("w1_blast_advisory", code_w == 0 and any("W1 blast unaccounted" in ln for ln in lines_w))
    chk("w1_blast_strict_fails",
        plan(TARGETS + item("RX-01", mut="costly", rtype="dramatic"), strict=True)[0] == 1)
    # free item needs no blast_radius
    chk("w1_free_no_blast_clean", plan(TARGETS + item("RX-01", mut="free"))[0] == 0)

    # W2 — firewall drift (invented quote / ghostwrite directive) => advisory; override silences
    code_w, lines_w = plan(TARGETS + item("RX-01", disp='have her say "I always knew it was you, dear sister"'))
    chk("w2_invented_quote", code_w == 0 and any("W2 firewall drift" in ln for ln in lines_w))
    code_w, lines_w = plan(TARGETS + item("RX-01", iclass="write the line where she confesses"))
    chk("w2_ghostwrite", code_w == 0 and any("W2 firewall drift" in ln for ln in lines_w))
    chk("w2_strict_fails",
        plan(TARGETS + item("RX-01", iclass="write the dialogue for the reveal"), strict=True)[0] == 1)
    ovf = "<!-- override: retcon-firewall RX-01 — quoting the author's own draft line -->\n"
    chk("w2_override",
        plan(TARGETS + ovf + item("RX-01", iclass="write the line where she confesses"))[0] == 0)
    # "draft"/"scene" as revision NOUNS must not false-trigger W2 (verb must govern the object)
    code_w, lines_w = plan(TARGETS + item("RX-01", iclass="recontextualize the draft's opening scene"))
    chk("w2_noun_no_falsetrigger", code_w == 0 and not any("W2 firewall drift" in ln for ln in lines_w))
    # "add a scene where <events>" — the documented content-invention form (PR #38 review P2)
    code_w, lines_w = plan(TARGETS + item("RX-01", iclass="add a scene where Maya confesses to hiding the letter"))
    chk("w2_add_scene_where", code_w == 0 and any("W2 firewall drift" in ln for ln in lines_w))
    chk("w2_add_scene_strict_fails",
        plan(TARGETS + item("RX-01", iclass="insert a beat where she admits it"), strict=True)[0] == 1)
    # removing/cutting an existing scene is a legit class — must NOT trigger
    code_w, lines_w = plan(TARGETS + item("RX-02", target="T2", iclass="cut the scene where she lies"))
    chk("w2_remove_scene_no_falsetrigger",
        code_w == 0 and not any("W2 firewall drift" in ln for ln in lines_w))

    # ---- Door-B Selection step (F1): candidate readings ----
    # clean single reading (all 5 dims, note present); a pure Door-B plan (no items) is valid
    chk("reading_clean", plan(TARGETS + reading("CR-01"))[0] == 0)

    # R5 — bad id / missing dim / out-of-range / non-int / bad JSON
    chk("r5_bad_id", plan(TARGETS + reading("CR-1"))[0] == 1)
    chk("r5_missing_dim",
        plan(TARGETS + reading("CR-01", scores={"canon_coherence": 4, "payoff_density": 3,
             "agency_preservation": 4, "genre_fit": 4}))[0] == 1)
    chk("r5_out_of_range",
        plan(TARGETS + reading("CR-01", scores={"canon_coherence": 7, "payoff_density": 3,
             "agency_preservation": 4, "genre_fit": 4, "coincidence_resistance": 4}))[0] == 1)
    chk("r5_non_int",
        plan(TARGETS + reading("CR-01", scores={"canon_coherence": "hi", "payoff_density": 3,
             "agency_preservation": 4, "genre_fit": 4, "coincidence_resistance": 4}))[0] == 1)
    code, lines = plan(TARGETS + '<!-- apodictic:retcon_reading\n{"schema":"apodictic.retcon_reading.v1"\n-->')
    chk("r5_bad_json", code == 1 and any("R5 invalid reading" in ln for ln in lines))

    # R6 — duplicate reading id
    code, lines = plan(TARGETS + reading("CR-01") + "\n" + reading("CR-01", label="other"))
    chk("r6_duplicate_id", code == 1 and any("R6 duplicate reading id" in ln for ln in lines))

    # R7 — dangling implied target (mirrors R4); a declared target is the legit Door-B->Door-A handoff
    code, lines = plan(TARGETS + reading("CR-01", implied=["T9"]))
    chk("r7_dangling_target", code == 1 and any("R7 dangling reading target" in ln and "T9" in ln for ln in lines))
    chk("r7_declared_target_ok", plan(TARGETS + reading("CR-01", implied=["T1"]))[0] == 0)

    # W3 — uncosted reading (no/empty coincidence_note) => advisory, ERROR --strict (the signature check)
    code_w, lines_w = plan(TARGETS + reading("CR-01", note=None))
    chk("w3_uncosted_advisory", code_w == 0 and any("W3 uncosted reading" in ln for ln in lines_w))
    chk("w3_uncosted_strict_fails", plan(TARGETS + reading("CR-01", note=None), strict=True)[0] == 1)
    code_w, lines_w = plan(TARGETS + reading("CR-01", note="   "))
    chk("w3_empty_note_triggers", code_w == 0 and any("W3 uncosted reading" in ln for ln in lines_w))

    # W4 — unpruned shortlist (>3 readings) => advisory, ERROR --strict; exactly 3 is clean
    four = "".join(reading("CR-0%d" % n) for n in range(1, 5))
    code_w, lines_w = plan(TARGETS + four)
    chk("w4_unpruned_advisory", code_w == 0 and any("W4 unpruned shortlist" in ln for ln in lines_w))
    chk("w4_unpruned_strict_fails", plan(TARGETS + four, strict=True)[0] == 1)
    chk("w4_three_ok", plan(TARGETS + "".join(reading("CR-0%d" % n) for n in range(1, 4)))[0] == 0)

    # ranked display: highest score total printed first (the Selection step made visible)
    hi = reading("CR-01", scores={"canon_coherence": 5, "payoff_density": 5, "agency_preservation": 5,
                                  "genre_fit": 5, "coincidence_resistance": 5})
    lo = reading("CR-02", scores={"canon_coherence": 2, "payoff_density": 2, "agency_preservation": 2,
                                  "genre_fit": 2, "coincidence_resistance": 2})
    _c, dlines = plan(TARGETS + lo + hi)
    order = [ln for ln in dlines if "score=" in ln]
    chk("ranked_display_order", len(order) == 2 and "CR-01" in order[0] and "CR-02" in order[1])

    # items + readings compose cleanly in one plan
    chk("items_and_readings_clean",
        plan(TARGETS + item("RX-01") + reading("CR-01", implied=["T1"]))[0] == 0)

    # ---- F3: Pass-8 source provenance on a retcon item ----
    # a well-formed source is clean and shown in the item line
    code, lines = plan(TARGETS + item("RX-01", source="F-P8-03"))
    chk("f3_source_clean", code == 0 and any("source=F-P8-03" in ln for ln in lines))
    # a malformed source (one-digit suffix) fails its schema pattern -> R1
    chk("f3_source_malformed_r1", plan(TARGETS + item("RX-01", source="F-P8-3"))[0] == 1)

    # no blocks -> no-op
    chk("no_items_noop", plan("# Retcon Plan\nNothing structured yet.\n")[0] == 0)

    # run-folder + explicit-file resolution
    d = tempfile.mkdtemp()
    made.append(d)
    with open(os.path.join(d, "Proj_Retcon_Plan_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Retcon Plan\n" + TARGETS + item("RX-01", blast=["Protected: close"]) + "\n")
    chk("run_folder_resolution", run([d])[0] == 0)
    chk("explicit_file_resolution", run([os.path.join(d, "Proj_Retcon_Plan_run.md")])[0] == 0)
    chk("missing_artifact_usage", run([d + "/nope.md"])[0] == 2)

    for d in made:
        shutil.rmtree(d, ignore_errors=True)
    # regression: a non-dict retcon_reading payload must not crash _score_errors (2026-06-20 sweep)
    chk("crash_nondict_reading", plan('<!-- apodictic:retcon_reading\n"juststring"\n-->')[0] == 1)
    print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
    return rc["v"]


def main(argv):
    if "--self-test" in argv:
        return run_self_test()
    args = [a for a in argv[1:] if a != "retcon-plan"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: retcon_plan.py retcon-plan <run_folder|files...> [--strict] | --self-test")
        return 2
    code, lines = run(paths, strict=strict)
    for ln in lines:
        print(ln)
    return code


if __name__ == "__main__":
    sys.exit(main(sys.argv))

SHA-256: ed6d9e4e2cc6a38477d09ab2a224cf00dad5f850b13f0a6c7f98c232f1799158