← Files APODICTIC Development EditorARCHIVED FILE

scripts/feedback_triage.py

25.8 KB · Nov 9, 2026 · 00:53 UTC

↓ Download file

#!/usr/bin/env python3
"""feedback-triage — structural integrity for the Feedback Triage workflow (Workflows track).

`validate.sh feedback-triage <run_folder> [--strict]` (or explicit files) shells out here.
A writer returning with beta-reader / critique-group / editor feedback records each item as a
structured `apodictic.feedback_item.v1` block in a Feedback Triage artifact: the external claim,
APODICTIC's own `assessment` of it (did our analysis confirm it?), the `triage` disposition, the
items it `conflicts_with`, and the chosen `disposition`. This validator owns the workflow's
machine-checkable invariants — contract hygiene + conflict referential integrity + the
"contradiction kept live on both sides" coherence gap that prose triage can hide.

  E1 invalid item     a feedback_item block fails its schema (bad enum / id / missing field / JSON).
  E2 duplicate id     two items share an FB-NN id (ids must be unique per triage).
  E3 dangling conflict an id in conflicts_with does not resolve to a real item in the artifact.
  E4 self conflict    an item lists its own id in conflicts_with.
  W1 unresolved conflict  two items conflict but BOTH stay actionable (act-now/act-later) — the
                          contradiction was never resolved (advisory; ERROR under --strict).
  W2 act-on-unvalidated   an item triaged act-now whose claim is not (partly-)validated — acting
                          now on an unconfirmed external claim (advisory; ERROR under --strict).
  W3 unreconciled decline an item triaged `decline` whose evidence_refs cite a ledger F-… id, with
                          no disposition marker for that id in this artifact — the decline lives
                          only here, invisible to the engine (docs/finding-dispositions.md;
                          advisory; ERROR under --strict).
  E5 dangling maps_to  an item's `maps_to` finding-id (Increment 2) does not resolve to a real
                          apodictic.finding.v1 in the supplied Findings Ledger — a phantom / typo'd
                          finding reference. Cross-artifact referential integrity, mirroring
                          finding-trace E1. Requires the ledger (2nd file); skipped without it.
  W4 unmapped validated   a `validated` item that carries no `maps_to` — the spec's "a validated
                          feedback item must point at a real F-…", made checkable. Advisory (ERROR
                          under --strict) so an item validated ahead of its ledger entry, and every
                          pre-Increment-2 artifact, stays valid; --strict is the finalize/CI gate.
                          Also requires the ledger (only engaged in the two-file cross-check mode).

Conflicts are treated as an UNDIRECTED graph: A.conflicts_with B pairs {A,B} even if B omits A.
The Findings Ledger is an OPTIONAL second file (a run folder globs *_Findings_Ledger_*.md; explicit
args classify by block type) — like finding-trace / ledger-consolidation. When absent (or carrying
no finding blocks), the maps_to cross-check (E5/W4) is skipped and only the self-contained schema +
conflict checks run. Each artifact is optional; an empty/absent one is a no-op (no false failure).
Reuses apodictic_artifacts (one block grammar + the schema engine). See docs/feedback-triage.md.

  feedback_triage.py feedback-triage <run_folder|files...> [--strict]
  feedback_triage.py feedback-triage <triage.md> [<ledger.md>] [--strict]
  feedback_triage.py --self-test

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

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.feedback_item.v1"
_ACTIONABLE = ("act-now", "act-later")
_VALIDATED = ("validated", "partly-validated")
# W4 targets the spec's exact term — "a *validated* feedback item must point at a real F-…" — so it
# keys on assessment == "validated" only, NOT the broader (partly-validated) actionable set above.
_MAPS_REQUIRED = "validated"
_TRIAGE_GLOB = "*_Feedback_Triage_*.md"
_LEDGER_GLOB = "*_Findings_Ledger_*.md"


def ledger_ids(ledger_text):
    """The authoritative set of Finding Lifecycle IDs in a Findings Ledger — {fid_key(id)} over its
    apodictic.finding.v1 blocks. Mirrors finding_trace.ledger_inventory (keyed the same way, via
    art.fid_key so a malformed non-hashable finding id can't crash the index). Empty when no ledger
    is supplied or it carries no finding blocks — the caller treats that as 'no cross-check'."""
    ids = set()
    if not ledger_text or art is None:
        return ids
    for bt, obj, _err in art.parse_blocks(ledger_text):
        if bt == "finding" and isinstance(obj, dict) and obj.get("id"):
            ids.add(art.fid_key(obj["id"]))
    return ids


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


def parse_items(text):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:feedback_item block.

    Each entry's schema_errs is a list (JSON/schema failures); obj is the parsed dict (or None
    when the JSON itself is broken). Index is 1-based block position for messages."""
    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 != "feedback_item":
            continue
        idx += 1
        where = "feedback_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 triage(text, ledger_text=None, strict=False):
    """Run the Feedback Triage integrity checks. Returns (code, lines).

    ledger_text = the paired Findings Ledger (Increment 2). When it carries finding blocks the
    maps_to cross-check engages (E5 dangling reference / W4 unmapped validated); when absent or
    empty of findings the check is skipped — self-contained schema + conflict checks only."""
    lines, errs, warns = [], [], []
    items = parse_items(text)
    if not items:
        return 0, ["feedback-triage: no feedback_item blocks found — nothing to triage"]
    led = ledger_ids(ledger_text)
    have_ledger = bool(led)  # a ledger with real findings to cross-check against

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

    # Index well-formed items by id (only items that passed schema have a trustworthy id)
    by_id = {}
    valid = [(obj, idx) for obj, schema_errs, idx in items if obj is not None and not schema_errs]
    # E2 — duplicate id
    seen = {}
    for obj, idx in valid:
        fid = obj.get("id")
        seen.setdefault(fid, []).append(idx)
    for fid, where in sorted(seen.items()):
        if len(where) > 1:
            errs.append("E2 duplicate id: %s appears %d times (ids must be unique per triage)"
                        % (fid, len(where)))
        by_id[fid] = next(o for o, _ in valid if o.get("id") == fid)

    known = set(by_id)
    # E3 dangling conflict / E4 self conflict + build the undirected conflict graph
    conflicts = set()  # frozenset({a, b})
    for fid, obj in sorted(by_id.items()):
        for other in obj.get("conflicts_with") or []:
            if other == fid:
                errs.append("E4 self conflict: %s lists itself in conflicts_with" % fid)
                continue
            if other not in known:
                errs.append("E3 dangling conflict: %s.conflicts_with cites %s — no such item" % (fid, other))
                continue
            conflicts.add(frozenset((fid, other)))

    # W1 — unresolved conflict: both sides still actionable
    for pair in sorted(conflicts, key=lambda p: sorted(p)):
        a, b = sorted(pair)
        if by_id[a].get("triage") in _ACTIONABLE and by_id[b].get("triage") in _ACTIONABLE:
            warns.append("W1 unresolved conflict: %s and %s contradict but both stay actionable "
                         "(%s / %s) — resolve one before revising"
                         % (a, b, by_id[a].get("triage"), by_id[b].get("triage")))
    # W2 — acting now on an unvalidated claim
    for fid, obj in sorted(by_id.items()):
        if obj.get("triage") == "act-now" and obj.get("assessment") not in _VALIDATED:
            warns.append("W2 act-on-unvalidated: %s is act-now but assessment=%r (not validated)"
                         % (fid, obj.get("assessment")))
    # W3 — unreconciled decline (docs/finding-dispositions.md): a declined item that CITES a ledger
    # finding (an F-… token in evidence_refs) should carry an engine disposition marker in this same
    # artifact — otherwise the decline is invisible to the coach ladder and the /ready caveat.
    # OWNERSHIP BOUNDARY (W3 vs disposition-check DP2.2): W3 is feedback-triage-ARTIFACT-scoped
    # advisory — it never reads the sidecar; DP2.2 is sidecar-scoped ledger-integrity — it never
    # reads triage artifacts. The matching comment lives at DP2.2 in disposition_check.py.
    marker_ids = ({m["id"] for m in art.parse_disposition_markers(text)}
                  if art is not None and hasattr(art, "parse_disposition_markers") else set())
    for fid, obj in sorted(by_id.items()):
        if obj.get("triage") != "decline":
            continue
        cited = set()
        for ref in obj.get("evidence_refs") or []:
            if isinstance(ref, str) and art is not None:
                cited.update(art.FID_RE.findall(ref))
        for led_id in sorted(cited - marker_ids):
            warns.append("W3 unreconciled decline: declined feedback %s maps to engine finding %s "
                         "but no disposition was recorded — the decline lives only in this artifact"
                         % (fid, led_id))

    # E5 dangling maps_to / W4 unmapped validated (Increment 2) — the structured finding-id link,
    # cross-checked against the Findings Ledger. Only engaged when a ledger with findings is supplied
    # (the two-file cross-check mode, like finding-trace); without it the referential dimension is
    # skipped, not failed. E5 is a hard referential-integrity error (a maps_to must resolve to a real
    # ledger finding — assessment-independent, mirroring finding-trace E1); W4 is the advisory
    # presence gate for the spec's "a validated feedback item must point at a real F-…".
    if have_ledger:
        for fid, obj in sorted(by_id.items()):
            mapped = obj.get("maps_to")
            if mapped and art is not None and art.fid_key(mapped) not in led:
                errs.append("E5 dangling maps_to: %s.maps_to cites %s — not in the Findings Ledger"
                            % (fid, mapped))
            if obj.get("assessment") == _MAPS_REQUIRED and not mapped:
                warns.append("W4 unmapped validated: %s is assessment=validated but carries no "
                             "maps_to — a validated item must point at a real ledger finding" % fid)

    # Report
    lines.append("feedback-triage: %d item(s)%s%s" % (len(items),
                 "" if len(valid) == len(items) else " (%d well-formed)" % len(valid),
                 "" if have_ledger else " (no ledger — maps_to cross-check skipped)"))
    for obj, idx in valid:
        cw = ",".join(obj.get("conflicts_with") or []) or "—"
        lines.append("  %-7s src=%-16s assess=%-15s triage=%-9s conflicts=%-11s maps=%s"
                     % (obj.get("id"), (obj.get("source") or "?")[:16],
                        obj.get("assessment"), obj.get("triage"), cw, obj.get("maps_to") or "—"))
    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("feedback-triage: 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: feedback-triage: %d advisory coherence gap(s) — see W1/W2/W3/W4 above" % len(warns))
    else:
        lines.append("feedback-triage: PASS (contract + conflict integrity)")
    return 0, lines


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

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


def resolve(paths):
    """Return (triage_path, ledger_path) from a run folder or explicit files. The ledger is the
    optional Increment-2 second artifact — a run folder globs *_Findings_Ledger_*.md; explicit args
    classify by block type (feedback_item -> triage, finding -> ledger). ledger_path is None when
    none is supplied, in which case the maps_to cross-check degrades (self-contained checks only)."""
    if len(paths) == 1 and os.path.isdir(paths[0]):
        return (_newest(glob.glob(os.path.join(paths[0], _TRIAGE_GLOB))),
                _newest(glob.glob(os.path.join(paths[0], _LEDGER_GLOB))))
    triage_path = ledger_path = None
    for p in paths:
        text = _read(p) or ""
        if triage_path is None and _has_block(text, "feedback_item"):
            triage_path = p
        elif ledger_path is None and _has_block(text, "finding"):
            ledger_path = p
    # fall back to the first non-ledger file arg if none carried a feedback block (so a clean empty
    # file reports no-op, matching Increment-1 behaviour)
    if triage_path is None:
        for p in paths:
            if p != ledger_path:
                triage_path = p
                break
    return triage_path, ledger_path


def run(paths, strict=False):
    path, ledger_path = resolve(paths)
    if not path:
        return 2, ["feedback-triage: no Feedback Triage artifact found (need a *_Feedback_Triage_*.md "
                   "or a file with apodictic:feedback_item blocks)"]
    text = _read(path)
    if text is None:
        return 2, ["feedback-triage: cannot read %s" % path]
    ledger_text = _read(ledger_path) if ledger_path else None
    return triage(text, ledger_text=ledger_text, strict=strict)


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

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

    def check(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")
    check("non_utf8_read_returns_none", _read(_nu) is None)
    os.unlink(_nu)

    def item(fid, assessment="validated", triage_="act-now", conflicts=None, source="Beta reader",
             claim="c", disp="d", maps_to=None):
        obj = {"schema": _SCHEMA_ID, "id": fid, "source": source, "claim": claim,
               "assessment": assessment, "triage": triage_, "disposition": disp}
        if conflicts is not None:
            obj["conflicts_with"] = conflicts
        if maps_to is not None:
            obj["maps_to"] = maps_to
        import json as _j
        return "<!-- apodictic:feedback_item\n%s\n-->" % _j.dumps(obj)

    def ledger(*fids):
        import json as _j
        blocks = []
        for f in fids:
            blocks.append("<!-- apodictic:finding\n%s\n-->" % _j.dumps(
                {"schema": "apodictic.finding.v1", "id": f, "mechanism": "m", "severity": "Must-Fix",
                 "confidence": "HIGH", "evidence_refs": ["c"], "fix_class": "x", "risk_if_fixed": "y"}))
        return "# Findings Ledger\n" + "\n".join(blocks) + "\n"

    # clean single item
    code, _ = triage(item("FB-01"))
    check("clean_single", code == 0)

    # bad enums / id / missing field -> E1
    check("bad_assessment", triage(item("FB-01").replace("validated", "true"))[0] == 1)
    check("bad_triage", triage(item("FB-01", triage_="act-now").replace('"act-now"', '"asap"'))[0] == 1)
    check("bad_id_format", triage(item("FB-1"))[0] == 1)
    check("missing_field", triage(item("FB-01").replace('"disposition": "d"', '"disposition2": "d"'))[0] == 1)
    # malformed JSON block (no closing brace before -->) -> E1 invalid item
    code, lines = triage('<!-- apodictic:feedback_item\n{"schema":"apodictic.feedback_item.v1"\n-->')
    check("bad_json", code == 1 and any("E1 invalid item" in ln for ln in lines))

    # E2 duplicate id
    code, lines = triage(item("FB-01") + "\n" + item("FB-01", claim="other"))
    check("e2_duplicate_id", code == 1 and any("E2 duplicate" in ln for ln in lines))

    # E3 dangling conflict
    code, lines = triage(item("FB-01", conflicts=["FB-99"]))
    check("e3_dangling_conflict", code == 1 and any("E3 dangling" in ln and "FB-99" in ln for ln in lines))

    # E4 self conflict
    code, lines = triage(item("FB-01", conflicts=["FB-01"]))
    check("e4_self_conflict", code == 1 and any("E4 self conflict" in ln for ln in lines))

    # W1 unresolved conflict (both actionable) — advisory, ERROR --strict
    two_live = item("FB-01", triage_="act-now", conflicts=["FB-02"]) + "\n" + item("FB-02", triage_="act-later")
    code_w, lines_w = triage(two_live)
    check("w1_unresolved_advisory",
          code_w == 0 and any("W1 unresolved conflict" in ln for ln in lines_w))
    check("w1_unresolved_strict_fails", triage(two_live, strict=True)[0] == 1)

    # one-directional declaration still detected (FB-02 omits the back-reference)
    code_w, lines_w = triage(item("FB-01", conflicts=["FB-02"]) + "\n" + item("FB-02"))
    check("w1_undirected", code_w == 0 and any("W1 unresolved conflict" in ln for ln in lines_w))

    # resolved conflict: one side declined -> clean
    resolved = item("FB-01", triage_="act-now", conflicts=["FB-02"]) + "\n" + \
        item("FB-02", assessment="refuted", triage_="decline")
    check("conflict_resolved_clean", triage(resolved)[0] == 0)

    # W2 act-on-unvalidated — advisory, ERROR --strict
    code_w, lines_w = triage(item("FB-01", assessment="pending", triage_="act-now"))
    check("w2_act_unvalidated_advisory",
          code_w == 0 and any("W2 act-on-unvalidated" in ln for ln in lines_w))
    check("w2_act_unvalidated_strict_fails",
          triage(item("FB-01", assessment="pending", triage_="act-now"), strict=True)[0] == 1)

    # monitor on a pending item is fine (no W2)
    check("pending_monitor_clean", triage(item("FB-01", assessment="pending", triage_="monitor"))[0] == 0)

    # W3 unreconciled decline — a declined item citing a ledger F-… id with no disposition marker
    # in the same artifact fires; recording the marker (or citing no F-… id) clears it.
    declined_ref = item("FB-01", assessment="refuted", triage_="decline")\
        .replace('"disposition": "d"', '"disposition": "d", "evidence_refs": ["Pass 5", "F-P5-01"]')
    code_w, lines_w = triage(declined_ref)
    check("w3_unreconciled_decline_fires",
          code_w == 0 and any("W3 unreconciled decline" in ln and "F-P5-01" in ln for ln in lines_w))
    check("w3_strict_fails", triage(declined_ref, strict=True)[0] == 1)
    reconciled = declined_ref + "\n<!-- declined: F-P5-01 — reader claim refuted by the diagnosis -->\n"
    code_w, lines_w = triage(reconciled)
    check("w3_marker_reconciles", code_w == 0 and not any("W3" in ln for ln in lines_w))
    check("w3_no_fid_ref_clean",
          not any("W3" in ln for ln in triage(item("FB-02", assessment="refuted", triage_="decline"))[1]))
    # a NON-declined item citing an F-… id never fires W3
    acted = item("FB-03", assessment="validated", triage_="act-now")\
        .replace('"disposition": "d"', '"disposition": "d", "evidence_refs": ["F-P5-01"]')
    check("w3_only_on_decline", not any("W3" in ln for ln in triage(acted)[1]))

    # --- Increment 2: structured maps_to cross-checked against the Findings Ledger ---
    led = ledger("F-RR-01", "F-P1-02")
    # a validated item whose maps_to resolves -> clean (no E5, no W4)
    code, lines = triage(item("FB-01", maps_to="F-RR-01"), ledger_text=led)
    check("maps_to_resolves_clean",
          code == 0 and not any("E5" in ln or "W4" in ln for ln in lines))
    check("maps_to_report_column", any("maps=F-RR-01" in ln for ln in lines))
    # a dangling maps_to (no such ledger finding) -> E5 ERROR (assessment-independent)
    code, lines = triage(item("FB-01", maps_to="F-XX-99"), ledger_text=led)
    check("e5_dangling_maps_to",
          code == 1 and any("E5 dangling maps_to" in ln and "F-XX-99" in ln for ln in lines))
    # E5 fires even for a NON-validated item — a recorded maps_to must always resolve
    code, lines = triage(item("FB-02", assessment="refuted", triage_="decline", maps_to="F-XX-99"),
                         ledger_text=led)
    check("e5_dangling_any_assessment",
          code == 1 and any("E5 dangling maps_to" in ln for ln in lines))
    # a validated item WITHOUT maps_to, ledger present -> W4 advisory (ERROR under --strict)
    code_w, lines_w = triage(item("FB-01"), ledger_text=led)
    check("w4_unmapped_validated_advisory",
          code_w == 0 and any("W4 unmapped validated" in ln and "FB-01" in ln for ln in lines_w))
    check("w4_unmapped_validated_strict_fails", triage(item("FB-01"), ledger_text=led, strict=True)[0] == 1)
    # a NON-validated item without maps_to -> no W4 (only `validated` must map)
    check("w4_only_on_validated",
          not any("W4" in ln for ln in triage(item("FB-03", assessment="pending", triage_="monitor"),
                                              ledger_text=led)[1]))
    # partly-validated is NOT subject to W4 (spec: "a *validated* feedback item must point at ...")
    check("w4_skips_partly_validated",
          not any("W4" in ln for ln in triage(item("FB-04", assessment="partly-validated",
                                                    triage_="act-later"), ledger_text=led)[1]))
    # NO ledger supplied -> maps_to cross-check skipped entirely (self-contained schema/conflict only);
    # a validated item without maps_to, and even a dangling maps_to, do not fire without a ledger
    code, lines = triage(item("FB-01", maps_to="F-XX-99"))
    check("no_ledger_skips_maps_check",
          code == 0 and not any("E5" in ln or "W4" in ln for ln in lines)
          and any("maps_to cross-check skipped" in ln for ln in lines))
    # a ledger file with NO finding blocks is treated as no cross-check (degrade, not false E5/W4)
    check("empty_ledger_degrades",
          triage(item("FB-01", maps_to="F-XX-99"), ledger_text="# not a ledger\n")[0] == 0)
    # a malformed maps_to value (bad F-pattern) is caught by the schema (E1), not E5
    code, lines = triage(item("FB-01", maps_to="F-1"), ledger_text=led)
    check("bad_maps_to_pattern_is_e1", code == 1 and any("E1 invalid item" in ln for ln in lines))

    # no blocks -> no-op
    check("no_items_noop", triage("# Feedback\nNo structured items yet.\n")[0] == 0)

    # file + run-folder resolution
    d = tempfile.mkdtemp()
    made.append(d)
    with open(os.path.join(d, "Proj_Feedback_Triage_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Feedback Triage\n" + resolved + "\n")
    check("run_folder_resolution", run([d])[0] == 0)
    check("explicit_file_resolution", run([os.path.join(d, "Proj_Feedback_Triage_run.md")])[0] == 0)
    check("missing_artifact_usage", run([d + "/nope.md"])[0] in (2,))

    # Increment 2: a run folder with BOTH a triage artifact and a Findings Ledger cross-checks maps_to.
    d2 = tempfile.mkdtemp()
    made.append(d2)
    with open(os.path.join(d2, "Proj_Feedback_Triage_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Feedback Triage\n" + item("FB-01", maps_to="F-RR-01") + "\n")
    with open(os.path.join(d2, "Proj_Findings_Ledger_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write(ledger("F-RR-01"))
    check("run_folder_ledger_crosscheck", run([d2])[0] == 0)
    # a dangling maps_to in the run folder is caught as E5
    with open(os.path.join(d2, "Proj_Feedback_Triage_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Feedback Triage\n" + item("FB-01", maps_to="F-XX-99") + "\n")
    rc2, ln2 = run([d2])
    check("run_folder_ledger_dangling", rc2 == 1 and any("E5 dangling maps_to" in ln for ln in ln2))
    # explicit two-file classification (triage + ledger, order-independent)
    with open(os.path.join(d2, "Proj_Feedback_Triage_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Feedback Triage\n" + item("FB-01", maps_to="F-RR-01") + "\n")
    check("explicit_two_file_crosscheck",
          run([os.path.join(d2, "Proj_Findings_Ledger_run.md"),
               os.path.join(d2, "Proj_Feedback_Triage_run.md")])[0] == 0)

    for d in made:
        shutil.rmtree(d, ignore_errors=True)
    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 != "feedback-triage"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: feedback_triage.py feedback-triage <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: be589939055bcd147b122bd9e0073da264bba7d93d625eb3fed23cfbacdc5be6