#!/usr/bin/env python3
"""Tests for workflow enforcement logic.

Run: python3 test_enforcement.py
Tests the enforcement functions in issues.py, auto-approve.py, stop-gate.py,
and post-transition-check.py without requiring API access.
"""

import json
import os
import re
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch

# Add parent dirs to path for imports
SCRIPT_DIR = Path(__file__).parent.resolve()
HOOKS_DIR = SCRIPT_DIR.parent.parent.parent / "hooks"
sys.path.insert(0, str(SCRIPT_DIR))
sys.path.insert(0, str(HOOKS_DIR))

from status_vocab import DEFAULT_STATUS_MAPPING  # noqa: E402

# Hermetic environment for every issues.py subprocess (FLY-1285). The suite's
# docstring promises "without requiring API access", but credentials resolve
# globally (~/.flydocs/credentials), so on any developer machine a create that
# passes client-side validation REACHES THE RELAY AND FILES A REAL ISSUE —
# that is where the board's recurring "Quick test" bugs came from. An invalid
# key plus a relay URL pointing at the discard port makes the API layer fail
# fast and offline for every test, current and future.
HERMETIC_ENV = {
    **os.environ,
    "FLYDOCS_API_KEY": "fdk_test_enforcement_hermetic",
    "FLYDOCS_RELAY_URL": "http://127.0.0.1:9",
}

# The env alone defeats only the RELAY. A cwd holding a local-tier
# .flydocs/config.json (the template dir is one) routes create to the
# filesystem backend and litters flydocs/issues/ with "Quick test" files
# (found running the suite from template/, FLY-1272 review). An empty temp
# cwd means no config resolves on EITHER tier, so the subprocess fails fast
# before any write, anywhere.
HERMETIC_CWD = tempfile.mkdtemp(prefix="flydocs-hermetic-")

passed = 0
failed = 0


def test(name: str):
    """Decorator to register and run a test."""
    def decorator(fn):
        global passed, failed
        try:
            fn()
            print(f"  PASS: {name}")
            passed += 1
        except AssertionError as e:
            print(f"  FAIL: {name} — {e}")
            failed += 1
        except Exception as e:
            print(f"  ERROR: {name} — {type(e).__name__}: {e}")
            failed += 1
        return fn
    return decorator


# ---------------------------------------------------------------------------
# issues.py enforcement tests
# ---------------------------------------------------------------------------

print("\n## issues.py enforcement")


@test("create rejects empty description")
def _():
    """issues.py create should fail with empty --description."""
    import subprocess
    result = subprocess.run(
        [sys.executable, str(SCRIPT_DIR / "issues.py"), "create",
         "--title", "Test", "--type", "feature", "--description", ""],
        capture_output=True, text=True, timeout=10,
        env=HERMETIC_ENV, cwd=HERMETIC_CWD,
    )
    assert result.returncode != 0, f"Expected failure, got rc={result.returncode}"
    assert "Description is required" in result.stderr, f"Expected error message, got: {result.stderr[:200]}"


@test("create rejects missing description")
def _():
    """issues.py create should fail without --description flag."""
    import subprocess
    result = subprocess.run(
        [sys.executable, str(SCRIPT_DIR / "issues.py"), "create",
         "--title", "Test", "--type", "feature"],
        capture_output=True, text=True, timeout=10,
        env=HERMETIC_ENV, cwd=HERMETIC_CWD,
    )
    assert result.returncode != 0, f"Expected failure, got rc={result.returncode}"


@test("create allows --triage without description")
def _():
    """issues.py create with --triage should not fail on empty description."""
    # This would succeed only if we have API access, so just check the
    # enforcement bypass by testing the error message doesn't say "Description is required"
    import subprocess
    result = subprocess.run(
        [sys.executable, str(SCRIPT_DIR / "issues.py"), "create",
         "--title", "Quick test", "--type", "bug", "--triage"],
        capture_output=True, text=True, timeout=10,
        env=HERMETIC_ENV, cwd=HERMETIC_CWD,
    )
    # It may fail for other reasons (no API), but NOT for missing description
    assert "Description is required" not in result.stderr, \
        f"--triage should bypass description check, got: {result.stderr[:200]}"


@test("transition rejects empty comment")
def _():
    """issues.py transition should reject whitespace-only comment."""
    import subprocess
    result = subprocess.run(
        [sys.executable, str(SCRIPT_DIR / "issues.py"), "transition",
         "FLY-999", "REVIEW", "   "],
        capture_output=True, text=True, timeout=10,
        env=HERMETIC_ENV, cwd=HERMETIC_CWD,
    )
    assert result.returncode != 0, f"Expected failure, got rc={result.returncode}"
    assert "comment cannot be empty" in result.stderr, f"Got: {result.stderr[:200]}"


# ---------------------------------------------------------------------------
# auto-approve.py enforcement tests
# ---------------------------------------------------------------------------

print("\n## auto-approve.py enforcement")


@test("should_approve matches workflow scripts")
def _():
    # Import the function directly
    sys.path.insert(0, str(HOOKS_DIR))
    # We need to import carefully since it's a hook script
    import importlib.util
    spec = importlib.util.spec_from_file_location("auto_approve", HOOKS_DIR / "auto-approve.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)

    assert mod.should_approve("python3 .claude/skills/flydocs-workflow/scripts/issues.py create --title test")
    assert mod.should_approve("python3 .claude/skills/flydocs-workflow/scripts/workspace.py validate")
    assert not mod.should_approve("python3 malicious.py")
    assert not mod.should_approve("rm -rf /")


@test("validate_create_args warns on missing description")
def _():
    import importlib.util
    spec = importlib.util.spec_from_file_location("auto_approve", HOOKS_DIR / "auto-approve.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)

    warnings = mod.validate_create_args(
        'python3 .claude/skills/flydocs-workflow/scripts/issues.py create --title "test" --type feature'
    )
    assert len(warnings) > 0, "Should warn about missing --description"
    assert any("description" in w.lower() for w in warnings)


@test("validate_create_args no warning when description present")
def _():
    import importlib.util
    spec = importlib.util.spec_from_file_location("auto_approve", HOOKS_DIR / "auto-approve.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)

    warnings = mod.validate_create_args(
        'python3 .claude/skills/flydocs-workflow/scripts/issues.py create --title "test" --type feature --description "Full description here with enough content"'
    )
    desc_warnings = [w for w in warnings if "Missing --description" in w]
    assert len(desc_warnings) == 0, f"Should not warn when description present, got: {warnings}"


@test("get_transition_comment_hint returns template for known status")
def _():
    import importlib.util
    spec = importlib.util.spec_from_file_location("auto_approve", HOOKS_DIR / "auto-approve.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)

    hint = mod.get_transition_comment_hint("issues.py transition FLY-123 IMPLEMENTING")
    assert hint is not None, "Should return hint for IMPLEMENTING"
    assert "Starting implementation" in hint


@test("get_transition_comment_hint returns None for non-transition")
def _():
    import importlib.util
    spec = importlib.util.spec_from_file_location("auto_approve", HOOKS_DIR / "auto-approve.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)

    hint = mod.get_transition_comment_hint("issues.py list --status BACKLOG")
    assert hint is None, "Should return None for non-transition commands"


# ---------------------------------------------------------------------------
# Transition validation tests
# ---------------------------------------------------------------------------

print("\n## Transition validation")


# These three used to reach into `issues.VALID_TRANSITIONS` and assert the map
# "allows" or "blocks" an edge. Since FLY-1265 the map blocks nothing —
# `issues.py` does not import it at all — so asserting its contents through
# that module tested a re-export rather than a behavior. What is worth holding
# is the behavior that replaced it: a listed edge is silent, an unlisted one
# produces text. The map's own shape is pinned against the server table in
# "the client map mirrors the server table it now defers to (FLY-1265)".


@test("a listed edge (IMPLEMENTING -> REVIEW / BLOCKED) says nothing")
def _():
    import status_vocab
    assert status_vocab.transition_hint("IMPLEMENTING", "REVIEW") is None
    assert status_vocab.transition_hint("IMPLEMENTING", "BLOCKED") is None


@test("BACKLOG -> REVIEW is flagged, and names the targets it expected")
def _():
    import status_vocab
    hint = status_vocab.transition_hint("BACKLOG", "REVIEW")
    assert hint is not None, "skipping refinement entirely should be remarked on"
    assert "BACKLOG -> REVIEW" in hint, hint
    # The value of the warning is the alternative, not the complaint.
    assert "READY" in hint and "IMPLEMENTING" in hint, hint


@test("READY -> REVIEW is flagged")
def _():
    import status_vocab
    hint = status_vocab.transition_hint("READY", "REVIEW")
    assert hint is not None, "READY -> REVIEW skips the work itself"
    assert "READY -> REVIEW" in hint, hint


print("\n## stop-gate.py dirtiness heuristic")


def _run_stop_gate_full(cwd: Path):
    """Run the stop-gate hook against a directory, return the whole result."""
    import subprocess
    return subprocess.run(
        [sys.executable, str(HOOKS_DIR / "stop-gate.py")],
        input=json.dumps({"cwd": str(cwd)}),
        capture_output=True, text=True, timeout=15,
    )


def _run_stop_gate(cwd: Path) -> int:
    """`_run_stop_gate_full` for the tests that only read the exit code."""
    return _run_stop_gate_full(cwd).returncode


def _make_session(root: Path, status: str, status_ref: str = "FLY-999") -> None:
    session = root / ".flydocs" / "session" / "default"
    session.mkdir(parents=True)
    (session / "focus.md").write_text("FLY-999\n")
    (session / "status").write_text(f"{status}\n")
    # status-ref names the issue `status` describes; the gate requires it to
    # match the focused issue before judging anything (FLY-1064).
    if status_ref:
        (session / "status-ref").write_text(f"{status_ref}\n")


@test("READY gate does not fire in a non-git directory (FLY-968)")
def _():
    import subprocess
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "READY")
        rc = _run_stop_gate(root)
        assert rc == 0, f"Expected 0 at non-git cwd, got {rc}"


@test("READY gate does not fire in a clean git repo")
def _():
    import subprocess
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "READY")
        subprocess.run(["git", "init", "-q"], cwd=root, check=True)
        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        subprocess.run(
            ["git", "-c", "user.email=t@t", "-c", "user.name=t",
             "commit", "-qm", "init"], cwd=root, check=True,
        )
        rc = _run_stop_gate(root)
        assert rc == 0, f"Expected 0 in clean repo, got {rc}"


@test("READY gate fires on uncommitted tracked changes")
def _():
    import subprocess
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "READY")
        subprocess.run(["git", "init", "-q"], cwd=root, check=True)
        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        subprocess.run(
            ["git", "-c", "user.email=t@t", "-c", "user.name=t",
             "commit", "-qm", "init"], cwd=root, check=True,
        )
        (root / ".flydocs" / "session" / "default" / "focus.md").write_text(
            "FLY-999\nWIP\n"
        )
        rc = _run_stop_gate(root)
        assert rc == 2, f"Expected 2 in dirty repo, got {rc}"


@test("IMPLEMENTING gate fires on staged-only changes")
def _():
    import subprocess
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "IMPLEMENTING")
        subprocess.run(["git", "init", "-q"], cwd=root, check=True)
        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        subprocess.run(
            ["git", "-c", "user.email=t@t", "-c", "user.name=t",
             "commit", "-qm", "init"], cwd=root, check=True,
        )
        (root / "new-file.txt").write_text("staged\n")
        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        rc = _run_stop_gate(root)
        assert rc == 2, f"Expected 2 with staged changes, got {rc}"


@test("stop gate fails open when status-ref names a different issue (FLY-1064)")
def _():
    import subprocess
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        # Same staged-changes setup that fires above, but the status belongs
        # to another issue — the gate must not judge the focused one by it.
        _make_session(root, "IMPLEMENTING", status_ref="FLY-1070")
        subprocess.run(["git", "init", "-q"], cwd=root, check=True)
        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        subprocess.run(
            ["git", "-c", "user.email=t@t", "-c", "user.name=t",
             "commit", "-qm", "init"], cwd=root, check=True,
        )
        (root / "new-file.txt").write_text("staged\n")
        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        rc = _run_stop_gate(root)
        assert rc == 0, f"Expected 0 on ref mismatch, got {rc}"


@test("stop gate fails open when status-ref is absent entirely (FLY-1064)")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "READY", status_ref="")
        rc = _run_stop_gate(root)
        assert rc == 0, f"Expected 0 without status-ref, got {rc}"


print("\n## session.py wrap-body validation (FLY-990)")


def _load_session_module():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "session", SCRIPT_DIR / "session.py"
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


@test("validate_wrap_body passes when all required sections present")
def _():
    session = _load_session_module()
    body = "## Accomplished\n- x\n## Next up\n- y\n## Blockers & open questions\nNone\n## Progress\n1 closed"
    assert session.validate_wrap_body(body) == [], "expected no missing sections"


@test("validate_wrap_body reports a missing section")
def _():
    session = _load_session_module()
    body = "## Accomplished\n- x\n## Next up\n- y\n## Progress\n1 closed"
    missing = session.validate_wrap_body(body)
    assert missing == ["Blockers"], f"expected ['Blockers'], got {missing}"


@test("validate_wrap_body accepts Blockers section with 'None' content")
def _():
    session = _load_session_module()
    body = "## Accomplished\nx\n## Next up\ny\n## Blockers\nNone\n## Progress\nz"
    assert session.validate_wrap_body(body) == []


@test("validate_wrap_body does not require Notes")
def _():
    session = _load_session_module()
    body = "## Accomplished\nx\n## Next up\ny\n## Blockers\nNone\n## Progress\nz"
    assert "Notes" not in session.validate_wrap_body(body)


@test("validate_wrap_body recognizes bold (**Heading**) style")
def _():
    session = _load_session_module()
    body = "**Accomplished**\nx\n**Next up**\ny\n**Blockers**\nNone\n**Progress**\nz"
    assert session.validate_wrap_body(body) == []


@test("validate_wrap_body treats empty body as all sections missing")
def _():
    session = _load_session_module()
    assert set(session.validate_wrap_body("")) == set(
        session.REQUIRED_WRAP_SECTIONS
    )


print("\n## issues.py PR body rendering (FLY-998)")

_PR_TEMPLATE = (
    "## Summary\n\nResolves {ISSUE_REF}\n\n{ISSUE_SUMMARY}\n\n"
    "## Changes\n\n<!-- c -->\n\n- {CHANGE_1}\n- {CHANGE_2}\n\n"
    "## Test Plan\n\n<!-- t -->\n\n- [ ] {TEST_1}\n- [ ] {TEST_2}\n\n"
    "## Acceptance Criteria\n\n{ACCEPTANCE_CRITERIA}\n\n"
    "## Notes\n\n<!-- n -->\n\n{NOTES}\n"
)


def _load_issues_module():
    import importlib.util
    spec = importlib.util.spec_from_file_location("issues", SCRIPT_DIR / "issues.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


@test("_bitbucket_repo_from_remote reads workspace/slug from every Bitbucket remote shape (FLY-1543)")
def _():
    m = _load_issues_module()
    for url in (
        "git@bitbucket.org:squirrels/ditto-sender-android.git",
        "ssh://git@bitbucket.org/squirrels/ditto-sender-android.git",
        "https://dev@bitbucket.org/squirrels/ditto-sender-android.git",
        "https://bitbucket.org/squirrels/ditto-sender-android",
    ):
        assert m._bitbucket_repo_from_remote(url) == "squirrels/ditto-sender-android", url
    assert m._bitbucket_repo_from_remote("git@github.com:plastrlab/flydocs-core.git") is None
    assert m._bitbucket_repo_from_remote("") is None


@test("render_pr_body emits no hollow change bullets or empty checkboxes")
def _():
    m = _load_issues_module()
    out = m.render_pr_body(
        _PR_TEMPLATE, "FLY-1", "Title", "See issue.", [], [], None
    )
    assert "- {CHANGE" not in out and "{TEST" not in out
    assert "\n- \n" not in out, "hollow change bullet leaked"
    assert "- [ ] \n" not in out and "- [ ]\n" not in out, "empty checkbox leaked"
    assert "_Describe how to verify this works._" in out, "missing test-plan hint"


@test("render_pr_body fills Changes bullets when provided")
def _():
    m = _load_issues_module()
    out = m.render_pr_body(
        _PR_TEMPLATE, "FLY-1", "T", "AC", ["did X", "did Y"], ["run tests"], None
    )
    assert "- did X" in out and "- did Y" in out
    assert "- [ ] run tests" in out


@test("render_pr_body drops the Notes section when empty")
def _():
    m = _load_issues_module()
    out = m.render_pr_body(_PR_TEMPLATE, "FLY-1", "T", "AC", ["c"], ["t"], None)
    assert "## Notes" not in out
    out2 = m.render_pr_body(_PR_TEMPLATE, "FLY-1", "T", "AC", ["c"], ["t"], "watch out")
    assert "## Notes" in out2 and "watch out" in out2


@test("_split_items splits on newlines and semicolons, strips leading dashes")
def _():
    m = _load_issues_module()
    assert m._split_items("a; b\n- c") == ["a", "b", "c"]
    assert m._split_items(None) == []
    assert m._split_items("  ") == []


# ---------------------------------------------------------------------------
# stop-gate.py: acceptance criteria read live, not from a snapshot (FLY-1065)
# ---------------------------------------------------------------------------

print("\n## stop-gate.py live acceptance criteria (FLY-1065)")


def _load_stop_gate_module():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "stop_gate", HOOKS_DIR / "stop-gate.py"
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _criteria_doc(checked: int, unchecked: int = 0, deferred: int = 0) -> str:
    """Build an issue description with the given mix of criteria."""
    lines = ["## Acceptance Criteria", ""]
    lines += [f"- [x] met criterion {i}" for i in range(checked)]
    lines += [f"- [ ] unmet criterion {i}" for i in range(unchecked)]
    lines += [f"- [ ] (deferred: FLY-9{i:03d}) deferred criterion {i}"
              for i in range(deferred)]
    return "\n".join(lines) + "\n"


def _run_gate_in_process(mod, root: Path, live_description) -> tuple[int, str]:
    """Drive stop-gate's main() with a stubbed live description fetch.

    Returns (exit_code, stdout). The fetch is stubbed because the real one calls
    the relay; the point under test is what the gate DOES with the answer. The
    stub returns a description rather than counts so the real counting and
    deferral-detection logic is exercised.
    """
    import io
    import contextlib
    payload = json.dumps({"cwd": str(root)})
    cwd = os.getcwd()
    buf = io.StringIO()
    try:
        with patch.object(mod, "fetch_live_description", lambda *a, **k: live_description), \
             patch.object(sys, "stdin", io.StringIO(payload)), \
             contextlib.redirect_stdout(buf):
            try:
                mod.main()
                code = 0
            except SystemExit as e:
                code = e.code if isinstance(e.code, int) else 0
    finally:
        os.chdir(cwd)
    return code, buf.getvalue()


@test("count_criteria counts checked and total, case-insensitively")
def _():
    m = _load_stop_gate_module()
    assert m.count_criteria("- [x] a\n- [ ] b\n- [X] c") == (2, 3)
    assert m.count_criteria("no criteria here") == (0, 0)


@test("REVIEW gate passes on live-complete criteria despite a stale snapshot")
def _():
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "REVIEW")
        # A stale snapshot claiming nothing is done — must be ignored entirely.
        session = root / ".flydocs" / "session" / "default"
        (session / "acceptance-criteria.md").write_text(
            "# Acceptance Criteria\n\n- [ ] a\n- [ ] b\n- [ ] c\n"
        )
        code, _ = _run_gate_in_process(m, root, _criteria_doc(checked=3))
        assert code == 0, f"Expected pass on live-complete criteria, got {code}"


@test("REVIEW gate still blocks when live criteria are genuinely unchecked")
def _():
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "REVIEW")
        # A stale snapshot claiming everything is done must not let it through.
        session = root / ".flydocs" / "session" / "default"
        (session / "acceptance-criteria.md").write_text("- [x] a\n- [x] b\n")
        code, _ = _run_gate_in_process(m, root, _criteria_doc(checked=1, unchecked=2))
        assert code == 2, f"Expected block on incomplete criteria, got {code}"


@test("REVIEW gate degrades to a warning when the provider is unreachable")
def _():
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "REVIEW")
        code, out = _run_gate_in_process(m, root, None)
        assert code == 0, f"Provider outage must not block, got {code}"
        assert "skipped, not passed" in out, \
            f"Expected an explicit non-blocking warning, got: {out!r}"


@test("REVIEW gate passes when the issue has no acceptance criteria at all")
def _():
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "REVIEW")
        code, _ = _run_gate_in_process(m, root, _criteria_doc(checked=0))
        assert code == 0, f"No criteria means nothing to block on, got {code}"


@test("count_deferred requires an issue reference (FLY-1087)")
def _():
    m = _load_stop_gate_module()
    assert m.count_deferred("- [ ] (deferred: FLY-1108) needs deploy") == 1
    assert m.count_deferred("- [ ] (deferred: fly-1108) lowercase ref") == 1
    # A deferral with no destination is an unfinished criterion with manners.
    assert m.count_deferred("- [ ] (deferred) no destination given") == 0
    assert m.count_deferred("- [ ] deferred until later, honest") == 0
    # A checked box is not a deferral.
    assert m.count_deferred("- [x] (deferred: FLY-1) already met") == 0


@test("REVIEW gate passes when the shortfall is all deferred (FLY-1087)")
def _():
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "REVIEW")
        code, _ = _run_gate_in_process(
            m, root, _criteria_doc(checked=8, deferred=1)
        )
        assert code == 0, f"Deferred criterion should not block, got {code}"


@test("REVIEW gate still blocks a plain unchecked criterion (FLY-1087)")
def _():
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "REVIEW")
        code, _ = _run_gate_in_process(
            m, root, _criteria_doc(checked=8, unchecked=1, deferred=1)
        )
        assert code == 2, f"An unmet criterion must still block, got {code}"


@test("REVIEW gate blocks a deferral with no destination (FLY-1087)")
def _():
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "REVIEW")
        doc = "- [x] met\n- [ ] (deferred) no destination\n"
        code, _ = _run_gate_in_process(m, root, doc)
        assert code == 2, "Deferral without a named issue must not qualify"


@test("gate message always states the deferral contract (FLY-1087, FLY-1473)")
def _():
    m = _load_stop_gate_module()
    # The contract is not a syntax reminder that can be dropped once one
    # deferral is in use: "the destination must be real, the box stays
    # unchecked" is what stops a fabricated ref from walking an issue past the
    # gate. It prints either way (FLY-1473).
    for deferred, total in ((0, 5), (1, 6)):
        msg = m.render_unmet_hint(
            "FLY-123", done=3, deferred=deferred, total=total, tier="cloud"
        )
        assert "(deferred: FLY-xxxx)" in msg, msg
        assert "destination" in msg and "stays unchecked" in msg, msg
    # The shortfall summary still reports how many are already deferred.
    with_deferral = m.render_unmet_hint(
        "FLY-123", done=3, deferred=1, total=6, tier="cloud"
    )
    assert "1 deferred" in with_deferral, with_deferral
    assert "2 of 6 acceptance criteria unmet" in with_deferral, with_deferral


@test("unmet-criteria hint leads with the acceptance route on cloud tier (FLY-1473)")
def _():
    m = _load_stop_gate_module()
    msg = m.render_unmet_hint(
        "FLY-123", done=3, deferred=0, total=5, tier="cloud"
    )
    # Surface order follows AGENTS.md rule 1: MCP is the hot path, the runner
    # is the fallback, the raw script is the last resort.
    order = [
        msg.index("issue_acceptance_update"),
        msg.index("flydocs run issue.acceptance FLY-123 --check N"),
        msg.index("issues.py acceptance FLY-123 --check N"),
        msg.index("issue.description FLY-123 --file"),
    ]
    assert order == sorted(order), f"surface order is wrong: {msg!r}"
    # The whole-description rewrite stays available, but as the fallback for
    # prose — it is never the way to tick a box (AGENTS.md rule 4).
    # FLY-1470: the rendered command leads with the content guard, because the
    # revision token trips on a status change that touched no prose — which is
    # exactly what an agent has just done by the time this gate fires.
    assert (
        "  flydocs run issue.description FLY-123 --file <PATH> "
        "--expected-description-hash <HASH> [--repo <NAME>]\n"
    ) in msg, msg
    assert "--file <PATH> --expected-revision" not in msg, \
        f"the revision belongs in the prose now, not on the command line: {msg!r}"
    # Still named, as the fallback that also satisfies the refusal.
    assert "--expected-revision <REVISION>" in msg, msg
    assert "FLY-1468" in msg and "FLY-1470" in msg, msg


@test("unmet-criteria hint leads with the description route on local tier (FLY-1473)")
def _():
    m = _load_stop_gate_module()
    msg = m.render_unmet_hint(
        "FLY-123", done=3, deferred=0, total=5, tier="local"
    )
    # `issues.py acceptance` hard-fails off cloud, so leading with it would
    # hand every local-tier agent a command that cannot work. Runner first,
    # raw script as the fallback (AGENTS.md rule 1).
    assert (
        "  flydocs run issue.description FLY-123 --file <PATH> "
        "[--repo <NAME>]\n"
    ) in msg, msg
    assert msg.index("flydocs run issue.description") < msg.index(
        "issues.py description"
    ), f"runner must lead the script: {msg!r}"
    assert "--file <PATH> --expected-revision" not in msg, msg
    # The revision token is a relay concept: claiming the write is refused
    # without it would be a lie on this tier (FLY-1468 is cloud-only).
    assert "FLY-1468" not in msg, msg
    assert "cloud-only" in msg, msg
    assert "--check N" not in msg, msg


@test("an unread tier gets the cloud route plus an honest caveat (FLY-1473)")
def _():
    m = _load_stop_gate_module()
    # Guessing "local" on a cloud workspace is the dead end: a tokenless
    # --file rewrite hard-fails there, so the message would carry no working
    # route at all. Guessing "cloud" on local fails self-describingly —
    # `issues.py acceptance` prints the local instruction itself. So an
    # unrecognised tier leads with cloud and says the tier is unread.
    for tier in ("unknown", "", "something-new"):
        msg = m.render_unmet_hint(
            "FLY-123", done=3, deferred=0, total=5, tier=tier
        )
        assert "flydocs run issue.acceptance FLY-123 --check N" in msg, msg
        assert "Tier unread" in msg, msg
        assert "flydocs run issue.description FLY-123 --file <PATH>" in msg, msg
        # The local-tier claims are false on cloud — they must not appear.
        assert "ignored here" not in msg, msg
        assert "cloud-only" not in msg, msg


@test("unmet-criteria hint renders as runnable commands (FLY-1473)")
def _():
    m = _load_stop_gate_module()
    # Placeholder budget per branch. Cloud (FLY-1470): <PATH> and <HASH> twice
    # each (command + the line saying where the value comes from), <REVISION>
    # once in the prose that names the fallback, and <NAME> on the three runner
    # lines and its explanation. Local: <PATH> three times, <NAME> twice.
    # Unknown: cloud plus the caveat's <PATH>. Counting both brackets catches a
    # stray `<` as well as a multi-word placeholder.
    for tier, brackets in (("cloud", 9), ("local", 5), ("unknown", 10)):
        msg = m.render_unmet_hint(
            "FLY-123", done=3, deferred=0, total=5, tier=tier
        )
        assert msg.count("<") == msg.count(">") == brackets, \
            f"{tier}: unexpected bracket count: {msg!r}"
        # A placeholder carrying a space or a backtick cannot be pasted into a
        # shell — the reader has to decode the hint before running it.
        for placeholder in re.findall(r"<[^>\n]*>", msg):
            assert re.fullmatch(r"<[A-Za-z0-9_-]+>", placeholder), \
                f"{placeholder} is not a single-token placeholder: {msg!r}"
        # And a placeholder is only usable if the message says where it comes
        # from.
        assert "issues.py get FLY-123" in msg, msg
        # No pasteable criterion number and no pasteable destination ref: a
        # literal `--defer 4:FLY-1234` is a working command that writes a
        # deferral to whatever issue that happens to be, and count_deferred
        # then honours it — a gate bypass printed by the gate itself.
        assert not re.search(r"--check \d", msg), msg
        assert not re.search(r"--defer \d+:", msg), msg
        assert not re.search(r"--defer N:FLY-\d", msg), msg
        # A label on the command line makes the line unpasteable.
        for line in msg.splitlines():
            if line.startswith("  ") and "flydocs run " in line:
                assert line.lstrip().startswith("flydocs run "), \
                    f"labelled command line: {line!r}"


@test("cloud acceptance command lines render verbatim and abstract (FLY-1473)")
def _():
    m = _load_stop_gate_module()
    msg = m.render_unmet_hint(
        "FLY-123", done=3, deferred=0, total=5, tier="cloud"
    )
    # `flydocs run` resolves the repo from the cwd's ancestry, so the flag is
    # part of the advertised command (src/lib/run/repo.ts). The condition is
    # cwd-shaped, not topology-shaped: a worktree or the workspace root needs
    # it, while a root carrying a stale `.flydocs/config.json` (FLY-723) is
    # silently accepted without it — so the message describes the cwd.
    for line in (
        "  flydocs run issue.acceptance FLY-123 --check N [--repo <NAME>]\n",
        "  flydocs run issue.acceptance FLY-123 --defer N:FLY-xxxx "
        "[--repo <NAME>]\n",
    ):
        assert line in msg, f"missing verbatim line {line!r}: {msg!r}"
    assert "not inside the repo you mean" in msg, msg
    assert "multi-repo" not in msg, \
        f"the --repo condition is about the cwd, not the topology: {msg!r}"


@test("the gate hint follows the tier of the repo the commands will run in (FLY-1473)")
def _():
    import io, contextlib
    m = _load_stop_gate_module()

    def _stderr_for(build) -> str:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _make_session(root, "REVIEW")
            build(root)
            err = io.StringIO()
            with contextlib.redirect_stderr(err):
                code, _ = _run_gate_in_process(
                    m, root, _criteria_doc(checked=3, unchecked=2)
                )
            assert code == 2, f"unmet criteria must block, got {code}"
            return err.getvalue()

    def _config(root: Path, tier: str) -> None:
        (root / ".flydocs" / "config.json").write_text(json.dumps({"tier": tier}))

    cloud = _stderr_for(lambda root: _config(root, "cloud"))
    assert "flydocs run issue.acceptance FLY-999 --check N" in cloud, cloud

    local = _stderr_for(lambda root: _config(root, "local"))
    assert "flydocs run issue.description FLY-999 --file <PATH>" in local, local
    assert "issue.acceptance FLY-999 --check" not in local, local

    # No config anywhere: the tier is unread, not local.
    unread = _stderr_for(lambda root: None)
    assert "Tier unread" in unread, unread
    assert "cloud-only" not in unread, unread


@test("multi-repo: the tier comes from the child repo, not the stale root (FLY-1473)")
def _():
    import io, contextlib
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "REVIEW")
        # The workspace-root `.flydocs/config.json` is a pre-multi-repo
        # leftover that flydocs_api refuses to trust (FLY-723); the authority
        # is the child repo's, which is what `issues.py` — and therefore every
        # command this message prints — resolves to.
        (root / ".flydocs" / "config.json").write_text(
            json.dumps({"tier": "local"})
        )
        child = root / "child"
        (child / ".flydocs").mkdir(parents=True)
        (child / ".flydocs" / "config.json").write_text(
            json.dumps({"tier": "cloud"})
        )
        (root / ".flydocs-workspace.json").write_text(
            json.dumps({"repos": {"child": {"path": "./child"}}})
        )
        # No `active-repo` pointer: it is written only by the file-edit hook,
        # so any session that reaches REVIEW without one has none.
        assert not (root / ".flydocs" / "session" / "active-repo").exists()

        err = io.StringIO()
        with contextlib.redirect_stderr(err):
            code, _ = _run_gate_in_process(
                m, root, _criteria_doc(checked=3, unchecked=2)
            )
        assert code == 2, f"unmet criteria must block, got {code}"
        out = err.getvalue()
        assert "flydocs run issue.acceptance FLY-999 --check N" in out, out
        assert "cloud-only" not in out, \
            f"the stale root config must not decide the tier: {out!r}"


@test("fetch_live_description returns None rather than raising when unreachable")
def _():
    m = _load_stop_gate_module()
    with tempfile.TemporaryDirectory() as tmp:
        # No .flydocs config here, so the real script cannot authenticate.
        assert m.fetch_live_description("FLY-999", Path(tmp)) is None


# ---------------------------------------------------------------------------
# post-transition-check.py: authoritative status, not a stale mirror (FLY-1064)
# ---------------------------------------------------------------------------

print("\n## post-transition-check.py authoritative status (FLY-1064)")


def _load_post_transition_module():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "post_transition_check", HOOKS_DIR / "post-transition-check.py"
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _transition_payload(ref: str, target: str, previous: str, new: str,
                        cwd: Path, success: bool = True) -> dict:
    """Build a realistic PostToolUse payload for a transition command.

    Mirrors the payload Claude Code actually sends: the command output lands
    under `tool_response.stdout` (NOT `tool_result`).
    """
    body = {"success": success, "issue": ref,
            "previousStatus": previous, "newStatus": new}
    return {
        "hook_event_name": "PostToolUse",
        "tool_name": "Bash",
        "cwd": str(cwd),
        "tool_input": {"command": (
            f"python3 .claude/skills/flydocs-workflow/scripts/issues.py "
            f'transition {ref} {target} "some comment"'
        )},
        "tool_response": {"stdout": json.dumps(body), "stderr": ""},
    }


def _run_post_transition(payload: dict, cwd: Path) -> str:
    """Run the hook against a payload, return its additionalContext (or '')."""
    import subprocess
    result = subprocess.run(
        [sys.executable, str(HOOKS_DIR / "post-transition-check.py")],
        input=json.dumps(payload), capture_output=True, text=True,
        timeout=15, cwd=str(cwd),
    )
    try:
        out = json.loads(result.stdout or "{}")
    except (json.JSONDecodeError, ValueError):
        return f"<unparseable stdout: {result.stdout!r}>"
    return out.get("hookSpecificOutput", {}).get("additionalContext", "")


def _run_auto_approve_edit(root: Path, file_name: str = "some_file.py") -> dict:
    """Run auto-approve.py over an Edit, return its parsed stdout payload.

    The hook's answer travels in `hookSpecificOutput.additionalContext`, so
    the delivery is only tested by running the hook and reading its stdout —
    calling `check_workflow_state_for_edit` alone tests the sentence, not
    whether anyone receives it.
    """
    import subprocess
    result = subprocess.run(
        [sys.executable, str(HOOKS_DIR / "auto-approve.py")],
        input=json.dumps({
            "hook_event_name": "PreToolUse", "tool_name": "Edit",
            "tool_input": {"file_path": str(root / file_name)},
        }),
        capture_output=True, text=True, timeout=15, cwd=str(root),
        env={**os.environ, "CLAUDE_PROJECT_DIR": str(root)},
    )
    try:
        return json.loads(result.stdout or "{}")
    except (json.JSONDecodeError, ValueError):
        return {"<unparseable>": result.stdout}


def _seed_mirror(root: Path, status: str, ref: str | None) -> Path:
    """Seed the local status mirror, optionally with the ref it describes."""
    session = root / ".flydocs" / "session" / "default"
    session.mkdir(parents=True, exist_ok=True)
    (session / "status").write_text(status)
    if ref:
        (session / "status-ref").write_text(ref)
    return session


@test("canonical_status maps provider names and passes canonical through")
def _():
    m = _load_post_transition_module()
    assert m.canonical_status("In Progress") == "IMPLEMENTING"
    assert m.canonical_status("In Review") == "REVIEW"
    assert m.canonical_status("Backlog") == "BACKLOG"
    assert m.canonical_status("Done") == "COMPLETE"
    assert m.canonical_status("IMPLEMENTING") == "IMPLEMENTING"
    assert m.canonical_status("") is None
    assert m.canonical_status(None) is None
    assert m.canonical_status("Not A Status") is None


@test("authoritative previousStatus overrides a mirror naming another issue")
def _():
    # The exact false positive seen on 2026-07-25: mirror held IMPLEMENTING
    # from an unrelated issue while FLY-1064 genuinely moved Backlog -> In
    # Progress, producing "Unusual transition: IMPLEMENTING -> IMPLEMENTING".
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _seed_mirror(root, "IMPLEMENTING", "FLY-9999")
        msg = _run_post_transition(
            _transition_payload("FLY-1064", "IMPLEMENTING", "Backlog",
                                "In Progress", root), root)
        assert msg == "", f"Expected no warning, got: {msg}"


@test("two-issue transition sequence yields zero false warnings")
def _():
    # The regression that fired ~8 times in one session: each transition was
    # compared against the previous *issue's* status.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        sequence = [
            ("FLY-1010", "REVIEW", "In Progress", "In Review"),
            ("FLY-1011", "REVIEW", "In Progress", "In Review"),
            ("FLY-1010", "COMPLETE", "In Review", "Done"),
            ("FLY-1011", "COMPLETE", "In Review", "Done"),
        ]
        for ref, target, prev, new in sequence:
            msg = _run_post_transition(
                _transition_payload(ref, target, prev, new, root), root)
            assert msg == "", f"{ref} {prev}->{new} warned: {msg}"


@test("mirror describing a different issue is ignored, not compared")
def _():
    # No authoritative output available; the mirror belongs to another issue,
    # so the honest result is silence rather than an invented comparison.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _seed_mirror(root, "COMPLETE", "FLY-9999")
        payload = _transition_payload("FLY-1064", "REVIEW", "", "", root)
        payload["tool_response"] = {"stdout": "", "stderr": ""}
        msg = _run_post_transition(payload, root)
        assert msg == "", f"Expected silence on ref mismatch, got: {msg}"


@test("gate still fails closed on a genuinely unusual transition")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        msg = _run_post_transition(
            _transition_payload("FLY-1064", "REVIEW", "Backlog",
                                "In Review", root), root)
        assert "Unusual transition" in msg, f"Expected a warning, got: {msg}"
        assert "BACKLOG -> REVIEW" in msg, f"Wrong states reported: {msg}"


@test("ref-matched mirror is still used when no authoritative output exists")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _seed_mirror(root, "BACKLOG", "FLY-1064")
        payload = _transition_payload("FLY-1064", "REVIEW", "", "", root)
        payload["tool_response"] = {"stdout": "", "stderr": ""}
        msg = _run_post_transition(payload, root)
        assert "Unusual transition" in msg, f"Expected fallback warning: {msg}"


@test("a re-run that lands on the same state is not called unusual (FLY-1265)")
def _():
    # The regression this suite exists to catch a second time. Every canonical
    # status became a key in VALID_TRANSITIONS, and the hook's `from_status in
    # VALID_TRANSITIONS` test had been doing double duty as "can work leave
    # this state?". The terminal states therefore started being judged, and
    # COMPLETE -> COMPLETE — a transition the relay accepts as SAME_STATE, i.e.
    # a success — began printing a warning at the agent.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        for target, provider in (("COMPLETE", "Done"), ("ARCHIVED", "Archived"),
                                 ("REVIEW", "In Review")):
            msg = _run_post_transition(
                _transition_payload("FLY-1064", target, provider, provider,
                                    root), root)
            assert msg == "", f"{target} -> {target} warned: {msg}"


@test("the hook does not warn on a revival out of a closed state (FLY-1265)")
def _():
    # ARCHIVED and CANCELED revive to BACKLOG (spec §4). Before the terminal
    # set narrowed, the hook could not judge these at all; now that it can, it
    # has to get them right.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        for provider in ("Archived", "Canceled"):
            msg = _run_post_transition(
                _transition_payload("FLY-1064", "BACKLOG", provider,
                                    "Backlog", root), root)
            assert msg == "", f"{provider} -> Backlog warned: {msg}"


@test("the hook still warns on leaving a genuinely terminal state (FLY-1265)")
def _():
    # The other side of the same fix: COMPLETE -> BACKLOG is not a no-op and
    # not a revival, it is a reopen, and there is no reopen edge (§4 D7).
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        msg = _run_post_transition(
            _transition_payload("FLY-1064", "BACKLOG", "Done", "Backlog",
                                root), root)
        assert "COMPLETE -> BACKLOG" in msg, f"Expected a warning, got: {msg}"


@test("hook and command judge an edge identically (FLY-1265)")
def _():
    # Two warnings about the same edge, worded for two different moments —
    # `issues.py` before the call, the hook after it. They must never disagree
    # about *whether* to warn, so they share one predicate rather than two
    # readings of the table.
    import status_vocab
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        cases = [
            ("COMPLETE", "Done", "BACKLOG", "Backlog"),
            ("ARCHIVED", "Archived", "BACKLOG", "Backlog"),
            ("BACKLOG", "Backlog", "REVIEW", "In Review"),
            ("IMPLEMENTING", "In Progress", "REVIEW", "In Review"),
            ("COMPLETE", "Done", "COMPLETE", "Done"),
        ]
        for source, source_native, target, target_native in cases:
            hook_warned = bool(_run_post_transition(
                _transition_payload("FLY-1064", target, source_native,
                                    target_native, root), root))
            command_warned = status_vocab.is_unusual_transition(source, target)
            assert hook_warned == command_warned, (
                f"{source} -> {target}: hook warned={hook_warned}, "
                f"command warned={command_warned}"
            )


@test("nothing is judged without a status on both sides (FLY-1265)")
def _():
    # An unknown or absent state degrades to silence, never to a warning about
    # a comparison that was never made.
    import status_vocab
    assert status_vocab.is_unusual_transition(None, "REVIEW") is False
    assert status_vocab.is_unusual_transition("", "REVIEW") is False
    assert status_vocab.is_unusual_transition("IMPLEMENTING", None) is False
    assert status_vocab.is_unusual_transition("NOT_A_STATUS", "REVIEW") is False
    # Case and padding are the caller's accident, not a reason to stay quiet.
    assert status_vocab.is_unusual_transition(" backlog ", "review") is True


# FLY-1186 moved every session-file write out of the post-transition hook and
# into `issues.py transition` — one fact, one writer. The mirror tests below
# still addressed the hook, so from that commit they asserted a contract no
# code had any longer, and the suite carried three red tests. They now follow
# the writer, and the hook keeps one test of its own: that it writes nothing.


def _run_writer_transition_full(root: Path, ref: str, target: str,
                                reached: str | None = None,
                                relay_fails: bool = False,
                                payload: dict | None = None,
                                mapping: dict | None = None,
                                patch_mapping: bool = True):
    """Drive the real writer — `issues.py transition` — against a temp session.

    `reached` is the status the relay reports landing on, which is not always
    the one asked for (fallbacks, provider mappings). `relay_fails` models a
    rejection: the API layer renders the error and exits, so `cmd_transition`
    never reaches its write block. `payload` (FLY-1356) supplies the whole
    relay reply verbatim, for the shapes `reached` cannot express — a
    provider-native status name, a `success: false` body, an absent `success`.
    `mapping` (FLY-1356) is the workspace's canonical -> provider mapping the
    writer resolves against; it is patched by default, to the shipped fallback,
    so no test depends on whichever `.flydocs/config.json` happens to sit above
    the checkout. `patch_mapping=False` leaves the real `_status_mapping` in
    place for the tests that are about *it* — those must control the config
    themselves.

    Returns a namespace: `.session`, `.code`, `.err`, `.out`.
    """
    import contextlib as _cl
    import io as _sio
    import types as _ty

    module = _load_issues_module()
    session = root / ".flydocs" / "session" / "default"
    resolved_mapping = (
        DEFAULT_STATUS_MAPPING if mapping is None else mapping
    )

    class _Client:
        def transition(self, ref_, status_, comment_, force=None):
            if relay_fails:
                raise SystemExit(1)
            if payload is not None:
                return payload
            return {"success": True, "newStatus": reached or status_}

    args = _ty.SimpleNamespace(ref=ref, status=target, comment="because",
                               force=None)
    out, err = _sio.StringIO(), _sio.StringIO()
    code = 0
    mapping_patch = (
        patch.object(module, "_status_mapping", lambda: resolved_mapping)
        if patch_mapping else _cl.nullcontext()
    )
    with patch.object(module, "_resolve_session_dir", lambda: session), \
            mapping_patch, \
            patch.object(module, "get_client", lambda: _Client()), \
            _cl.redirect_stdout(out), _cl.redirect_stderr(err):
        try:
            module.cmd_transition(args)
        except SystemExit as exit_error:
            code = exit_error.code or 0
    return _ty.SimpleNamespace(session=session, code=code,
                               err=err.getvalue(), out=out.getvalue())


def _run_writer_transition(root: Path, ref: str, target: str,
                           reached: str | None = None,
                           relay_fails: bool = False,
                           payload: dict | None = None,
                           mapping: dict | None = None,
                           patch_mapping: bool = True) -> Path:
    """`_run_writer_transition_full` for the tests that only read the files."""
    return _run_writer_transition_full(
        root, ref, target, reached=reached, relay_fails=relay_fails,
        payload=payload, mapping=mapping,
        patch_mapping=patch_mapping).session


@test("mirror records status AND the ref it describes (FLY-1186)")
def _():
    # Writing `status` without `status-ref` desynchronizes the pair that
    # issues.py's own transition gate and the stop gate both read.
    with tempfile.TemporaryDirectory() as tmp:
        session = _run_writer_transition(Path(tmp), "FLY-1064", "REVIEW")
        assert (session / "status").read_text().strip() == "REVIEW"
        assert (session / "status-ref").read_text().strip() == "FLY-1064"


@test("mirror records the status actually reached, not the one requested (FLY-1186)")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        # Asked for TESTING; the provider's workflow landed on REVIEW.
        session = _run_writer_transition(Path(tmp), "FLY-1064", "TESTING",
                                         reached="REVIEW")
        assert (session / "status").read_text().strip() == "REVIEW", \
            "Mirror must record the reached status, not the requested one"


@test("a failed transition does not write the mirror (FLY-1186)")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        session = _run_writer_transition(Path(tmp), "FLY-1064", "REVIEW",
                                         relay_fails=True)
        assert not (session / "status").exists(), \
            "Mirror must not record a state the provider never reached"
        assert not (session / "status-ref").exists()


@test("both closing statuses clear the whole session state (FLY-1186)")
def _():
    # COMPLETE and CANCELED are one branch in the writer, so they are one
    # contract here: an issue that stops being worked stops being the session's
    # subject, whichever way it stopped.
    for closing in ("COMPLETE", "CANCELED"):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _seed_mirror(root, "REVIEW", "FLY-1064")
            (root / ".flydocs" / "session" / "default" / "focus.md").write_text(
                "# Active Issue\n\nFLY-1064\n")
            session = _run_writer_transition(root, "FLY-1064", closing)
            for name in ("status", "status-ref", "focus.md"):
                assert not (session / name).exists(), \
                    f"{closing} left {name} behind"


@test("focus.md is written on IMPLEMENTING and on nothing else (FLY-1186)")
def _():
    # focus.md is the attribution subject, not a second copy of `status`.
    # Writing it on every transition would make REVIEW and BLOCKED claim the
    # focus that IMPLEMENTING owns.
    for target in ("REVIEW", "BLOCKED", "READY", "TESTING"):
        with tempfile.TemporaryDirectory() as tmp:
            session = _run_writer_transition(Path(tmp), "FLY-1064", target)
            assert not (session / "focus.md").exists(), \
                f"{target} must not claim the attributed focus"


@test("focus.md is never written from a ref the attribution schema rejects (FLY-1186)")
def _():
    # focus.md once held the literal string "--help" for a day, and every
    # attribution tuple that day recorded issue: null. Only a ref matching the
    # server's ISSUE_REF shape may reach the file.
    for bad_ref in ("--help", "not-a-ref", "FLY", "123"):
        with tempfile.TemporaryDirectory() as tmp:
            session = _run_writer_transition(Path(tmp), bad_ref, "IMPLEMENTING")
            assert not (session / "focus.md").exists(), \
                f"{bad_ref!r} reached focus.md — attribution would record null"


@test("a richer focus.md already naming this issue is left alone (FLY-1186)")
def _():
    # /activate writes a fuller focus.md (title, criteria, context). A
    # transition to IMPLEMENTING on the SAME issue must not flatten it back to
    # the bare ref — the transition adds nothing the file does not already say.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session_dir = root / ".flydocs" / "session" / "default"
        session_dir.mkdir(parents=True)
        rich = ("# Active Issue\n\nFLY-1064 — Post-transition hook regression\n"
                "\n## Acceptance Criteria\n\n- [ ] mirror pair stays in sync\n")
        (session_dir / "focus.md").write_text(rich)
        session = _run_writer_transition(root, "FLY-1064", "IMPLEMENTING")
        assert (session / "focus.md").read_text() == rich, \
            "an existing focus.md naming this issue was clobbered"


@test("the post-transition hook writes no session state (FLY-1186)")
def _():
    # The other half of single-writer: two writers of one fact is how focus.md
    # held a stale ref for a day while attribution recorded issue: null.
    #
    # Each payload gets its own root. Sharing one root hid the restoration of
    # the pre-FLY-1186 hook entirely: its terminal-state cleanup ran on the
    # COMPLETE payload and unlinked everything the earlier payloads had
    # written, so the assertions after the loop found a clean directory.
    for target, previous, new in (("REVIEW", "In Progress", "In Review"),
                                  ("IMPLEMENTING", "Backlog", "In Progress"),
                                  ("COMPLETE", "In Review", "Done")):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            _run_post_transition(
                _transition_payload("FLY-1064", target, previous, new, root),
                root)
            session = root / ".flydocs" / "session" / "default"
            for name in ("status", "status-ref", "focus.md"):
                assert not (session / name).exists(), \
                    (f"the hook wrote {name} on {target} — issues.py is the "
                     f"single writer")


@test("the post-transition hook clears no session state either (FLY-1186)")
def _():
    # The mirror image of the test above, and the one it needs to be honest:
    # a hook that only *deletes* is still a second writer of the same fact.
    # Seeded state must survive a COMPLETE payload untouched, because the hook
    # is not the thing that clears it — `issues.py transition` is.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-1064")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1064\n")
        _run_post_transition(
            _transition_payload("FLY-1064", "COMPLETE", "In Review", "Done",
                                root), root)
        for name in ("status", "status-ref", "focus.md"):
            assert (session / name).exists(), \
                f"the hook cleared {name} — that is the writer's job"


@test("acceptance-criteria snapshot is no longer written on IMPLEMENTING")
def _():
    # The snapshot was the stale source FLY-1065 removed; recreating it here
    # would reintroduce the defect from the writer side. focus.md, by
    # contrast, IS the writer's job on IMPLEMENTING (FLY-1186).
    with tempfile.TemporaryDirectory() as tmp:
        session = _run_writer_transition(Path(tmp), "FLY-1064", "IMPLEMENTING")
        assert not (session / "acceptance-criteria.md").exists(), \
            "Snapshot must not be recreated"
        assert "FLY-1064" in (session / "focus.md").read_text(), \
            "IMPLEMENTING must set the attributed focus"


print("\n## session-state writer honesty (FLY-1356)")

# The relay's transition reply has two shapes, and every payload below is one
# the server can actually emit (verified against flydocs-app):
#
#   adapter shape         — linear.ts ~876/921/965-975, jira.ts ~1256/1293/
#                           1345-1348. `newStatus`/`actualStatus` are
#                           PROVIDER-NATIVE ("In Review", "Done");
#                           `mappedFromFlydocsStatus` is `status.toUpperCase()`
#                           captured from the REQUEST before any resolution and
#                           echoed verbatim; `fallbackUsed` is hard-coded
#                           `false` on every shipped path (FLY-685).
#   reconciliation shape  — service.ts ~513-522. Only `success`,
#                           `previousStatus`, `newStatus`. No canonical answer.
#
# A workspace whose Linear workflow has no review or QA column gets
# REVIEW/TESTING/COMPLETE all pointed at "Done" by the dashboard's
# auto-propose (STATUS_FALLBACK in status-heuristic.ts ~140-151), and nothing
# validates uniqueness — so the inverse is legitimately many-to-one.
_DONE_ONLY_MAPPING = {
    "BACKLOG": "Backlog",
    "IMPLEMENTING": "In Progress",
    "REVIEW": "Done",
    "TESTING": "Done",
    "COMPLETE": "Done",
}


@test("the relay's canonical answer outranks a reverse lookup (FLY-1356)")
def _():
    # Reviewer row 1. Ranking `newStatus` above `mappedFromFlydocsStatus` and
    # canonicalizing it by scanning the mapping for the FIRST canonical key
    # whose provider name matched answered by dict order: "Done" resolved to
    # REVIEW, so a COMPLETED issue recorded REVIEW and the terminal clear
    # never fired — focus.md, status-ref and the acceptance snapshot all
    # survived on a closed issue, and REVIEW is a gated status, so the Stop
    # gate nagged about it forever. Silent, exit 0.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-1356")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1356\n")
        (session / "acceptance-criteria.md").write_text("- [ ] stale\n")
        run = _run_writer_transition_full(
            root, "FLY-1356", "COMPLETE", mapping=_DONE_ONLY_MAPPING,
            payload={"success": True, "issue": "FLY-1356",
                     "previousStatus": "In Progress", "newStatus": "Done",
                     "actualStatus": "Done", "fallbackUsed": False,
                     "mappedFromFlydocsStatus": "COMPLETE"})
        for name in ("focus.md", "status", "status-ref",
                     "acceptance-criteria.md"):
            assert not (run.session / name).exists(), \
                f"COMPLETE resolved to something else — {name} survived"
        assert run.code == 0


@test("a many-to-one provider name keeps the requested canonical (FLY-1356)")
def _():
    # Reviewer row 2. Same mapping, same provider name, different request —
    # and the answer must differ with it. The relay already said which
    # canonical target it resolved; "Done" agrees with TESTING here because
    # TESTING is one of the statuses this workspace points at "Done".
    with tempfile.TemporaryDirectory() as tmp:
        session = _run_writer_transition(
            Path(tmp), "FLY-1356", "TESTING", mapping=_DONE_ONLY_MAPPING,
            payload={"success": True, "issue": "FLY-1356",
                     "previousStatus": "In Review", "newStatus": "Done",
                     "actualStatus": "Done", "fallbackUsed": False,
                     "mappedFromFlydocsStatus": "TESTING"})
        assert (session / "status").read_text().strip() == "TESTING", \
            "the relay's canonical target must survive an ambiguous mapping"
        assert (session / "status-ref").read_text().strip() == "FLY-1356"


@test("mapping order cannot turn a TESTING move into a wipe (FLY-1356)")
def _():
    # Reviewer row 3. `statusMapping` is a JSON object, so its key order is
    # whatever the dashboard wrote. With COMPLETE first, a first-match reverse
    # lookup read "Done" as COMPLETE and DELETED the entire mirror on a
    # transition to TESTING. Key order must not be able to decide that.
    reordered = {"COMPLETE": "Done", "TESTING": "Done", "REVIEW": "Done",
                 "IMPLEMENTING": "In Progress", "BACKLOG": "Backlog"}
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-1356")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1356\n")
        _run_writer_transition(
            root, "FLY-1356", "TESTING", mapping=reordered,
            payload={"success": True, "issue": "FLY-1356",
                     "previousStatus": "In Review", "newStatus": "Done",
                     "actualStatus": "Done", "fallbackUsed": False,
                     "mappedFromFlydocsStatus": "TESTING"})
        assert (session / "focus.md").exists() and (session / "status").exists(), \
            "a TESTING transition deleted session state — key order decided it"
        assert (session / "status").read_text().strip() == "TESTING"


@test("a stale local mapping cannot veto the relay's answer (FLY-1356)")
def _():
    # `statusMapping` in .flydocs/config.json is a CACHE of the server's: it is
    # written only by `flydocs init` (init.ts ~664) and `flydocs update`
    # (runSync, sync.ts ~339) from a server response. A team that renames a
    # column in the dashboard leaves every un-synced checkout holding the old
    # name — so treating "the provider name is not what my mapping says" as a
    # contradiction wiped session state on EVERY transition for that team.
    # Here the local cache still says "In Progress"; the server column is now
    # "In Development"; the relay's own canonical answer is IMPLEMENTING.
    stale = dict(DEFAULT_STATUS_MAPPING)  # IMPLEMENTING -> "In Progress"
    with tempfile.TemporaryDirectory() as tmp:
        run = _run_writer_transition_full(
            Path(tmp), "FLY-1356", "IMPLEMENTING", mapping=stale,
            payload={"success": True, "issue": "FLY-1356",
                     "previousStatus": "Backlog",
                     "newStatus": "In Development",
                     "actualStatus": "In Development", "fallbackUsed": False,
                     "mappedFromFlydocsStatus": "IMPLEMENTING"})
        assert (run.session / "status").exists(), \
            "a mismatch means 'cannot check', not 'contradiction' — state wiped"
        assert (run.session / "status").read_text().strip() == "IMPLEMENTING"
        assert (run.session / "status-ref").read_text().strip() == "FLY-1356"
        assert "FLY-1356" in (run.session / "focus.md").read_text(), \
            "no focus.md means attribution records issue: null for the session"
        assert run.code == 0


@test("a workspace with no local statusMapping still records (FLY-1356)")
def _():
    # `statusMapping` is optional in the schema (types.ts ~157) — a checkout
    # that has never synced has none, and `_status_mapping` then falls back to
    # DEFAULT_STATUS_MAPPING, which is Linear's default template names and may
    # match nothing the workspace actually uses. The real `_status_mapping` is
    # left unpatched here so the fallback itself is exercised.
    import flydocs_api

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        (root / ".flydocs").mkdir(parents=True)
        (root / ".flydocs" / "config.json").write_text(
            json.dumps({"tier": "cloud", "configFormat": 3}))
        module = _load_issues_module()
        # Guarded: this test is about the fallback, not about the cache — an
        # uncached _status_mapping must fail ITS test, not this one.
        getattr(module._status_mapping, "cache_clear", lambda: None)()
        with patch.object(flydocs_api, "find_project_root", lambda: root):
            assert module._status_mapping() is DEFAULT_STATUS_MAPPING, \
                "a config without statusMapping must fall back, not crash"
            session = _run_writer_transition(
                root, "FLY-1356", "IMPLEMENTING", patch_mapping=False,
                payload={"success": True, "issue": "FLY-1356",
                         "previousStatus": "Backlog",
                         "newStatus": "Sprint Build Lane",
                         "actualStatus": "Sprint Build Lane",
                         "fallbackUsed": False,
                         "mappedFromFlydocsStatus": "IMPLEMENTING"})
        assert (session / "status").exists(), \
            "an absent local mapping wiped state on an ordinary transition"
        assert (session / "status").read_text().strip() == "IMPLEMENTING"
        assert "FLY-1356" in (session / "focus.md").read_text()
        getattr(module._status_mapping, "cache_clear", lambda: None)()


@test("a forced override to an unmapped column records nothing (FLY-1356)")
def _():
    # The one case where a mapping mismatch IS a contradiction rather than a
    # stale cache. `forceUsed` is the relay stating outright that it bypassed
    # the canonical mapping and resolved the provider state by name
    # (linear.ts ~925/970, jira.ts ~1297/1351) — so `mappedFromFlydocsStatus`
    # demonstrably does not describe where the issue landed, and the local
    # mapping's opinion about the forced column is not evidence either.
    mapping = {"TESTING": "QA", "COMPLETE": "Done", "REVIEW": "In Review"}
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-1356")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1356\n")
        run = _run_writer_transition_full(
            root, "FLY-1356", "TESTING", mapping=mapping,
            payload={"success": True, "issue": "FLY-1356",
                     "previousStatus": "In Review", "newStatus": "Done",
                     "actualStatus": "Done", "fallbackUsed": False,
                     "forceUsed": True, "forceTarget": "Done",
                     "mappedFromFlydocsStatus": "TESTING"})
        assert not (run.session / "status").exists(), \
            "a contradicted reply must not record either side of it"
        assert not (run.session / "status-ref").exists()
        assert (session / "focus.md").exists(), \
            "reading 'Done' as COMPLETE here closed the session on a guess"
        assert run.code == 0


@test("the reconciliation shape resolves an unambiguous provider name (FLY-1356)")
def _():
    # service.ts ~513-522 answers with only success/previousStatus/newStatus —
    # no `mappedFromFlydocsStatus` to lean on. A reverse lookup is all there
    # is, and it is safe exactly when the mapping sends one canonical status
    # to this provider name. The writer used to accept only a *canonical*
    # newStatus and otherwise fall back to the request, so this reply against
    # a requested TESTING recorded TESTING — a state the issue was never in.
    with tempfile.TemporaryDirectory() as tmp:
        session = _run_writer_transition(
            Path(tmp), "FLY-1356", "TESTING",
            payload={"success": True, "issue": "FLY-1356",
                     "reconciliation": "transitioned_comment_pending",
                     "previousStatus": "In Progress",
                     "newStatus": "In Review"})
        assert (session / "status").read_text().strip() == "REVIEW", \
            "an unambiguous provider name must canonicalize, not be discarded"
        assert (session / "status-ref").read_text().strip() == "FLY-1356"


@test("the reconciliation shape refuses an ambiguous provider name (FLY-1356)")
def _():
    # Same shape, a workspace where "Done" is three canonical statuses. There
    # is nothing in the reply to break the tie, so there is no answer to give.
    with tempfile.TemporaryDirectory() as tmp:
        run = _run_writer_transition_full(
            Path(tmp), "FLY-1356", "COMPLETE", mapping=_DONE_ONLY_MAPPING,
            payload={"success": True, "issue": "FLY-1356",
                     "reconciliation": "transitioned_comment_pending",
                     "previousStatus": "In Progress", "newStatus": "Done"})
        assert not (run.session / "status").exists(), \
            "an ambiguous reverse lookup must resolve to nothing, not to a pick"
        assert run.code == 0


@test("reached_status refuses ambiguity and honours the canonical answer")
def _():
    # FLY-1356, the resolver on its own — the writer's file effects are tested
    # above, this pins the decision table.
    module = _load_issues_module()
    with patch.object(module, "_status_mapping",
                      lambda: _DONE_ONLY_MAPPING):
        assert module.canonical_status("Done") is None, \
            "three canonical statuses map to 'Done' — that is not an answer"
        assert module.canonical_status("In Progress") == "IMPLEMENTING"
        # Unforced: the relay's canonical answer is a statement about where it
        # moved the issue, and a cache that cannot corroborate the provider
        # name is not grounds to overrule it.
        assert module.reached_status(
            {"newStatus": "Ready For Sign-off",
             "mappedFromFlydocsStatus": "BLOCKED"}) == "BLOCKED"
        # The drift case in miniature: the cache HAS an entry and it is the
        # old column name. Still not grounds to overrule the relay.
        assert module.reached_status(
            {"newStatus": "In Development",
             "mappedFromFlydocsStatus": "IMPLEMENTING"}) == "IMPLEMENTING", \
            "a cache holding the old column name vetoed the relay's answer"
        # Forced at a column the mapping does not name: a real contradiction.
        assert module.reached_status(
            {"newStatus": "Ready For Sign-off", "forceUsed": True,
             "mappedFromFlydocsStatus": "REVIEW"}) is None, \
            "forceUsed says the canonical target was not honoured — not a fact"
        # Forced at the column the mapping already named — the override
        # changed how the target resolved, not what it was.
        assert module.reached_status(
            {"newStatus": "Done", "forceUsed": True,
             "mappedFromFlydocsStatus": "REVIEW"}) == "REVIEW", \
            "a force that landed where the mapping points is not a divergence"
        # An empty reply confirms nothing.
        assert module.reached_status({"newStatus": ""}) is None
    with patch.object(module, "_status_mapping", lambda: {}):
        # A canonical name spelled verbatim is the local tier's own reply
        # shape and is not a guess.
        assert module.reached_status({"newStatus": "REVIEW"}) == "REVIEW"
        # TRIAGE is a source-only status: an issue can be in it, this writer
        # may never put it in the mirror.
        assert module.reached_status({"newStatus": "TRIAGE"}) is None


@test("an unresolvable reply clears status and status-ref (FLY-1356)")
def _():
    # "Write nothing" is not the same as "leave what is there". A mirror left
    # reading IMPLEMENTING after a TESTING transition that landed is the same
    # lie one move older — and bridge.py hands that file straight back to the
    # MCP caller as `sessionState.status`. Every consumer treats the missing
    # pair as unknown, so deleting it is how the writer says so.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-1356")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1356\n")
        run = _run_writer_transition_full(
            root, "FLY-1356", "REVIEW",
            payload={"success": True, "issue": "FLY-1356",
                     "reconciliation": "transitioned_comment_pending",
                     "previousStatus": "In Progress",
                     "newStatus": "Sprint Review Lane"})
        assert not (session / "status").exists(), \
            "the stale status was reported as current after a real transition"
        assert not (session / "status-ref").exists(), \
            "status without its ref is the half-written pair FLY-1064 removed"
        assert (session / "focus.md").exists(), \
            "which issue is being worked on is not what the reply put in doubt"
        assert run.code == 0, \
            "exit 1 would make agents retry a transition that already landed"
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        assert len(notes) == 1, \
            f"bridge.py makes one MCP warning per stderr line, got {notes}"
        assert "cleared status, status-ref" in notes[0], notes[0]


@test("an unresolved CLOSED target still clears the whole mirror (FLY-1356)")
def _():
    # Refusing an ambiguous "Done" is right, but it must not leave a closed
    # issue as the session's subject: main cleared all four files on a
    # COMPLETE, and focus.md surviving means the Stop gate and attribution
    # keep pointing at work that is off the board. The relay confirmed the
    # move — the only thing it fails to say is WHICH closed state — and that
    # distinction changes nothing about session state.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-1356")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1356\n")
        (session / "acceptance-criteria.md").write_text("- [ ] stale\n")
        run = _run_writer_transition_full(
            root, "FLY-1356", "COMPLETE", mapping=_DONE_ONLY_MAPPING,
            payload={"success": True, "issue": "FLY-1356",
                     "reconciliation": "transitioned_comment_pending",
                     "previousStatus": "In Review", "newStatus": "Done"})
        for name in ("focus.md", "status", "status-ref",
                     "acceptance-criteria.md"):
            assert not (session / name).exists(), \
                f"a closed issue kept {name} — it stays the session's subject"
        assert run.code == 0
        assert len([ln for ln in run.err.splitlines() if ln.strip()]) == 1


@test("a forced CLOSED target does not close the session (FLY-1356)")
def _():
    # The mirror image of the test above, and the one it needs to be honest.
    # `reached_status` returned None here precisely BECAUSE the relay said it
    # did not honour the requested COMPLETE — so reading that same target one
    # line later as "the issue is off the board" contradicts the reason this
    # branch was entered. The reply puts the issue in a review column.
    mapping = {"COMPLETE": "Done", "REVIEW": "In Review",
               "IMPLEMENTING": "In Progress"}
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-1356")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1356\n")
        (session / "acceptance-criteria.md").write_text("- [ ] unfinished\n")
        run = _run_writer_transition_full(
            root, "FLY-1356", "COMPLETE", mapping=mapping,
            payload={"success": True, "issue": "FLY-1356",
                     "previousStatus": "In Progress",
                     "newStatus": "In Review", "actualStatus": "In Review",
                     "fallbackUsed": False, "forceUsed": True,
                     "forceTarget": "In Review",
                     "mappedFromFlydocsStatus": "COMPLETE"})
        assert (session / "focus.md").exists(), \
            "a forced target the relay refused still closed the session"
        assert (session / "acceptance-criteria.md").exists(), \
            "the acceptance snapshot was discarded on an issue still open"
        assert not (session / "status").exists(), \
            "the unresolved status must still be cleared to unknown"
        assert not (session / "status-ref").exists()
        assert run.code == 0


@test("a 200 carrying success:false writes no session state (FLY-1356)")
def _():
    # Failure used to be modelled only as SystemExit, so a body that said the
    # transition had not happened still wrote the mirror for it.
    with tempfile.TemporaryDirectory() as tmp:
        run = _run_writer_transition_full(
            Path(tmp), "FLY-1356", "REVIEW",
            payload={"success": False, "newStatus": "REVIEW",
                     "error": "provider rejected the move"})
        assert run.code == 1, \
            f"a failed transition must exit non-zero, got {run.code}"
        assert not (run.session / "status").exists(), \
            "a transition that did not happen must not be mirrored"
        assert not (run.session / "status-ref").exists()
        assert "not confirmed" in run.err, run.err


@test("the failure body still reaches stdout (FLY-1356)")
def _():
    # post-transition-check.py reads the failure signal off this script's
    # stdout (`read_authoritative_statuses` ~161-162) and defaults `succeeded`
    # to True when the buffer is empty — so exiting through `fail()` before
    # `output_json` made the hook audit a failed transition as a successful
    # one. main printed the body; the fix must not have lost that.
    with tempfile.TemporaryDirectory() as tmp:
        run = _run_writer_transition_full(
            Path(tmp), "FLY-1356", "REVIEW",
            payload={"success": False, "issue": "FLY-1356",
                     "newStatus": "In Review",
                     "error": "provider rejected the move"})
        lines = [ln for ln in run.out.splitlines() if ln.strip()]
        assert lines, \
            "stdout was empty — the hook then defaults `succeeded` to True"
        body = json.loads(lines[-1])
        assert body["success"] is False, \
            "the failure signal never reached stdout — the hook reads it there"
        assert body["issue"] == "FLY-1356"


@test("an absent success field counts as failure (FLY-1356)")
def _():
    # Silence is not confirmation. The writer's whole contract is to record
    # what the relay confirmed, and an omitted field confirms nothing.
    with tempfile.TemporaryDirectory() as tmp:
        run = _run_writer_transition_full(
            Path(tmp), "FLY-1356", "REVIEW",
            payload={"newStatus": "REVIEW"})
        assert run.code == 1, f"absent success must fail, got exit {run.code}"
        assert not (run.session / "status").exists()
        assert not (run.session / "status-ref").exists()


@test("the status mapping is read once per process (FLY-1356)")
def _():
    # `bridge.py` composes several dispatcher calls in one interpreter
    # (`issue_activate` = assign + transition) and every candidate lookup used
    # to re-read and re-parse the same config file.
    import flydocs_api

    module = _load_issues_module()
    reads = []

    def _counting_root():
        reads.append(1)
        raise OSError("no config here")

    assert hasattr(module._status_mapping, "cache_clear"), \
        "_status_mapping is uncached — every lookup re-reads and re-parses it"
    with patch.object(flydocs_api, "find_project_root", _counting_root):
        module._status_mapping.cache_clear()
        first = module._status_mapping()
        second = module._status_mapping()
    assert first is second is DEFAULT_STATUS_MAPPING, \
        "an unreadable config must fall back to the shipped mapping"
    assert len(reads) == 1, f"config re-read {len(reads)} times"
    module._status_mapping.cache_clear()


@test("the API layer manufactures neither success nor newStatus (FLY-1356)")
def _():
    # `success` defaulted to True and `newStatus` defaulted to the requested
    # status, so a silent reply was rendered as a confirmed transition to
    # exactly what the caller asked for.
    from flydocs_api import FlyDocsClient

    class _SilentRelay:
        def post(self, path, body=None):
            return {}

    client = object.__new__(FlyDocsClient)
    client.tier = "cloud"
    client._relay = _SilentRelay()

    response = client.transition("FLY-1356", "REVIEW", "done")
    assert response["success"] is False, \
        "an omitted success must not be rendered as a success"
    assert response["newStatus"] == "", \
        "an omitted newStatus must not echo the request back as confirmation"


@test("a terminal transition clears the acceptance-criteria snapshot (FLY-1356)")
def _():
    # The snapshot is the stale source FLY-1065 removed; leaving it behind on
    # COMPLETE lets a closed issue's criteria be read as the live ones.
    for closing in ("COMPLETE", "CANCELED"):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            session = _seed_mirror(root, "REVIEW", "FLY-1356")
            (session / "acceptance-criteria.md").write_text("- [ ] stale\n")
            _run_writer_transition(root, "FLY-1356", closing)
            assert not (session / "acceptance-criteria.md").exists(), \
                f"{closing} left the acceptance-criteria snapshot behind"


print("\n## resolved terminal clear covers every closed status (FLY-1407)")


@test("every closed status clears the whole session mirror (FLY-1407)")
def _():
    # The resolved branch used to test `effective in ("COMPLETE", "CANCELED")`
    # while the unresolvable branch beside it tested `CLOSED_STATUSES` — the
    # same question asked two ways, and the narrower answer was the wrong one.
    # An ARCHIVED or DUPLICATE issue is off the board exactly as a COMPLETE one
    # is, so leaving focus.md, the mirror pair and the acceptance snapshot
    # behind kept a closed issue as the session's subject. The hook that then
    # nagged is `auto-approve.py`: it reads the stale `status` and checks it
    # against `EDIT_OK_STATUSES` — IMPLEMENTING/REVIEW/TESTING/COMPLETE/
    # CANCELED, and pointedly not the two statuses this branch adds — so every
    # edit drew "Issue X is in DUPLICATE, not IMPLEMENTING. Transition before
    # making changes" about an issue that was correctly closed. Not the Stop
    # gate, which exits on any status outside `GATED_STATUSES` and so was
    # silent on both. Attribution kept charging turns to it either way.
    #
    # The table is `status_vocab.CLOSED_STATUSES` spelled out on purpose. A
    # membership test written against the set would pass against a writer that
    # also used the set and say nothing about which statuses that set holds.
    #
    # Each row is seeded from a source that `VALID_TRANSITIONS` lists as
    # leading to its target. That is not decoration: IMPLEMENTING -> DUPLICATE
    # is not a listed edge, so seeding every row from IMPLEMENTING made the
    # writer emit an "Unusual transition" hint the harness silently discarded,
    # and the row would have gone on passing beside a warning nobody saw.
    import status_vocab
    rows = (
        ("REVIEW", "COMPLETE", True),
        ("IMPLEMENTING", "CANCELED", True),
        ("IMPLEMENTING", "ARCHIVED", True),
        ("BACKLOG", "DUPLICATE", True),
        # The control row: a status that is not closed must leave every one of
        # those files in place, or "clears on close" is just "clears".
        ("IMPLEMENTING", "REVIEW", False),
    )
    assert {target for _, target, closed in rows if closed} == \
        status_vocab.CLOSED_STATUSES, \
        "the table has drifted from CLOSED_STATUSES — add the new status here"
    for source, target, _closed in rows:
        assert not status_vocab.is_unusual_transition(source, target), \
            f"{source} -> {target} warns; the row would pass beside a hint"

    mirror = ("focus.md", "status", "status-ref", "acceptance-criteria.md")
    for source, target, closed in rows:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            session = _seed_mirror(root, source, "FLY-1407")
            (session / "focus.md").write_text("# Active Issue\n\nFLY-1407\n")
            (session / "acceptance-criteria.md").write_text("- [ ] stale\n")
            _run_writer_transition(root, "FLY-1407", target)
            for name in mirror:
                if closed:
                    assert not (session / name).exists(), \
                        (f"{target} left {name} behind — a closed issue stays "
                         f"the session's subject")
                else:
                    assert (session / name).exists(), \
                        f"{target} is not a closed status but cleared {name}"
    # And the open case must still record where it landed, not merely survive.
    with tempfile.TemporaryDirectory() as tmp:
        session = _run_writer_transition(Path(tmp), "FLY-1407", "REVIEW")
        assert (session / "status").read_text().strip() == "REVIEW"


@test("closing a DIFFERENT issue leaves the focused mirror alone (FLY-1407)")
def _():
    # The clear was unconditional: it deleted whatever the mirror held, never
    # asking whether the mirror was about the issue being closed. So closing
    # FLY-200 while working FLY-100 wiped FLY-100's focus.md, mirror pair and
    # acceptance snapshot — the session lost its subject to a transition on an
    # unrelated issue, and attribution recorded issue: null from there on.
    #
    # Widening the clear to ARCHIVED and DUPLICATE moves that hazard onto the
    # common path: "close these five as duplicates" is ordinary bulk triage,
    # and none of those five is the issue being worked.
    #
    # The guard is the one the transition-hint block twenty lines earlier
    # already applies (FLY-1064): `status-ref` says which issue the mirror
    # describes, and a mirror describing someone else is not this reply's to
    # touch.
    for target in ("DUPLICATE", "COMPLETE", "ARCHIVED", "CANCELED"):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
            (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
            (session / "acceptance-criteria.md").write_text("- [ ] live\n")
            _run_writer_transition(root, "FLY-200", target)
            for name in ("focus.md", "status", "status-ref",
                         "acceptance-criteria.md"):
                assert (session / name).exists(), \
                    (f"closing FLY-200 as {target} deleted FLY-100's {name} — "
                     f"the session lost a subject it was still working")
            assert (session / "status").read_text().strip() == "IMPLEMENTING", \
                "FLY-100's status was rewritten by a transition on FLY-200"
            assert (session / "status-ref").read_text().strip() == "FLY-100"


@test("a foreign focus.md survives a close the mirror pair invites (FLY-1407)")
def _():
    # Scenario A. `status-ref` speaks for `status` and for nothing else, so
    # keying the whole clear on it deletes two files it has no authority over.
    # Three ordinary steps reach the round-1 wipe again: work FLY-100, sweep
    # into a review of FLY-200 (an open-status write, which claims the mirror
    # pair and leaves focus.md alone), then complete FLY-200. `mirror_ref` now
    # says FLY-200, matches, and FLY-100's focus.md and acceptance snapshot go
    # with FLY-200's status pair. A review sweep across several issues is the
    # documented working pattern, not an exotic sequence.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        (session / "acceptance-criteria.md").write_text("- [ ] FLY-100 live\n")
        # FLY-1471 shut the door this precondition used to walk through: an
        # open-status transition on FLY-200 no longer claims a pair the focus
        # says belongs to FLY-100. The drifted state it produced stays
        # reachable by other routes — /activate writes focus.md for one issue
        # over a pair another issue left, an older client wrote the pair
        # unguarded — so the close guard still has this job, and the state is
        # seeded directly rather than manufactured by the writer.
        (session / "status").write_text("REVIEW")
        (session / "status-ref").write_text("FLY-200")
        _run_writer_transition(root, "FLY-200", "COMPLETE")
        assert (session / "focus.md").read_text().strip().endswith("FLY-100"), \
            "completing FLY-200 deleted the focus.md that names FLY-100"
        assert (session / "acceptance-criteria.md").exists(), \
            "completing FLY-200 discarded FLY-100's acceptance snapshot"
        for name in ("status", "status-ref"):
            assert not (session / name).exists(), \
                f"FLY-200 closed, but its own {name} survived"


@test("a lowercase ref in focus.md prose is not read as its owner (FLY-1407)")
def _():
    # `focus_ref` searched `text.upper()`, and no other reader of focus.md
    # does: `prompt-submit.py` (~126) and `auto-approve.py` (~168) both search
    # the raw text with a case-SENSITIVE pattern. So a lowercase ref mentioned
    # in prose was invisible to attribution and visible to the guard, and the
    # two disagreed about whose file it was — the round-1 wipe again, through a
    # narrower door: attribution charges FLY-100 while closing the incidental
    # fly-050 deletes FLY-100's focus.md and acceptance snapshot.
    #
    # The `.upper()` bought nothing. Everything the system writes into focus.md
    # is already uppercase: the write below gates on `fullmatch(ref_upper)`,
    # and /activate writes the same canonical ref.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
        (session / "focus.md").write_text(
            "# Active Issue\n\nsee fly-050 for context\n\n"
            "FLY-100 — the real subject\n")
        (session / "acceptance-criteria.md").write_text("- [ ] FLY-100 live\n")
        _run_writer_transition(root, "FLY-050", "DUPLICATE")
        assert (session / "focus.md").exists(), \
            ("closing the fly-050 mentioned in prose deleted the focus.md "
             "attribution reads as FLY-100")
        assert "FLY-100" in (session / "focus.md").read_text()
        assert (session / "acceptance-criteria.md").exists(), \
            "FLY-100's acceptance snapshot went with an unrelated close"
        # The one reader that matters agrees with the guard, by construction:
        # the same pattern over the same raw text picks the same owner.
        import re as _re
        module = _load_issues_module()
        assert _re.search(module.ISSUE_REF_RE,
                          (session / "focus.md").read_text()).group(0) == \
            "FLY-100", "the guard and attribution disagree about the owner"


@test("a foreign focus.md is cleared when its issue closes (FLY-1407)")
def _():
    # Scenario B, the inverse, and the reason the guard cannot simply be
    # widened to "leave everything when anything disagrees". Here the pair
    # tracks FLY-100 while focus.md names FLY-200; closing FLY-200 must clear
    # focus.md and the snapshot — the attributed subject is off the board —
    # while leaving FLY-100's status pair, which this reply says nothing about.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-200\n")
        (session / "acceptance-criteria.md").write_text("- [ ] FLY-200\n")
        _run_writer_transition(root, "FLY-200", "COMPLETE")
        assert not (session / "focus.md").exists(), \
            "focus.md still names FLY-200 — attribution keeps charging to it"
        assert not (session / "acceptance-criteria.md").exists(), \
            "a closed issue's acceptance snapshot reads as the live criteria"
        assert (session / "status").read_text().strip() == "IMPLEMENTING", \
            "FLY-100's status was cleared by a transition on FLY-200"
        assert (session / "status-ref").read_text().strip() == "FLY-100"


@test("an unresolvable close respects focus.md's own owner (FLY-1407)")
def _():
    # The same split on the other branch: with the pair tracking FLY-200 and
    # focus.md naming FLY-100, an unresolvable CLOSED reply about FLY-200 may
    # clear the pair it does speak for and must not touch the two files it
    # does not. The note has to report exactly that, since bridge.py shows it
    # to the agent as the account of what happened.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-200")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        (session / "acceptance-criteria.md").write_text("- [ ] FLY-100 live\n")
        run = _run_writer_transition_full(
            root, "FLY-200", "COMPLETE", mapping=_DONE_ONLY_MAPPING,
            payload={"success": True, "issue": "FLY-200",
                     "reconciliation": "transitioned_comment_pending",
                     "previousStatus": "In Review", "newStatus": "Done"})
        assert (session / "focus.md").exists(), \
            "an unresolvable close of FLY-200 deleted FLY-100's focus.md"
        assert (session / "acceptance-criteria.md").exists()
        for name in ("status", "status-ref"):
            assert not (session / name).exists(), \
                f"the pair describing the closed FLY-200 kept {name}"
        assert run.code == 0
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        assert len(notes) == 1, f"one stderr line, one MCP warning: {notes}"
        assert "cleared status, status-ref" in notes[0], \
            f"the note must list exactly what it cleared: {notes[0]}"
        assert "focus.md" not in notes[0], \
            f"the note claims a focus.md clear that did not happen: {notes[0]}"
        # FLY-1471 appends the consequence of that clear — FLY-100 is still
        # the subject and now has no status — but it may not turn into a
        # claim that focus.md was touched.
        assert "no recorded status for FLY-100" in notes[0], notes[0]


@test("a ref with surrounding whitespace is not read as foreign (FLY-1407)")
def _():
    # `args.ref.upper()` without a strip made " fly-100 " compare unequal to
    # the mirror's FLY-100, so the guard read the session's own issue as
    # someone else's and silently skipped the clear it was supposed to do.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        (session / "acceptance-criteria.md").write_text("- [ ] stale\n")
        _run_writer_transition(root, " fly-100 ", "COMPLETE")
        for name in ("focus.md", "status", "status-ref",
                     "acceptance-criteria.md"):
            assert not (session / name).exists(), \
                f"a padded ref made the writer treat its own {name} as foreign"


@test("an undecodable session file does not crash the writer (FLY-1407)")
def _():
    # The transition has already landed by the time these files are read, so
    # an exception here loses the mirror write for a move the relay confirmed
    # and exits non-zero on a success. A non-UTF-8 `status-ref` raises
    # UnicodeDecodeError, which `except OSError` does not catch.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-100")
        (session / "status-ref").write_bytes(b"\xff\xfe\x00FLY-100")
        (session / "focus.md").write_bytes(b"\xff\xfe\x00FLY-100")
        run = _run_writer_transition_full(root, "FLY-100", "COMPLETE")
        assert run.code == 0, \
            f"an unreadable mirror file failed a transition that landed: {run.err}"


@test("an unresolvable reply about a DIFFERENT issue clears nothing (FLY-1407)")
def _():
    # The same guard on the other branch. "Nothing in this reply resolves to a
    # canonical status" is a statement about FLY-200; it puts nothing in doubt
    # about the FLY-100 the mirror is tracking, so clearing the pair to
    # "unknown" would answer a question nobody asked — and would report
    # FLY-100 as unknown to every consumer that reads the pair.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        run = _run_writer_transition_full(
            root, "FLY-200", "REVIEW",
            payload={"success": True, "issue": "FLY-200",
                     "reconciliation": "transitioned_comment_pending",
                     "previousStatus": "In Progress",
                     "newStatus": "Sprint Review Lane"})
        for name in ("focus.md", "status", "status-ref"):
            assert (session / name).exists(), \
                f"an unresolvable reply about FLY-200 cleared FLY-100's {name}"
        assert (session / "status").read_text().strip() == "IMPLEMENTING"
        assert run.code == 0
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        assert len(notes) == 1, \
            f"bridge.py makes one MCP warning per stderr line, got {notes}"
        assert "cleared status" not in notes[0], \
            f"the note claims a clear that did not happen: {notes[0]}"
        assert "FLY-100" in notes[0], \
            f"the note should name the issue the mirror is about: {notes[0]}"


# ---------------------------------------------------------------------------
# The session mirror follows the focus, not the transition (FLY-1471)
# ---------------------------------------------------------------------------

print("\n## the mirror follows the focus, not the transition (FLY-1471)")

# A transition to ANY open status used to claim `status` and `status-ref` for
# the transitioned ref and leave focus.md alone, so `transition FLY-200 REVIEW`
# during work on FLY-100 left the pair saying FLY-200 and the focus saying
# FLY-100. Nothing warned. Every consumer of the pair then fails safe on the
# mismatch and goes silent — stop-gate.py skips the gate, auto-approve.py drops
# its nudge, prompt-submit.py reports the issue with no status — so the session
# quietly loses its enforcement for the issue actually being worked. It is also
# the step that armed FLY-1407's cross-issue wipe: the close guard asks
# `status-ref` whose the pair is, and the claim had already made it answer
# FLY-200.
#
# One open status is different. IMPLEMENTING *is* activation
# (reference/status-workflow.md): starting work on an issue is exactly the
# moment the session's subject changes, and the writer already moves focus.md
# with it. Every other open status is a move on the board that says nothing
# about who the session is working for.
OPEN_STATUSES_1471 = ("BACKLOG", "READY", "IMPLEMENTING", "BLOCKED", "REVIEW",
                      "TESTING")


@test("open status x focused/non-focused: only IMPLEMENTING claims (FLY-1471)")
def _():
    # The table is the whole contract: six open statuses, two subjects each.
    # Spelled out rather than derived, so a status added to the vocabulary
    # fails here and gets an explicit answer instead of inheriting one.
    import status_vocab
    assert set(OPEN_STATUSES_1471) == (
        set(status_vocab.CANONICAL_STATUSES) - status_vocab.CLOSED_STATUSES), \
        "the table has drifted from the open half of CANONICAL_STATUSES"

    for target in OPEN_STATUSES_1471:
        # Focused: the transitioned ref IS the session's subject, so the
        # mirror records where it landed, exactly as before.
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            session = _seed_mirror(root, "READY", "FLY-100")
            (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
            run = _run_writer_transition_full(root, "FLY-100", target)
            assert (session / "status").read_text().strip() == target, \
                f"{target} on the focused issue did not reach the mirror"
            assert (session / "status-ref").read_text().strip() == "FLY-100"
            assert "session mirror stays" not in run.err, \
                f"{target} on the focused issue reported a refusal: {run.err!r}"
            assert run.code == 0

        # Non-focused: FLY-200 moves on the board while the session works
        # FLY-100. Only activation may take the mirror with it.
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
            (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
            run = _run_writer_transition_full(root, "FLY-200", target)
            assert run.code == 0
            notes = [ln for ln in run.err.splitlines() if ln.strip()]
            if target == "IMPLEMENTING":
                assert (session / "status").read_text().strip() == "IMPLEMENTING"
                assert (session / "status-ref").read_text().strip() == "FLY-200", \
                    "activating FLY-200 must claim the session for FLY-200"
                assert "FLY-200" in (session / "focus.md").read_text(), \
                    "activation moves the attributed subject with the mirror"
                assert notes == [], \
                    f"activation is not a refusal, it needs no note: {notes}"
            else:
                assert (session / "status").read_text().strip() == "IMPLEMENTING", \
                    (f"{target} on FLY-200 overwrote the status describing "
                     f"FLY-100 — the mismatch that silences every consumer")
                assert (session / "status-ref").read_text().strip() == "FLY-100", \
                    f"{target} on FLY-200 claimed FLY-100's status-ref"
                assert "FLY-100" in (session / "focus.md").read_text(), \
                    f"{target} on FLY-200 moved the attributed subject"
                assert len(notes) == 1, \
                    (f"exactly one line — bridge.py makes one MCP warning per "
                     f"stderr line: {notes}")
                assert f"FLY-200 -> {target}" in notes[0], \
                    f"the note must say what the issue did: {notes[0]}"
                assert "session mirror stays on FLY-100" in notes[0], \
                    f"the note must say what the session kept: {notes[0]}"


@test("the refusal note reports the status actually reached (FLY-1471)")
def _():
    # The line the agent reads has to be true of the issue, not of the
    # request: the relay landed this TESTING request in a review column, and
    # the note is the only place that says so on this path.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        run = _run_writer_transition_full(root, "FLY-200", "TESTING",
                                          reached="REVIEW")
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        assert len(notes) == 1, notes
        assert "FLY-200 -> REVIEW recorded on the issue" in notes[0], notes[0]
        assert "session mirror stays on FLY-100" in notes[0], notes[0]
        assert (session / "status").read_text().strip() == "IMPLEMENTING"


@test("with no focus.md the mirror is nobody's, so the write stands (FLY-1471)")
def _():
    # The guard protects a subject, and an absent focus.md names none. A
    # session that has only ever transitioned — no /activate — must still get
    # its status recorded, or the pair every consumer reads never appears.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "READY", "FLY-100")
        run = _run_writer_transition_full(root, "FLY-200", "REVIEW")
        assert (session / "status").read_text().strip() == "REVIEW", \
            "with no focused issue there is nothing to protect"
        assert (session / "status-ref").read_text().strip() == "FLY-200"
        assert "session mirror stays" not in run.err, run.err


@test("a focus.md that names nobody does not block the write (FLY-1471)")
def _():
    # focus.md has held garbage before (a leaked "--help" for a day, FLY-1186).
    # `focus_ref` returns None for it, and None means "no subject", not "a
    # subject I cannot name" — refusing the write there would leave the
    # workspace with no mirror at all until someone repaired the file by hand.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "READY", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\n--help\n")
        _run_writer_transition(root, "FLY-200", "REVIEW")
        assert (session / "status").read_text().strip() == "REVIEW"
        assert (session / "status-ref").read_text().strip() == "FLY-200"


@test("a pair drifted onto the transitioned ref is cleared (FLY-1471)")
def _():
    # The case the guard cannot answer by refusing alone: the pair tracks
    # FLY-200, focus.md names FLY-100, and FLY-200 moves. Re-taking the pair
    # would be the unconditional claim again, one door along — it is exactly
    # the state FLY-1407's wipe needed. But *leaving* it is not free either:
    # the pair then says FLY-200 is IMPLEMENTING when FLY-200 is in REVIEW,
    # and unlike a missing pair, a wrong one is believed. Two consumers read
    # it as the issue's own before-state (issues.py's FLY-645 hint block,
    # post-transition-check's `read_cached_status`) and warn about legal moves
    # on the strength of it.
    #
    # So the pair is cleared, on the same authority the close path uses:
    # `status-ref` names this ref, so this transition speaks for it, and
    # "unknown" is a state every consumer already degrades through.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-200")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        (session / "acceptance-criteria.md").write_text("- [ ] FLY-100\n")
        run = _run_writer_transition_full(root, "FLY-200", "REVIEW")
        for name in ("status", "status-ref"):
            assert not (session / name).exists(), \
                (f"{name} still describes FLY-200 as IMPLEMENTING after it "
                 f"moved to REVIEW — a wrong pair, not an unknown one")
        assert "FLY-100" in (session / "focus.md").read_text(), \
            "the focused issue lost its focus.md to a transition on FLY-200"
        assert (session / "acceptance-criteria.md").exists(), \
            "the snapshot belongs to the focus, not to the cleared pair"
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        assert len(notes) == 1, notes
        assert "session focus stays on FLY-100" in notes[0], \
            f"the note must say what the session kept: {notes[0]}"
        assert "stale mirror entry for FLY-200" in notes[0], \
            f"the note must say what it cleared: {notes[0]}"
        assert "mirror stays on FLY-100" not in notes[0], \
            (f"the mirror did not stay on FLY-100 — it described FLY-200 and "
             f"is now gone: {notes[0]}")
        # The clear leaves FLY-100 with no recorded status, and a session
        # whose gates are off has to be told so — otherwise the fix lands the
        # pre-fix failure mode through a new door: three consumers silent,
        # nobody aware.
        assert "no recorded status for FLY-100" in notes[0], \
            f"the note must say the focus has no status: {notes[0]}"
        assert "gates are off" in notes[0], \
            f"the note must say what that costs: {notes[0]}"
        assert len(notes[0].splitlines()) == 1, "still one MCP warning"


@test("the note claims no cost when the mirror really does cover the focus")
def _():
    # The control for the clause above: when the pair describes the focused
    # issue, the gates are armed and saying otherwise would be false alarm.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        run = _run_writer_transition_full(root, "FLY-200", "REVIEW")
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        assert "gates are off" not in notes[0], \
            f"FLY-100 still has its own status recorded: {notes[0]}"


@test("clearing the drifted pair silences the false FLY-645 hint (FLY-1471)")
def _():
    # Why the clear, in the consumer that shows it. `issues.py` hints on an
    # unusual edge by reading the cached pair as this issue's current state
    # (FLY-645, ~844-849). Leaving FLY-200 recorded as IMPLEMENTING after it
    # reached REVIEW makes the NEXT move — REVIEW -> COMPLETE, a listed edge —
    # read as IMPLEMENTING -> COMPLETE, which is not: an "Unusual transition"
    # warning on a legal move, and bridge.py turns it into an MCP warning.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-200")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        _run_writer_transition(root, "FLY-200", "REVIEW")
        run = _run_writer_transition_full(root, "FLY-200", "COMPLETE")
        assert "Unusual transition" not in run.err, \
            (f"a stale pair made a legal REVIEW -> COMPLETE look unusual: "
             f"{run.err!r}")


@test("clearing the drifted pair silences the hook's note too (FLY-1471)")
def _():
    # The same false warning through the other reader: post-transition-check
    # falls back to `read_cached_status` when the relay reply carries no
    # before-state, and injects "Unusual transition ... Verify this is
    # intentional" as additionalContext — the agent is told to double-check a
    # move that was correct.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-200")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        _run_writer_transition(root, "FLY-200", "REVIEW")
        msg = _run_post_transition(
            _transition_payload("FLY-200", "COMPLETE", "", "", root), root)
        assert msg == "", \
            f"the hook warned about a legal move on a stale pair: {msg}"


@test("activation on another issue drops that issue's stale AC snapshot (FLY-1471)")
def _():
    # Activation moves the subject, and the acceptance snapshot beside
    # focus.md belongs to the subject. Rewriting focus.md and leaving the
    # snapshot makes session-start.py report the previous issue's "AC: 1/4"
    # under the new issue's ref.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        (session / "acceptance-criteria.md").write_text(
            "- [x] FLY-100 one\n- [ ] FLY-100 two\n")
        _run_writer_transition(root, "FLY-200", "IMPLEMENTING")
        assert "FLY-200" in (session / "focus.md").read_text(), \
            "precondition: activation claims the focus"
        assert not (session / "acceptance-criteria.md").exists(), \
            "FLY-200 inherited FLY-100's acceptance criteria"


@test("activation on the SAME issue keeps its AC snapshot (FLY-1471)")
def _():
    # The control: re-stating IMPLEMENTING on the issue already focused
    # changes no subject, so it must not throw away criteria the session is
    # working against (a BLOCKED -> IMPLEMENTING resume is the ordinary case).
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "BLOCKED", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        (session / "acceptance-criteria.md").write_text("- [ ] FLY-100 one\n")
        _run_writer_transition(root, "FLY-100", "IMPLEMENTING")
        assert (session / "acceptance-criteria.md").exists(), \
            "a resume of the focused issue discarded its criteria"


@test("a close that takes the pair says the focus has no status (FLY-1471)")
def _():
    # The second branch that reaches "focused, with no status recorded for
    # it", and the one that was entirely silent: the pair described FLY-200,
    # closing FLY-200 clears it (FLY-1407), and FLY-100 — still the session's
    # subject — is left with nothing any consumer will act on. The clear is
    # right; saying nothing about it is how the session finds out by noticing
    # the Stop gate never fires again.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-200")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        (session / "acceptance-criteria.md").write_text("- [ ] FLY-100\n")
        run = _run_writer_transition_full(root, "FLY-200", "COMPLETE")
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        assert len(notes) == 1, f"one line, one MCP warning: {notes}"
        assert "FLY-200" in notes[0], f"the note must name what closed: {notes[0]}"
        assert "no recorded status for FLY-100" in notes[0], notes[0]
        assert "gates are off" in notes[0], notes[0]
        # FLY-1407's split guard, unchanged by the new sentence.
        for name in ("status", "status-ref"):
            assert not (session / name).exists(), f"{name} survived its close"
        assert (session / "focus.md").exists()
        assert (session / "acceptance-criteria.md").exists()


@test("a close that ends the session's own subject says nothing (FLY-1471)")
def _():
    # The control. With focus.md cleared too there is no focused issue left to
    # have lost its status, and claiming otherwise would nag about a session
    # that correctly has nothing in flight.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-100")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        run = _run_writer_transition_full(root, "FLY-100", "COMPLETE")
        assert [ln for ln in run.err.splitlines() if ln.strip()] == [], \
            f"a clean close warned about nothing: {run.err!r}"


@test("an unresolved clear says the focus lost its status too (FLY-1471)")
def _():
    # The third branch: the relay confirms a move but names no canonical
    # status for it, so the pair describing FLY-200 is deleted as "unknown"
    # (FLY-1356). `forceUsed` keeps `closing` false, so focus.md survives —
    # and FLY-100 is again focused with nothing recorded. The existing note
    # listed what it cleared and stopped there.
    mapping = {"COMPLETE": "Done", "REVIEW": "In Review"}
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "REVIEW", "FLY-200")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
        run = _run_writer_transition_full(
            root, "FLY-200", "COMPLETE", mapping=mapping,
            payload={"success": True, "issue": "FLY-200",
                     "previousStatus": "In Review",
                     "newStatus": "Archive Bin", "actualStatus": "Archive Bin",
                     "fallbackUsed": False, "forceUsed": True,
                     "forceTarget": "Archive Bin",
                     "mappedFromFlydocsStatus": "COMPLETE"})
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        cleared = [n for n in notes if "cleared status, status-ref" in n]
        assert len(cleared) == 1, f"expected the clear note among {notes}"
        assert "no recorded status for FLY-100" in cleared[0], cleared[0]
        assert "gates are off" in cleared[0], cleared[0]
        assert (session / "focus.md").exists(), \
            "precondition: a forced close does not clear focus.md"
        assert not (session / "status").exists()
        assert run.code == 0


@test("the close path is untouched by the focus guard (FLY-1407, FLY-1471)")
def _():
    # The asymmetry has to survive: an open status may not take a mirror the
    # focus owns, and a CLOSED status still clears the pair `status-ref`
    # itself names, whoever the focus is. Closing FLY-200 while the pair
    # tracks FLY-200 and the focus names FLY-100 clears the pair and keeps
    # the focus — FLY-1407's split-guard contract, restated from the far side
    # of this fix.
    for closing in ("COMPLETE", "ARCHIVED", "CANCELED", "DUPLICATE"):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            session = _seed_mirror(root, "REVIEW", "FLY-200")
            (session / "focus.md").write_text("# Active Issue\n\nFLY-100\n")
            (session / "acceptance-criteria.md").write_text("- [ ] FLY-100\n")
            run = _run_writer_transition_full(root, "FLY-200", closing)
            for name in ("status", "status-ref"):
                assert not (session / name).exists(), \
                    f"{closing} left the pair it names behind ({name})"
            assert (session / "focus.md").exists(), \
                f"{closing} on FLY-200 deleted FLY-100's focus.md"
            assert (session / "acceptance-criteria.md").exists()
            assert "session mirror stays" not in run.err, \
                f"a close is not an open-status refusal: {run.err!r}"


print("\n## the unreachable fallback note is gone (FLY-1407)")


@test("a normal transition prints no note at all (FLY-1407)")
def _():
    # The FLY-653 note claimed a fallback had been used. It fired on
    # `fallbackUsed` — hard-coded `false` on every shipped adapter path since
    # FLY-685 — or on `actual != mapped_from and mapped_from != target`, and
    # `mappedFromFlydocsStatus` is the REQUEST echoed back verbatim
    # (`status.toUpperCase()`, before any resolution), so `mapped_from` either
    # defaults to `target` or equals it. No reply the relay can emit satisfies
    # either arm, which is why this test can only pin the reachable half: a
    # normal transition says nothing on stderr. Restoring the note does not
    # turn this test red — nothing the server sends does — and that is the
    # finding, not a gap in the test.
    #
    # stderr is not cosmetic here: bridge.py's `_warnings_from` turns every
    # line of it into a separate MCP warning shown to the agent.
    #
    # The empty session dir is deliberate: with no `status` file there is no
    # cached current state, so the transition-hint block stays silent and the
    # only thing that could reach stderr is a note from the code under test.
    with tempfile.TemporaryDirectory() as tmp:
        run = _run_writer_transition_full(
            Path(tmp), "FLY-1407", "IMPLEMENTING",
            payload={"success": True, "issue": "FLY-1407",
                     "previousStatus": "Todo", "newStatus": "In Progress",
                     "actualStatus": "In Progress", "fallbackUsed": False,
                     "mappedFromFlydocsStatus": "IMPLEMENTING"})
        assert run.code == 0
        assert [ln for ln in run.err.splitlines() if ln.strip()] == [], \
            f"a clean transition warned the agent about nothing: {run.err!r}"
        assert (run.session / "status").read_text().strip() == "IMPLEMENTING", \
            "the transition itself must still be recorded"


@test("the force note is the only note a resolved transition can print (FLY-1407)")
def _():
    # Removing one note must not remove the one beside it that a real reply
    # still triggers: `forceUsed` is genuinely emitted (linear.ts ~925/970,
    # jira.ts ~1297/1351) and the agent needs to know its canonical target was
    # resolved by provider name instead.
    mapping = {"COMPLETE": "Done", "REVIEW": "In Review",
               "IMPLEMENTING": "In Progress"}
    with tempfile.TemporaryDirectory() as tmp:
        run = _run_writer_transition_full(
            Path(tmp), "FLY-1407", "REVIEW", mapping=mapping,
            payload={"success": True, "issue": "FLY-1407",
                     "previousStatus": "In Progress",
                     "newStatus": "In Review", "actualStatus": "In Review",
                     "fallbackUsed": False, "forceUsed": True,
                     "forceTarget": "In Review",
                     "mappedFromFlydocsStatus": "REVIEW"})
        notes = [ln for ln in run.err.splitlines() if ln.strip()]
        assert len(notes) == 1, \
            f"expected exactly the force note, got {notes}"
        assert "Force override used" in notes[0], notes[0]


@test("a ref with trailing garbage never reaches focus.md (FLY-1356)")
def _():
    # `ISSUE_REF_RE.match` accepts a valid PREFIX; the attribution schema
    # accepts the whole string or nothing. Under `match`, "FLY-1064X" passes
    # the guard and the literal "FLY-1064X" lands in focus.md, where every
    # attribution tuple then records issue: null — the FLY-1186 failure again,
    # one character further along.
    for bad_ref in ("FLY-1064X", "FLY-1064-2", "FLY-1064 and more"):
        with tempfile.TemporaryDirectory() as tmp:
            session = _run_writer_transition(Path(tmp), bad_ref, "IMPLEMENTING")
            assert not (session / "focus.md").exists(), \
                f"{bad_ref!r} reached focus.md — a prefix is not a ref"


def _run_wrap(root: Path, session: Path) -> None:
    """Run the real `session.py wrap` against a temp session directory."""
    import contextlib as _cl
    import io as _sio
    import types as _ty

    module = _load_session_module()
    args = _ty.SimpleNamespace(
        issues=["FLY-1356"], pending=[], blockers=[], notes="",
        health=None, body=None, body_file=None, project=None)
    out, err = _sio.StringIO(), _sio.StringIO()
    with patch.object(module, "find_project_root", lambda: root), \
            patch.object(module, "_resolve_session_dir", lambda _root: session), \
            _cl.redirect_stdout(out), _cl.redirect_stderr(err):
        module.cmd_wrap(args)


@test("session wrap clears every session-state file (FLY-1356, FLY-1471)")
def _():
    # `status-ref` was the one file wrap left behind, and prompt-submit.py
    # falls back to it when focus.md yields no ref — so a wrapped issue came
    # back as the next session's attribution subject.
    #
    # The list comes from `SESSION_STATE_FILES` rather than being spelled out
    # again, because the failure mode is always the same one: a file added to
    # the session dir and forgotten here. Driving the seed from the constant
    # means the wrap loop has to honour the whole constant — hard-coding a
    # subset of it back into `cmd_wrap` fails here rather than passing beside
    # a list nothing reads.
    module = _load_session_module()
    assert "mirror-mismatch-notice" in module.SESSION_STATE_FILES, \
        "the edit gate's marker is session state and expires with the session"
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = root / ".flydocs" / "session" / "default"
        session.mkdir(parents=True)
        for name in module.SESSION_STATE_FILES:
            (session / name).write_text("FLY-1356\n")

        _run_wrap(root, session)

        for name in module.SESSION_STATE_FILES:
            assert not (session / name).exists(), \
                f"wrap left {name} behind — it resurrects in the next session"


@test("create audit reads tool_response and reports auto-resolved fields")
def _():
    # This path read `tool_result`, a key the payload never contains, so it
    # never once ran (FLY-1064).
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        payload = {
            "hook_event_name": "PostToolUse", "tool_name": "Bash",
            "cwd": str(root),
            "tool_input": {"command": (
                "python3 .claude/skills/flydocs-workflow/scripts/issues.py "
                "create --title T --type chore --description D"
            )},
            "tool_response": {"stdout": json.dumps({
                "identifier": "FLY-1075",
                "url": "https://linear.app/x/issue/FLY-1075/e2e-validation",
                "autoResolved": {"categoryLabel": "chore"},
            }), "stderr": ""},
        }
        msg = _run_post_transition(payload, root)
        assert "categoryLabel=chore" in msg, f"Auto-resolved not surfaced: {msg}"


# ---------------------------------------------------------------------------
# Hook noise: create matcher and staleness nudge (FLY-1066)
# ---------------------------------------------------------------------------

print("\n## hook noise (FLY-1066)")


def _load_hook_module(filename: str, modname: str):
    import importlib.util
    spec = importlib.util.spec_from_file_location(modname, HOOKS_DIR / filename)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _bash_payload(command: str, stdout: str, root: Path) -> dict:
    return {
        "hook_event_name": "PostToolUse", "tool_name": "Bash", "cwd": str(root),
        "tool_input": {"command": command},
        "tool_response": {"stdout": stdout, "stderr": ""},
    }


@test("create --help produces no audit output")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        msg = _run_post_transition(_bash_payload(
            "python3 .claude/skills/flydocs-workflow/scripts/issues.py create --help",
            "usage: issues.py create [-h] --title TITLE --type {feature,bug}\n",
            root), root)
        assert msg == "", f"Expected silence on --help, got: {msg}"


@test("a heredoc merely containing 'issues.py create' is not audited")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        msg = _run_post_transition(_bash_payload(
            "cat > notes.md <<'EOF'\nRun issues.py create --title X\nEOF",
            "", root), root)
        assert msg == "", f"Expected silence on heredoc, got: {msg}"


@test("a transition whose comment mentions 'issues.py create' is not audited")
def _():
    # Observed twice in one session: the audit fired on a comment body.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        command = (
            'python3 .claude/skills/flydocs-workflow/scripts/issues.py transition '
            'FLY-1082 CANCELED "Probe created while testing issues.py create --project"'
        )
        stdout = json.dumps({"success": True, "issue": "FLY-1082",
                             "previousStatus": "Backlog", "newStatus": "Canceled"})
        msg = _run_post_transition(_bash_payload(command, stdout, root), root)
        assert "Post-create audit" not in msg, f"Audited a transition: {msg}"


@test("a real create is still audited")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        stdout = json.dumps({
            "id": "x", "identifier": "FLY-1090", "title": "T",
            "url": "https://linear.app/x/issue/FLY-1090/t",
            "autoResolved": {"categoryLabel": "bug"},
        })
        msg = _run_post_transition(_bash_payload(
            "python3 .../issues.py create --title T --type bug --description D",
            stdout, root), root)
        assert "categoryLabel=bug" in msg, f"Real create not audited: {msg}"


@test("a real create missing --type is still flagged")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        stdout = json.dumps({"id": "x", "identifier": "FLY-1091", "title": "T",
                             "url": "https://linear.app/x/issue/FLY-1091/t"})
        msg = _run_post_transition(_bash_payload(
            "python3 .../issues.py create --title T --description D", stdout, root),
            root)
        assert "without --type" in msg, f"Expected a --type warning, got: {msg}"


@test("compliance score is not incremented by a non-create command")
def _():
    # The old matcher counted --help invocations as creates, skewing the score.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _run_post_transition(_bash_payload(
            "python3 .../issues.py create --help", "usage: ...\n", root), root)
        cache = root / ".flydocs" / "validation-cache.json"
        if cache.exists():
            data = json.loads(cache.read_text())
            assert data.get("compliance", {}).get("totalCreated", 0) == 0, \
                "Non-create must not count toward compliance"


def _write_stale_config(root: Path, age_hours: int) -> None:
    from datetime import datetime, timedelta, timezone
    flydocs = root / ".flydocs"
    flydocs.mkdir(parents=True, exist_ok=True)
    (flydocs / "config.json").write_text(json.dumps(
        {"tier": "cloud", "setupComplete": True}))
    stamp = (datetime.now(timezone.utc) - timedelta(hours=age_hours)).isoformat()
    (flydocs / "validation-cache.json").write_text(json.dumps({"timestamp": stamp}))


@test("staleness nudge fires once per session, not on every prompt")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_stale_config(root, age_hours=40)
        cwd = os.getcwd()
        try:
            os.chdir(root)
            first = m.get_config_freshness_nudge("session-a")
            second = m.get_config_freshness_nudge("session-a")
            third = m.get_config_freshness_nudge("session-a")
        finally:
            os.chdir(cwd)
        assert first and "Config stale" in first, f"Expected a nudge, got {first}"
        assert second is None, f"Nudge repeated within a session: {second}"
        assert third is None, f"Nudge repeated within a session: {third}"


@test("staleness nudge returns for a new session")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_stale_config(root, age_hours=40)
        cwd = os.getcwd()
        try:
            os.chdir(root)
            m.get_config_freshness_nudge("session-a")
            next_session = m.get_config_freshness_nudge("session-b")
        finally:
            os.chdir(cwd)
        assert next_session is not None, "A new session should see the nudge once"


@test("fresh config produces no nudge at all")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_stale_config(root, age_hours=2)
        cwd = os.getcwd()
        try:
            os.chdir(root)
            assert m.get_config_freshness_nudge("session-a") is None
        finally:
            os.chdir(cwd)


# ---------------------------------------------------------------------------
# prompt-submit.py / auto-approve.py: no fabricated issue|status pairs
# ---------------------------------------------------------------------------

print("\n## prompt context and edit gate (FLY-1064)")


@test("prompt context omits status when it belongs to another issue")
def _():
    # Produced the "FLY-1028 | DUPLICATE" line for an issue genuinely In Review.
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "DUPLICATE", "FLY-9999")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        issue_id, status, _warning = m.get_issue_context(str(root))
        assert issue_id == "FLY-1028", f"Expected the focused ref, got {issue_id}"
        assert status is None, f"Expected no status, got {status}"


@test("prompt context reports status when it matches the focused issue")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-1028")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        issue_id, status, _warning = m.get_issue_context(str(root))
        assert (issue_id, status) == ("FLY-1028", "IMPLEMENTING"), \
            f"Got {issue_id} | {status}"


@test("edit gate demands no transition when the mirror names another issue")
def _():
    # FLY-1064's contract, restated: the gate may not ask for a transition on
    # the strength of another issue's status. FLY-1471 changed what it does
    # INSTEAD of the demand — it says the pair has drifted — so the assertion
    # moved from "returns nothing" to "demands nothing".
    m = _load_hook_module("auto-approve.py", "auto_approve")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "DUPLICATE", "FLY-9999")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        cwd = os.getcwd()
        try:
            os.chdir(root)
            nudge = m.check_workflow_state_for_edit(str(root / "some_file.py"))
        finally:
            os.chdir(cwd)
        assert nudge and nudge.startswith("FlyDocs: the session status "), \
            f"expected the drift note, got: {nudge}"
        assert "FLY-9999" in nudge and "FLY-1028" in nudge, nudge
        assert "Transition FLY-1028 to resync." in nudge, \
            (f"the only transition the gate may ask for is the focused "
             f"issue's own: {nudge}")


# ---------------------------------------------------------------------------
# The consumers of the pair say so instead of going quiet (FLY-1471)
# ---------------------------------------------------------------------------

print("\n## a mismatched pair is reported, not swallowed (FLY-1471)")

# All three readers fail safe on a focus/status-ref mismatch, and all three did
# it in silence: the Stop gate exits 0 without a rule, the edit gate drops its
# nudge, the prompt line shows an issue with no status. Failing safe is right —
# judging one issue by another's status is what FLY-1064 removed — but silence
# is what let the mismatch survive a whole session. Each says one line now, and
# none of them blocks on it.


@test("the Stop gate delivers the mismatch as a systemMessage (FLY-1471)")
def _():
    # Channel, not just wording: Claude Code shows hook stderr to the agent
    # only on exit 2, and the gate must not exit 2 here — it has no verdict,
    # and blocking on the absence of one is the bug FLY-1064 removed. The
    # channel that reaches anyone at exit 0 is a JSON `systemMessage` on
    # stdout, which this file already uses for its BLOCKED and
    # unreadable-criteria paths.
    import subprocess
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        # The staged-changes setup that fires the gate for real, with the
        # status belonging to another issue.
        _make_session(root, "IMPLEMENTING", status_ref="FLY-1070")
        subprocess.run(["git", "init", "-q"], cwd=root, check=True)
        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        subprocess.run(
            ["git", "-c", "user.email=t@t", "-c", "user.name=t",
             "commit", "-qm", "init"], cwd=root, check=True,
        )
        (root / "new-file.txt").write_text("staged\n")
        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        result = _run_stop_gate_full(root)
        assert result.returncode == 0, \
            f"the note must not become a block: rc={result.returncode}"
        payload = json.loads(result.stdout or "{}")
        message = payload.get("systemMessage", "")
        assert message, \
            f"nothing reached the agent — stdout was {result.stdout!r}"
        assert "FLY-1070" in message and "FLY-999" in message, \
            f"the message must name both refs: {message}"
        assert len(message.splitlines()) == 1, f"one line: {message!r}"


@test("the Stop gate reports a missing status-ref too (FLY-1471)")
def _():
    # Half a pair is the same fail-safe through a different door, and the same
    # silence: `status` describes an issue nothing names.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _make_session(root, "READY", status_ref="")
        result = _run_stop_gate_full(root)
        assert result.returncode == 0, f"rc={result.returncode}"
        message = json.loads(result.stdout or "{}").get("systemMessage", "")
        assert "FLY-999" in message, \
            f"the message must name the focus: {message!r}"


@test("the edit gate delivers the mismatch as additionalContext (FLY-1471)")
def _():
    # Same channel the gate's real nudge uses (PreToolUse additionalContext),
    # and deliberately no `permissionDecision`: the edit is not being judged,
    # only explained.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "DUPLICATE", "FLY-9999")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        out = _run_auto_approve_edit(root)
        context = out.get("hookSpecificOutput", {}).get("additionalContext", "")
        assert context, f"nothing reached the agent: {out}"
        assert "FLY-9999" in context and "FLY-1028" in context, \
            f"the note must name both refs: {context}"
        assert "permissionDecision" not in out.get("hookSpecificOutput", {}), \
            "explaining a skipped gate must not decide the edit"
        assert "not IMPLEMENTING. Transition before making changes" not in context, \
            "the fabricated demand FLY-1064 removed is back"


@test("the edit gate says it once per drift, not once per edit (FLY-1471)")
def _():
    # The gate runs on every Edit and Write. A note repeated on each one is
    # the kind of per-turn noise FLY-1267 stripped out of prompt-submit, and
    # it would land in additionalContext, which the agent pays for. Once,
    # until the pair is repaired.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "DUPLICATE", "FLY-9999")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        first = _run_auto_approve_edit(root)
        second = _run_auto_approve_edit(root)
        assert first.get("hookSpecificOutput", {}).get("additionalContext"), \
            "the first edit after the drift must say so"
        assert second == {}, f"the note repeated on the next edit: {second}"

        # Repaired pair, then drifted again: the session hears about the new
        # drift. A one-shot that never rearms is a note you get once a month.
        # The repaired status is IMPLEMENTING, so the gate's own nudge stays
        # quiet too and an empty payload means exactly one thing.
        (session / "status").write_text("IMPLEMENTING")
        (session / "status-ref").write_text("FLY-1028")
        assert _run_auto_approve_edit(root) == {}, \
            "an agreeing pair must say nothing at all"

        # Back to the SAME ref the marker already names. This is the leg that
        # tests the clear: without it, "was this reported?" and "is this the
        # same pair?" are the same question, and dropping the unlink on repair
        # passes every other assertion here.
        (session / "status-ref").write_text("FLY-9999")
        again = _run_auto_approve_edit(root)
        assert "FLY-9999" in again.get("hookSpecificOutput", {}).get(
            "additionalContext", ""), \
            (f"a drift that recurred after a repair went unreported — the "
             f"marker was never cleared: {again}")

        (session / "status-ref").write_text("FLY-1028")
        _run_auto_approve_edit(root)
        (session / "status-ref").write_text("FLY-7777")
        third = _run_auto_approve_edit(root)
        assert "FLY-7777" in third.get("hookSpecificOutput", {}).get(
            "additionalContext", ""), \
            f"a second, different drift went unreported: {third}"


@test("the Stop gate speaks when the focus has no recorded status (FLY-1471)")
def _():
    # The state the drifted-pair clear leaves behind, and the one an
    # unresolvable reply leaves too (FLY-1356 clears the pair, keeps
    # focus.md). Every consumer reads a missing `status` as "unknown" and
    # stands down — correctly, and until FLY-1471 without a word, which is the
    # same silence this issue is about, one door along.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = root / ".flydocs" / "session" / "default"
        session.mkdir(parents=True)
        (session / "focus.md").write_text("FLY-999\n")
        result = _run_stop_gate_full(root)
        assert result.returncode == 0, f"rc={result.returncode}"
        message = json.loads(result.stdout or "{}").get("systemMessage", "")
        assert "FLY-999" in message, f"nothing reached the user: {result.stdout!r}"
        assert "no recorded status" in message, f"{message}"
        assert len(message.splitlines()) == 1, f"one line: {message!r}"


@test("no focus.md at all keeps every hook silent (FLY-1471)")
def _():
    # The boundary the notes must not cross: an idle session — no focus, no
    # pair — is not a drift, and a workspace that has never activated anything
    # must not be nagged on every Stop, edit and prompt.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        (root / ".flydocs" / "session" / "default").mkdir(parents=True)
        result = _run_stop_gate_full(root)
        assert result.returncode == 0 and result.stdout.strip() == "", \
            f"the Stop gate spoke about nothing: {result.stdout!r}"
        assert _run_auto_approve_edit(root) == {}, "the edit gate spoke about nothing"
        m = _load_hook_module("prompt-submit.py", "prompt_submit")
        assert m.get_issue_context(str(root)) == (None, None, None), \
            "the prompt context invented a warning for an idle session"


@test("the edit gate speaks when the focus has no recorded status (FLY-1471)")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = root / ".flydocs" / "session" / "default"
        session.mkdir(parents=True)
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        first = _run_auto_approve_edit(root)
        context = first.get("hookSpecificOutput", {}).get("additionalContext", "")
        assert "FLY-1028" in context and "no recorded status" in context, \
            f"nothing reached the agent: {first}"
        assert _run_auto_approve_edit(root) == {}, \
            "the same missing status was reported on the next edit too"


@test("the prompt context speaks when the focus has no recorded status (FLY-1471)")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = root / ".flydocs" / "session" / "default"
        session.mkdir(parents=True)
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        issue_id, status, warning = m.get_issue_context(str(root))
        assert (issue_id, status) == ("FLY-1028", None)
        assert warning and "FLY-1028" in warning and "no recorded status" in warning, \
            f"the missing status reached nobody: {warning}"
        assert warning.startswith("[") and warning.endswith("]"), warning


@test("wrap clears every file a session accumulates (FLY-1471)")
def _():
    # The dedup marker is session state like any other, and session state that
    # wrap does not delete outlives the session it belongs to — the FLY-1356
    # `status-ref` bug exactly. Asserted as a set relation rather than as one
    # more filename, so the next file added to the session dir fails here
    # instead of quietly surviving a wrap.
    session_mod = _load_session_module()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "DUPLICATE", "FLY-9999")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        _run_auto_approve_edit(root)
        produced = {f.name for f in session.iterdir() if f.is_file()}
        assert "mirror-mismatch-notice" in produced, \
            "precondition: the edit gate records what it has already reported"
        leftover = produced - set(session_mod.SESSION_STATE_FILES)
        assert leftover == set(), \
            f"{sorted(leftover)} is session state no wrap knows about"

        # And the list is not the claim — wrap running is. A file named in
        # `SESSION_STATE_FILES` that `cmd_wrap` does not actually delete is
        # the FLY-1356 bug with a tidier constant.
        _run_wrap(root, session)
        survivors = {f.name for f in session.iterdir() if f.is_file()}
        stale = survivors & set(session_mod.SESSION_STATE_FILES)
        assert not stale, \
            f"wrap ran and left {sorted(stale)} for the next session"
        # `last-summary.json` is what wrap PRODUCES — the handoff the next
        # start-session reads — not state it failed to clear.
        assert survivors <= {"last-summary.json"}, \
            f"wrap left something unaccounted for: {sorted(survivors)}"


@test("the edit gate stays quiet when the pair agrees (FLY-1471)")
def _():
    m = _load_hook_module("auto-approve.py", "auto_approve")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-1028")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        cwd = os.getcwd()
        try:
            os.chdir(root)
            nudge = m.check_workflow_state_for_edit(str(root / "a.py"))
        finally:
            os.chdir(cwd)
        assert nudge is None, f"the normal case must say nothing: {nudge}"


@test("the prompt context delivers the mismatch as its warning (FLY-1471)")
def _():
    # `get_issue_context`'s third element is the hook's own channel for
    # exactly this — `main` prints it to stdout (~663-665), where a
    # UserPromptSubmit hook's stdout becomes context the agent sees. The
    # precedent is the "[focus.md holds no valid issue ref ...]" warning
    # beside it, so the shape follows it: one bracketed line.
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "DUPLICATE", "FLY-9999")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        issue_id, status, warning = m.get_issue_context(str(root))
        assert (issue_id, status) == ("FLY-1028", None), \
            f"the fabricated pair is back: {issue_id} | {status}"
        assert warning, "the drift reached nobody"
        assert "FLY-9999" in warning and "FLY-1028" in warning, \
            f"the warning must name both refs: {warning}"
        assert warning.startswith("[") and warning.endswith("]"), \
            f"the file's warnings are bracketed one-liners: {warning}"
        assert len(warning.splitlines()) == 1, f"one line: {warning!r}"


@test("the prompt context keeps the malformed-focus warning first (FLY-1471)")
def _():
    # Two warnings, one channel. A focus.md holding no ref falls back to
    # `status-ref` for the issue id, which makes the pair agree by
    # construction — but if the two conditions ever met, the repair
    # instruction for the unreadable file is the one worth printing.
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-9999")
        (session / "focus.md").write_text("# Active Issue\n\n--help\n")
        issue_id, status, warning = m.get_issue_context(str(root))
        assert issue_id == "FLY-9999", \
            f"the status-ref fallback stopped working: {issue_id}"
        assert warning and "focus.md" in warning, \
            f"the malformed-focus warning was displaced: {warning}"


@test("the prompt context stays quiet when the pair agrees (FLY-1471)")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        session = _seed_mirror(root, "IMPLEMENTING", "FLY-1028")
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1028\n")
        issue_id, status, warning = m.get_issue_context(str(root))
        assert (issue_id, status) == ("FLY-1028", "IMPLEMENTING")
        assert warning is None, f"the normal case must say nothing: {warning}"


# ---------------------------------------------------------------------------
# Template isolation and gitignore coverage (FLY-1067)
# ---------------------------------------------------------------------------

print("\n## template isolation and ignore coverage (FLY-1067)")

# Source-repo paths. Absent in an installed customer workspace, where this
# suite also ships — those tests skip rather than fail (FLY-1067).
TEMPLATE_DIR = HOOKS_DIR.parent.parent / "flydocs-core" / "template"
CORE_SRC = HOOKS_DIR.parent.parent / "flydocs-core" / "src"


def _source_repo_present() -> bool:
    return TEMPLATE_DIR.is_dir() and CORE_SRC.is_dir()


def _load_repo_context():
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "repo_context_t", HOOKS_DIR / "repo_context.py"
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _fake_template(root: Path) -> Path:
    """Build a directory shaped like the packaged template."""
    tpl = root / "template"
    (tpl / ".flydocs").mkdir(parents=True)
    (tpl / ".flydocs" / "config.json").write_text('{"tier":"local"}')
    # The `packagedTemplate` marker is what identifies the template, not the
    # presence of manifest.json (FLY-1144).
    (tpl / "manifest.json").write_text(
        '{"version":"1.0.0","packagedTemplate":true}'
    )
    # The repo that contains the template is itself a live install.
    (root / ".flydocs").mkdir(parents=True, exist_ok=True)
    (root / ".flydocs" / "config.json").write_text('{"tier":"cloud"}')
    return tpl


@test("CLI-generated ignore entries cover every never-commit path")
def _():
    if not _source_repo_present():
        return  # installed workspace — no source tree to check
    # Coverage is generated by src/lib/gitignore.ts, NOT shipped as a file in
    # the template. Shipping one would create a second source of truth.
    source = (TEMPLATE_DIR.parent / "src" / "lib" / "gitignore.ts").read_text()
    for entry in (".flydocs/session/", ".flydocs/credentials.json",
                  ".flydocs/me.json", ".flydocs/validation-cache.json",
                  ".claude/logs/"):
        assert f'"{entry}"' in source, f"{entry} is not in the generated entries"


@test("no duplicate .gitignore is shipped inside the template")
def _():
    for rel in (".flydocs/.gitignore", ".claude/.gitignore"):
        assert not (TEMPLATE_DIR / rel).exists(), (
            f"{rel} duplicates coverage that gitignore.ts already generates"
        )


@test("is_template_dir recognises the real packaged template")
def _():
    if not _source_repo_present():
        return  # installed workspace — no source tree to check
    m = _load_repo_context()
    assert m.is_template_dir(TEMPLATE_DIR), "Real template not recognised"
    assert not m.is_template_dir(TEMPLATE_DIR.parent), \
        "The repo containing the template must not read as a template"


@test("session state never resolves inside a template directory")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        tpl = _fake_template(root)
        session = m.resolve_session_dir(str(tpl)).resolve()
        assert "template" not in session.parts, \
            f"Session dir resolved inside the template: {session}"
        assert str(session).startswith(str(root.resolve())), \
            f"Session dir escaped the repo entirely: {session}"


@test("fresh install: git add -A stages no session state")
def _():
    if not _source_repo_present():
        return  # installed workspace — no source tree to check
    import subprocess
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        subprocess.run(["git", "init", "-q"], cwd=root, check=True)
        # Reproduce what ensureGitignore() writes at init, sourced from the
        # real entry lists so this test fails if an entry is ever dropped.
        source = (TEMPLATE_DIR.parent / "src" / "lib" / "gitignore.ts").read_text()
        entries = re.findall(r'^\s*"([^"]+/|[^"]+\.\w+)",\s*$', source, re.M)
        assert ".flydocs/session/" in entries, "entry extraction failed"
        (root / ".gitignore").write_text("\n".join(entries) + "\n")
        (root / ".flydocs").mkdir(parents=True, exist_ok=True)
        (root / ".flydocs" / "config.json").write_text('{"tier":"cloud"}')
        # Session state and secrets a real session would produce.
        session = root / ".flydocs" / "session" / "ws1"
        session.mkdir(parents=True)
        (session / "focus.md").write_text("FLY-1\n")
        (session / "usage-attribution.jsonl").write_text('{"sid":"secret"}\n')
        (root / ".flydocs" / "credentials.json").write_text('{"key":"secret"}')
        (root / ".claude").mkdir(exist_ok=True)
        (root / ".claude" / "logs").mkdir(exist_ok=True)
        (root / ".claude" / "logs" / "hook-debug.log").write_text("noise\n")

        subprocess.run(["git", "add", "-A"], cwd=root, check=True)
        staged = subprocess.run(
            ["git", "diff", "--cached", "--name-only"],
            cwd=root, capture_output=True, text=True, check=True,
        ).stdout.split()

        leaked = [f for f in staged if "session" in f or "credentials" in f
                  or "logs/" in f or "config.json" in f]
        assert not leaked, f"Fresh install staged files it must never commit: {leaked}"
        assert ".gitignore" in staged, "sanity: the ignore file itself should stage"


# ---------------------------------------------------------------------------
# Focus resolution: narrowest signal, never widen (FLY-1097)
# ---------------------------------------------------------------------------

print("\n## focus resolution (FLY-1097)")


class _FakeSprintClient:
    """Stands in for the relay client's sprint listing."""

    def __init__(self, sprints, raises=False):
        self._sprints = sprints
        self._raises = raises

    def list_sprints(self, **_kwargs):
        if self._raises:
            raise RuntimeError("provider unreachable")
        return self._sprints


@test("synthesize_active_context builds a context from flat config keys")
def _():
    m = _load_issues_module()
    ctx = m.synthesize_active_context(
        {"activeSprintId": "SP1", "activeProjectId": "PR1"}
    )
    assert ctx["sprintId"] == "SP1", f"sprint not carried: {ctx}"
    assert ctx["id"] == "PR1", f"project not carried: {ctx}"
    assert ctx["synthesized"] is True


@test("synthesize_active_context falls back to the activeProjects list")
def _():
    m = _load_issues_module()
    ctx = m.synthesize_active_context({"activeProjects": ["PR9", "PR8"]})
    assert ctx and ctx["id"] == "PR9", f"expected first project, got {ctx}"


@test("synthesize_active_context returns None with nothing to focus on")
def _():
    m = _load_issues_module()
    assert m.synthesize_active_context({}) is None


@test("a Linear context with no boardType still resolves to its sprint")
def _():
    # The pre-fix code switched on boardType, and FLY-691 specifies Linear
    # contexts carry none — so every correct Linear context hit the
    # "unrecognized" branch and focus could never work on Linear.
    m = _load_issues_module()
    sprint, board, narrow, note = m.resolve_focus(
        {"type": "project", "id": "PR1", "sprintId": "SP1"}
    )
    assert sprint == "SP1", f"expected sprint SP1, got {sprint}"
    assert board is None and narrow is False
    assert "sprint" in (note or "").lower()


@test("a Jira scrum board resolves to its active sprint")
def _():
    m = _load_issues_module()
    sprint, board, narrow, _ = m.resolve_focus(
        {"type": "board", "id": "B1", "boardType": "scrum", "sprintId": "SP9"}
    )
    assert (sprint, board, narrow) == ("SP9", None, False)


@test("a Jira kanban board resolves to the board")
def _():
    m = _load_issues_module()
    sprint, board, narrow, _ = m.resolve_focus(
        {"type": "board", "id": "B2", "boardType": "kanban"}
    )
    assert (sprint, board, narrow) == (None, "B2", False)


@test("a scrum board between sprints uses the board and says why")
def _():
    m = _load_issues_module()
    sprint, board, narrow, note = m.resolve_focus(
        {"type": "board", "id": "B1", "boardType": "scrum"}
    )
    assert (sprint, board, narrow) == (None, "B1", False)
    assert "no active sprint" in (note or "").lower(), f"unclear note: {note}"


@test("no resolvable focus narrows instead of widening")
def _():
    m = _load_issues_module()
    for ctx in (None, {"type": "project", "id": "PR1"}):
        sprint, board, narrow, note = m.resolve_focus(ctx)
        assert (sprint, board) == (None, None)
        assert narrow is True, f"must narrow, not widen: {ctx}"
        assert "your open issues" in (note or "").lower(), f"note: {note}"


@test("explicit --sprint / --board override context resolution")
def _():
    m = _load_issues_module()
    assert m.resolve_focus({"sprintId": "SP1"}, sprint_filter="EXPLICIT") == (
        "EXPLICIT", None, False, None
    )
    assert m.resolve_focus({"sprintId": "SP1"}, board_filter="B7") == (
        None, "B7", False, None
    )


@test("verify_sprint_id accepts the id when it IS the active sprint")
def _():
    m = _load_issues_module()
    client = _FakeSprintClient([{"id": "SP1", "state": "active", "name": "C33"}])
    assert m.verify_sprint_id(client, "SP1") == ("SP1", None)


@test("verify_sprint_id repairs a cached id pointing at a closed cycle")
def _():
    # The live defect: config held a closed cycle's id. It is a *valid* id, so
    # existence checking is not enough — the test must be "is it active".
    m = _load_issues_module()
    client = _FakeSprintClient([{"id": "SP-NEW", "state": "active", "name": "C33"}])
    sprint, note = m.verify_sprint_id(client, "SP-OLD-CLOSED")
    assert sprint == "SP-NEW", f"expected repair to active sprint, got {sprint}"
    assert "not the active one" in (note or ""), f"note should explain: {note}"
    assert "C33" in (note or ""), "note should name the sprint used"


@test("verify_sprint_id drops focus when no sprint is active")
def _():
    m = _load_issues_module()
    client = _FakeSprintClient([{"id": "SP1", "state": "closed"}])
    sprint, note = m.verify_sprint_id(client, "SP1")
    assert sprint is None, "no active sprint means no sprint focus"
    assert "no sprint is currently active" in (note or "").lower()


@test("verify_sprint_id trusts the cached id when the provider is unreachable")
def _():
    # A provider outage must not silently drop focus.
    m = _load_issues_module()
    client = _FakeSprintClient([], raises=True)
    assert m.verify_sprint_id(client, "SP1") == ("SP1", None)


# ---------------------------------------------------------------------------
# Silent-drop family: list cap and create field names (FLY-1105, FLY-1103)
# ---------------------------------------------------------------------------

print("\n## silent-drop family (FLY-1105, FLY-1103)")


@test("list default is high enough that ordinary projects are not truncated")
def _():
    # The default was 50, and a saturated page was reported as the whole set.
    m = _load_issues_module()
    assert m.DEFAULT_LIST_LIMIT >= 250, (
        f"default {m.DEFAULT_LIST_LIMIT} is low enough to silently truncate"
    )


@test("list warns on stderr when the limit is saturated (FLY-1105)")
def _():
    src = (SCRIPT_DIR / "issues.py").read_text()
    # The warning must be tied to the requested limit, not a hardcoded number.
    assert "the {args.limit} limit was" in src or "args.limit} limit" in src, \
        "saturation warning should name the limit actually requested"
    # Assert the PROPERTY — that incompleteness is communicated — not an exact
    # phrase. This previously pinned the literal string "more may exist", which
    # made the honest FLY-1115 wording ("returned 3 of 7", where the count is
    # known and "may" would be wrong) fail the test.
    assert "more exist" in src or "not shown" in src, \
        "warning must communicate that the set is incomplete"


@test("list states returned-vs-total when the provider supplies one (FLY-1115)")
def _():
    # FLY-1105 could only warn on page saturation, which cannot distinguish 6
    # remaining from 600. The relay now returns pagination headers.
    src = (SCRIPT_DIR / "issues.py").read_text()
    assert "last_list_pagination" in src, \
        "list should read pagination context from the response headers"
    assert "of {total}" in src, \
        "warning should state returned-vs-total when the total is known"


@test("pagination context survives the header round trip (FLY-1115)")
def _():
    # The client must retain response headers — _request returned only the
    # parsed body, so the total had nowhere to travel.
    src = (SCRIPT_DIR / "flydocs_api.py").read_text()
    assert "last_response_headers" in src, \
        "the HTTP client must retain response headers"
    assert "x-total-count" in src, "pagination reader should look for X-Total-Count"
    assert "x-has-more" in src, "pagination reader should look for X-Has-More"


@test("no list default silently caps at 50 (FLY-1115)")
def _():
    # The FLY-1105 audit recorded five sibling caps that kept their bare 50
    # after the CLI default was raised. A cap is only acceptable if it is
    # deliberate and says so.
    for name in ("issues.py", "session.py", "flydocs_api.py"):
        src = (SCRIPT_DIR / name).read_text()
        assert "default=50" not in src, f"{name} still has a bare default=50"
        assert 'kwargs.get("limit", 50)' not in src, \
            f"{name} still falls back to a bare 50"


@test("create sends the milestone field the relay actually reads (FLY-1103)")
def _():
    # The CLI sent `projectMilestoneId`; the relay POST handler reads
    # `milestoneId`. The key never matched, so every create dropped it while
    # still reporting success.
    api = (SCRIPT_DIR / "flydocs_api.py").read_text()
    assert '"milestoneId"] = milestone_id' in api, \
        "create must send milestoneId"
    assert '"projectMilestoneId"] = milestone_id' not in api, \
        "the non-matching key must be gone"


@test("relay create accepts both milestone field names (FLY-1103)")
def _():
    # Already-installed CLIs still send the old key; fixing only the client
    # would leave them broken until they upgrade.
    route = (
        SCRIPT_DIR.parent.parent.parent.parent
        / "flydocs-app" / "src" / "app" / "api" / "relay" / "issues" / "route.ts"
    )
    if not route.exists():
        return  # app repo not present in this checkout
    src = route.read_text()
    assert "projectMilestoneId" in src, "route should accept the legacy key"
    assert "milestoneIdRaw ?? projectMilestoneId" in src, \
        "route should prefer the canonical key and fall back"


# ---------------------------------------------------------------------------
# Relay client mutation idempotency (FLY-1263 — spec §7.2 / §12.1)
# ---------------------------------------------------------------------------
#
# These drive RelayBackend._request with a stubbed urllib so no request ever
# leaves the process. The backend is built via object.__new__ to skip the
# credential/workspace resolution __init__ does — only the handful of fields
# _request reads are set. Time is stubbed so retries do not actually sleep.

print("\n## relay client idempotency (FLY-1263)")

import ast as _ast
import io as _io
import email.message as _email_message
import urllib.error as _urllib_error

_UUID_RE = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
    re.IGNORECASE,
)


def _hermetic_relay():
    """A RelayBackend with just the fields _request touches — no network, no
    credential resolution."""
    from flydocs_api import RelayBackend

    relay = object.__new__(RelayBackend)
    relay.base_url = "http://127.0.0.1:9/api/relay"
    relay.api_key = "fdk_hermetic"
    relay.workspace_id = "ws_test"
    relay.repo_slug = None
    relay.last_response_headers = {}
    relay.log_path = Path(HERMETIC_CWD) / "relay-ops.jsonl"
    return relay


class _StubResp:
    def __init__(self, body="{}", status=200, headers=None):
        self._body = body.encode() if isinstance(body, str) else body
        self.status = status
        self.headers = headers or {}

    def read(self):
        return self._body

    def __enter__(self):
        return self

    def __exit__(self, *_a):
        return False


def _http_error(code, code_str=None, retry_after=None, body=None):
    hdrs = _email_message.Message()
    if retry_after is not None:
        hdrs["Retry-After"] = str(retry_after)
    payload = body if body is not None else ({"code": code_str} if code_str else {})
    fp = _io.BytesIO(json.dumps(payload).encode())
    return _urllib_error.HTTPError("http://relay/x", code, "err", hdrs, fp)


def _raises(exc_factory):
    def _factory():
        raise exc_factory()

    return _factory


def _sequenced_urlopen(factories, captured):
    """Return a urlopen stub that plays `factories` in order, recording each
    Request. The last factory repeats if called again."""
    state = {"i": 0}

    def _fake(req, timeout=None):
        captured.append(req)
        factory = factories[min(state["i"], len(factories) - 1)]
        state["i"] += 1
        return factory()

    return _fake


def _req_headers(req):
    return {k.lower(): v for k, v in req.headers.items()}


@test("mutation to /issues carries one stable X-Operation-Id across retries")
def _():
    captured, sleeps = [], []
    relay = _hermetic_relay()
    with patch(
        "urllib.request.urlopen",
        _sequenced_urlopen(
            [_raises(lambda: _http_error(503)), lambda: _StubResp('{"ok": true}')],
            captured,
        ),
    ), patch("time.sleep", lambda s: sleeps.append(s)):
        result = relay._request(
            "POST", "/issues/FLY-1/transition",
            body={"status": "REVIEW", "comment": "x"},
        )
    assert result == {"ok": True}, result
    assert len(captured) == 2, f"expected one retry, saw {len(captured)} attempts"
    ids = [_req_headers(r).get("x-operation-id") for r in captured]
    assert ids[0] and _UUID_RE.match(ids[0]), f"not a uuid: {ids[0]}"
    # The id names the intent, not the attempt.
    assert ids[0] == ids[1], f"operation id changed across retries: {ids}"


@test("reads carry no operation id and still retry 5xx")
def _():
    captured, sleeps = [], []
    relay = _hermetic_relay()
    with patch(
        "urllib.request.urlopen",
        _sequenced_urlopen(
            [_raises(lambda: _http_error(503)), lambda: _StubResp("[]")],
            captured,
        ),
    ), patch("time.sleep", lambda s: sleeps.append(s)):
        relay._request("GET", "/issues", params={"limit": "5"})
    assert len(captured) == 2, "a read must still retry 5xx"
    assert all(_req_headers(r).get("x-operation-id") is None for r in captured)


@test("a mutation off the issue routes is keyed too, and therefore retries (FLY-1265)")
def _():
    # This test asserted the opposite until FLY-1265: /projects was a mutation
    # on an unprotected route, so it got no id and could not be retried after an
    # ambiguous 5xx. Keying every mutation is what makes the retry safe, and the
    # retry is the whole reason to key it.
    captured, sleeps = [], []
    relay = _hermetic_relay()
    with patch(
        "urllib.request.urlopen",
        _sequenced_urlopen(
            [_raises(lambda: _http_error(503)), lambda: _StubResp('{"ok": true}')],
            captured,
        ),
    ), patch("time.sleep", lambda s: sleeps.append(s)):
        result = relay._request("POST", "/projects", body={"name": "x"})
    assert result == {"ok": True}, result
    assert len(captured) == 2, f"expected one retry, saw {len(captured)}"
    ids = [_req_headers(r).get("x-operation-id") for r in captured]
    assert ids[0] and ids[0] == ids[1], f"id must exist and be stable: {ids}"


@test("every mutating method on every route carries an operation id (FLY-1265)")
def _():
    # The FLY-1264-review pin: context push, workspace rules, scan and usage
    # writes are relay mutations too. "Which routes are protected?" is not a
    # question a call site should have to answer.
    relay = _hermetic_relay()
    for method, path in (
        ("POST", "/context"),
        ("PUT", "/workspace/rules"),
        ("PATCH", "/usage/session"),
        ("DELETE", "/services/repo-a"),
        ("POST", "/scan"),
    ):
        captured = []
        with patch(
            "urllib.request.urlopen",
            _sequenced_urlopen([lambda: _StubResp('{"ok": 1}')], captured),
        ):
            relay._request(method, path, body={"x": 1})
        header = _req_headers(captured[0]).get("x-operation-id")
        assert header and _UUID_RE.match(header), \
            f"{method} {path} sent no operation id: {header}"


@test("a read on any route still carries no operation id (FLY-1265)")
def _():
    # Keying reads would create operation records for traffic that changes
    # nothing — the id is a write-side contract.
    relay = _hermetic_relay()
    captured = []
    with patch(
        "urllib.request.urlopen",
        _sequenced_urlopen([lambda: _StubResp("[]")], captured),
    ):
        relay._request("GET", "/context")
    assert _req_headers(captured[0]).get("x-operation-id") is None


@test("429 honors Retry-After (capped) instead of exponential backoff")
def _():
    relay = _hermetic_relay()
    for retry_after, expected in ((7, 7), (120, 30)):
        captured, sleeps = [], []
        with patch(
            "urllib.request.urlopen",
            _sequenced_urlopen(
                [
                    _raises(lambda ra=retry_after: _http_error(429, retry_after=ra)),
                    lambda: _StubResp('{"ok": 1}'),
                ],
                captured,
            ),
        ), patch("time.sleep", lambda s: sleeps.append(s)):
            relay._request("GET", "/issues")
        assert sleeps == [expected], f"Retry-After {retry_after} → slept {sleeps}"


@test("409 OPERATION_IN_FLIGHT retries on Retry-After and reuses the id")
def _():
    captured, sleeps = [], []
    relay = _hermetic_relay()
    with patch(
        "urllib.request.urlopen",
        _sequenced_urlopen(
            [
                _raises(lambda: _http_error(409, "OPERATION_IN_FLIGHT", retry_after=3)),
                lambda: _StubResp('{"ok": 1}'),
            ],
            captured,
        ),
    ), patch("time.sleep", lambda s: sleeps.append(s)):
        result = relay._request(
            "POST", "/issues/FLY-1/transition",
            body={"status": "REVIEW", "comment": "x"},
        )
    assert result == {"ok": 1}, result
    assert sleeps == [3], f"should wait the Retry-After: {sleeps}"
    ids = [_req_headers(r).get("x-operation-id") for r in captured]
    assert ids[0] and ids[0] == ids[1], f"id must be stable: {ids}"


@test("422 OPERATION_ID_REUSED is terminal — no retry")
def _():
    captured, sleeps = [], []
    relay = _hermetic_relay()
    raised_exit = False
    with patch(
        "urllib.request.urlopen",
        _sequenced_urlopen(
            [_raises(lambda: _http_error(422, "OPERATION_ID_REUSED"))], captured
        ),
    ), patch("time.sleep", lambda s: sleeps.append(s)):
        try:
            relay._request("POST", "/issues/FLY-1/transition", body={"comment": "x"})
        except SystemExit:
            raised_exit = True
    assert raised_exit, "422 should be terminal via fail()"
    assert len(captured) == 1 and sleeps == []


@test("400 OPERATION_ID_REQUIRED is terminal — no retry")
def _():
    captured, sleeps = [], []
    relay = _hermetic_relay()
    raised_exit = False
    with patch(
        "urllib.request.urlopen",
        _sequenced_urlopen(
            [_raises(lambda: _http_error(400, "OPERATION_ID_REQUIRED"))], captured
        ),
    ), patch("time.sleep", lambda s: sleeps.append(s)):
        try:
            relay._request("POST", "/issues/FLY-1/transition", body={"comment": "x"})
        except SystemExit:
            raised_exit = True
    assert raised_exit, "400 should be terminal via fail()"
    assert len(captured) == 1 and sleeps == []


@test("repair_operation posts to /operations/{id}/repair and swallows a 404")
def _():
    # RLA-6's repair route is not deployed yet, so the call 404s; best-effort
    # means it must not raise or exit.
    captured = []
    relay = _hermetic_relay()
    with patch(
        "urllib.request.urlopen",
        _sequenced_urlopen([_raises(lambda: _http_error(404, "NOT_FOUND"))], captured),
    ), patch("time.sleep", lambda s: None):
        relay.repair_operation("op-123")  # must not raise / SystemExit
    assert len(captured) == 1, "repair fires exactly once"
    req = captured[0]
    assert req.full_url.endswith("/operations/op-123/repair"), req.full_url
    assert req.get_method() == "POST"
    # Repair is itself a keyed /operations* mutation.
    assert _req_headers(req).get("x-operation-id"), "repair must carry an operation id"


@test("transition fires a best-effort repair on a reconciliation response")
def _():
    from flydocs_api import FlyDocsClient

    class _FakeRelay:
        def __init__(self):
            self.repairs = []

        def post(self, path, body=None):
            return {
                "success": True,
                "previousStatus": "IMPLEMENTING",
                "newStatus": "REVIEW",
                "reconciliation": "transitioned_comment_pending",
                "operationId": "op-xyz",
            }

        def repair_operation(self, operation_id):
            self.repairs.append(operation_id)

    client = object.__new__(FlyDocsClient)
    client.tier = "cloud"
    client._relay = _FakeRelay()

    response = client.transition("FLY-1", "REVIEW", "done")
    assert client._relay.repairs == ["op-xyz"], client._relay.repairs
    assert response.get("reconciliation") == "transitioned_comment_pending"


@test("transition does not repair when there is no reconciliation")
def _():
    from flydocs_api import FlyDocsClient

    class _FakeRelay:
        def __init__(self):
            self.repairs = []

        def post(self, path, body=None):
            return {"success": True, "previousStatus": "A", "newStatus": "B"}

        def repair_operation(self, operation_id):
            self.repairs.append(operation_id)

    client = object.__new__(FlyDocsClient)
    client.tier = "cloud"
    client._relay = _FakeRelay()

    response = client.transition("FLY-1", "REVIEW", "done")
    assert client._relay.repairs == [], "no reconciliation → no repair call"
    assert "reconciliation" not in response


# ---------------------------------------------------------------------------
# Acceptance-criteria command + transition hints (FLY-1265, spec §10 / §12.1)
# ---------------------------------------------------------------------------
#
# These drive `cmd_acceptance` / `cmd_transition` in-process against a fake
# client. Nothing touches the network, and nothing needs a project root: the
# client and the session directory are both injected.

print("\n## acceptance command + transition hints (FLY-1265)")

import contextlib as _contextlib  # noqa: E402
import types as _types  # noqa: E402


def _criterion(cid, text, checked=False, deferred_to=None):
    return {"id": cid, "text": text, "checked": checked, "deferredTo": deferred_to}


_ISSUE = {
    "identifier": "FLY-1",
    "revision": "rev-1",
    "acceptance": [
        _criterion(1, "first criterion"),
        _criterion(2, "second criterion"),
        _criterion(3, "third criterion", checked=True),
    ],
}


class _FakeAcceptanceClient:
    """Cloud client whose acceptance route plays a scripted list of outcomes."""

    def __init__(self, issue=None, outcomes=(), tier="cloud"):
        self.tier = tier
        self.is_cloud = tier == "cloud"
        self.issue = issue if issue is not None else _ISSUE
        self.outcomes = list(outcomes) or [{"success": True, "issue": "FLY-1"}]
        self.calls = []

    def get_issue(self, ref, **_kwargs):
        return self.issue

    def acceptance(self, ref, changes, expected_revision):
        self.calls.append(
            {"ref": ref, "changes": changes, "revision": expected_revision}
        )
        outcome = self.outcomes[min(len(self.calls) - 1, len(self.outcomes) - 1)]
        if isinstance(outcome, Exception):
            raise outcome
        return outcome


def _relay_error(code, body, status=409):
    from flydocs_api import RelayError

    return RelayError(status, code, body.get("error", code), body)


def _run_acceptance(client, ref="FLY-1", **flags):
    """Run `issues.py acceptance` in-process. Returns (exit_code, out, err)."""
    m = _load_issues_module()
    args = _types.SimpleNamespace(
        ref=ref, check=None, uncheck=None, defer=None, note=None
    )
    for key, value in flags.items():
        setattr(args, key, value)
    out, err = _io.StringIO(), _io.StringIO()
    code = 0
    with patch.object(m, "get_client", lambda: client), \
            _contextlib.redirect_stdout(out), _contextlib.redirect_stderr(err):
        try:
            m.cmd_acceptance(args)
        except SystemExit as exit_error:
            code = exit_error.code or 0
    return code, out.getvalue(), err.getvalue()


@test("acceptance sends ordinals, guards and the revision it read (FLY-1265)")
def _():
    client = _FakeAcceptanceClient()
    code, out, err = _run_acceptance(
        client, check=["1,3"], defer=["2:fly-99"]
    )
    assert code == 0, f"exit {code}: {err}"
    assert len(client.calls) == 1, client.calls
    call = client.calls[0]
    assert call["revision"] == "rev-1", call
    by_id = {c["criterionId"]: c for c in call["changes"]}
    assert by_id[1]["status"] == "checked"
    assert by_id[3]["status"] == "checked"
    assert by_id[2]["status"] == "deferred"
    # A lowercase ref is normalised to the FLY-1087 shape the server enforces.
    assert by_id[2]["deferredTo"] == "FLY-99", by_id[2]
    assert by_id[1]["expectedText"] == "first criterion"
    payload = json.loads(out)
    assert payload["success"] is True
    assert {c["criterionId"] for c in payload["applied"]} == {1, 2, 3}
    assert all("expectedText" not in c for c in payload["applied"]), \
        "the guard is wire detail, not something to echo back at the caller"


@test("the text guard is the first 80 characters, exactly (FLY-1265)")
def _():
    long_text = "x" * 200
    client = _FakeAcceptanceClient(
        issue={"revision": "rev-1", "acceptance": [_criterion(1, long_text)]}
    )
    code, _out, err = _run_acceptance(client, check=["1"])
    assert code == 0, err
    guard = client.calls[0]["changes"][0]["expectedText"]
    # `acceptance.ts` EXPECTED_TEXT_GUARD_CHARS — a different number here is a
    # guard that fails on every long criterion.
    assert guard == "x" * 80, len(guard)


@test("a note and a box change on one criterion is refused before any call (FLY-1265)")
def _():
    # The server rejects a batch naming one criterion twice (ambiguous intent),
    # and `note` is its own status — so the brief's own example shape is two
    # commands. Saying that here beats decoding a 400.
    client = _FakeAcceptanceClient()
    code, _out, err = _run_acceptance(
        client, uncheck=["2"], note=["2:regressed in review"]
    )
    assert code == 1, "a batch with two meanings must not be sent"
    assert "--uncheck" in err and "--note" in err, err
    assert client.calls == [], "nothing should have reached the relay"


@test("acceptance validates the deferral destination client-side (FLY-1265)")
def _():
    client = _FakeAcceptanceClient()
    code, _out, err = _run_acceptance(client, defer=["2:not-a-ref"])
    assert code == 1 and "issue ref" in err, err
    assert client.calls == []

    code, _out, err = _run_acceptance(client, defer=["2"])
    assert code == 1 and "N:VALUE" in err, err
    assert client.calls == []


@test("an ordinal the issue does not have is refused with the real list (FLY-1265)")
def _():
    client = _FakeAcceptanceClient()
    code, _out, err = _run_acceptance(client, check=["9"])
    assert code == 1, err
    assert "no criterion 9" in err, err
    assert "1. [ ] first criterion" in err, err
    assert client.calls == []


@test("REVISION_MISMATCH retries once against the fresh revision (FLY-1265)")
def _():
    conflict = _relay_error("REVISION_MISMATCH", {
        "error": "Issue revision does not match.",
        "code": "REVISION_MISMATCH",
        "revision": "rev-2",
        # Same criteria, same text — the issue moved, the intent did not.
        "acceptance": _ISSUE["acceptance"],
    })
    client = _FakeAcceptanceClient(
        outcomes=[conflict, {"success": True, "issue": "FLY-1"}]
    )
    code, out, err = _run_acceptance(client, check=["1"])
    assert code == 0, f"exit {code}: {err}"
    assert len(client.calls) == 2, client.calls
    assert client.calls[0]["revision"] == "rev-1"
    assert client.calls[1]["revision"] == "rev-2", "retry must use the fresh token"
    assert client.calls[0]["changes"] == client.calls[1]["changes"], \
        "the intent is unchanged — only the revision it is stated against"
    assert "retrying once" in err, err
    assert json.loads(out)["success"] is True


@test("REVISION_MISMATCH does not retry when the criteria moved (FLY-1265)")
def _():
    conflict = _relay_error("REVISION_MISMATCH", {
        "error": "Issue revision does not match.",
        "revision": "rev-2",
        # Someone inserted a criterion: ordinal 1 is now a different criterion,
        # and blindly retrying would tick the wrong box with a 200 response.
        "acceptance": [
            _criterion(1, "inserted criterion"),
            _criterion(2, "first criterion"),
        ],
    })
    client = _FakeAcceptanceClient(outcomes=[conflict])
    code, _out, err = _run_acceptance(client, check=["1"])
    assert code == 1, "a drifted ordinal must not be retried"
    assert len(client.calls) == 1, client.calls
    assert "now reads different text" in err, err
    assert "inserted criterion" in err, "the caller needs the fresh list"


@test("a second REVISION_MISMATCH stops instead of looping (FLY-1265)")
def _():
    conflict = _relay_error("REVISION_MISMATCH", {
        "error": "Issue revision does not match.",
        "revision": "rev-2",
        "acceptance": _ISSUE["acceptance"],
    })
    client = _FakeAcceptanceClient(outcomes=[conflict, conflict])
    code, _out, err = _run_acceptance(client, check=["1"])
    assert code == 1, err
    assert len(client.calls) == 2, "one retry, not a loop"
    assert "second conflict" in err, err


@test("CRITERION_MISMATCH names the guard that failed and the fresh list (FLY-1265)")
def _():
    client = _FakeAcceptanceClient(outcomes=[_relay_error("CRITERION_MISMATCH", {
        "error": "Criterion 2 text does not match expectedText.",
        "criterionId": 2,
        "revision": "rev-2",
        "acceptance": [_criterion(1, "renamed criterion")],
    })])
    code, _out, err = _run_acceptance(client, check=["2"])
    assert code == 1, err
    assert "criterion 2" in err.lower(), err
    assert "renamed criterion" in err, err
    assert len(client.calls) == 1, "a guard failure is not retried blind"


@test("a VALIDATION_ERROR is rendered as itself, not as a conflict (FLY-1265)")
def _():
    client = _FakeAcceptanceClient(outcomes=[_relay_error(
        "VALIDATION_ERROR",
        {"error": "note is required and must be a single line", "criterionId": 1},
        status=400,
    )])
    code, _out, err = _run_acceptance(client, note=["1:x"])
    assert code == 1 and "VALIDATION_ERROR" in err, err


@test("acceptance on local tier points at the description route (FLY-1265)")
def _():
    client = _FakeAcceptanceClient(tier="local")
    code, _out, err = _run_acceptance(client, check=["1"])
    assert code == 1, err
    assert "issues.py description" in err, err
    assert client.calls == []


@test("a relay with no parsed acceptance list fails clearly (FLY-1265)")
def _():
    client = _FakeAcceptanceClient(issue={"revision": "rev-1"})
    code, _out, err = _run_acceptance(client, check=["1"])
    assert code == 1 and "acceptance" in err, err
    assert client.calls == []

    # A provider with no revision token cannot satisfy `expectedRevision`,
    # which the route requires — say so rather than sending an empty string.
    client = _FakeAcceptanceClient(
        issue={"acceptance": [_criterion(1, "a")], "revision": ""}
    )
    code, _out, err = _run_acceptance(client, check=["1"])
    assert code == 1 and "revision" in err, err
    assert client.calls == []


class _FakeTransitionClient:
    def __init__(self):
        self.calls = []

    def transition(self, ref, status, comment, force=None):
        self.calls.append((ref, status, comment, force))
        return {"success": True, "newStatus": status}


def _run_transition(session_dir, ref, target):
    m = _load_issues_module()
    args = _types.SimpleNamespace(
        ref=ref, status=target, comment="because", force=None
    )
    client = _FakeTransitionClient()
    out, err = _io.StringIO(), _io.StringIO()
    code = 0
    # FLY-1356: the mapping is patched here for the same reason the env is
    # patched at the top of this file — unpatched, `_status_mapping` reads
    # whichever `.flydocs/config.json` sits above the checkout, so what these
    # tests assert would depend on the developer's own workspace.
    with patch.object(m, "_resolve_session_dir", lambda: session_dir), \
            patch.object(m, "_status_mapping",
                         lambda: DEFAULT_STATUS_MAPPING), \
            patch.object(m, "get_client", lambda: client), \
            _contextlib.redirect_stdout(out), _contextlib.redirect_stderr(err):
        try:
            m.cmd_transition(args)
        except SystemExit as exit_error:
            code = exit_error.code or 0
    return code, client.calls, err.getvalue()


def _session_at(tmp: str, status: str, ref: str = "FLY-1") -> Path:
    session = Path(tmp) / "session"
    session.mkdir(parents=True, exist_ok=True)
    (session / "status").write_text(status)
    (session / "status-ref").write_text(ref)
    return session


@test("an unlisted edge warns and proceeds instead of refusing (FLY-1265)")
def _():
    # The client judged against a cached local file while the relay reads the
    # provider — so the refusing side was the stale side. It now hints.
    with tempfile.TemporaryDirectory() as tmp:
        session = _session_at(tmp, "BACKLOG")
        code, calls, err = _run_transition(session, "FLY-1", "REVIEW")
    assert code == 0, err
    assert calls and calls[0][1] == "REVIEW", "the transition must still be sent"
    assert "Unusual transition: BACKLOG -> REVIEW" in err, err
    assert "Invalid transition" not in err, "this must no longer be a refusal"


@test("revival out of a closed state is not blocked (FLY-1265)")
def _():
    # ARCHIVED and CANCELED revive to BACKLOG. The old client map had no edges
    # out of them at all, so this was a hard failure on a legal move.
    for closed in ("ARCHIVED", "CANCELED"):
        with tempfile.TemporaryDirectory() as tmp:
            session = _session_at(tmp, closed)
            code, calls, err = _run_transition(session, "FLY-1", "BACKLOG")
        assert code == 0, err
        assert calls and calls[0][1] == "BACKLOG", f"{closed} revival was dropped"
        assert "Unusual transition" not in err, \
            f"{closed} -> BACKLOG is legal and must not warn: {err}"


@test("TRIAGE source edges stay legal, TRIAGE targets stay rejected (FLY-1265)")
def _():
    for target in ("BACKLOG", "READY", "IMPLEMENTING"):
        with tempfile.TemporaryDirectory() as tmp:
            session = _session_at(tmp, "TRIAGE")
            code, calls, err = _run_transition(session, "FLY-1", target)
        assert code == 0 and calls, err
        assert "Unusual transition" not in err, \
            f"TRIAGE -> {target} is legal (TRIAGE reads as BACKLOG): {err}"

    # Unchanged: nothing may target an inbox, and that is a vocabulary
    # rejection, not a transition-table one.
    with tempfile.TemporaryDirectory() as tmp:
        session = _session_at(tmp, "BACKLOG")
        code, calls, err = _run_transition(session, "FLY-1", "TRIAGE")
    assert code == 1 and not calls, err
    assert "not a valid FlyDocs status" in err, err


@test("the client map mirrors the server table it now defers to (FLY-1265)")
def _():
    # The server's table is the contract (`state-machine.ts` TRANSITION_TABLE,
    # spec §4). The mirror may not promise an edge the server would reject.
    import status_vocab

    server_table = {
        "BACKLOG": {"READY", "IMPLEMENTING", "CANCELED", "DUPLICATE", "ARCHIVED"},
        "READY": {"IMPLEMENTING", "BACKLOG", "CANCELED", "DUPLICATE", "ARCHIVED"},
        "IMPLEMENTING": {"REVIEW", "BLOCKED", "CANCELED", "ARCHIVED"},
        "BLOCKED": {"IMPLEMENTING", "CANCELED", "ARCHIVED"},
        "REVIEW": {"COMPLETE", "TESTING", "IMPLEMENTING", "CANCELED"},
        "TESTING": {"COMPLETE", "IMPLEMENTING", "CANCELED"},
        "COMPLETE": set(),
        "ARCHIVED": {"BACKLOG"},
        "CANCELED": {"BACKLOG"},
        "DUPLICATE": set(),
    }
    for source, targets in server_table.items():
        assert set(status_vocab.VALID_TRANSITIONS[source]) == targets, \
            f"{source} diverges from the server table"
    # TRIAGE is the one client-only key: an alias that reads as BACKLOG, so it
    # carries BACKLOG's targets plus the same-state move to BACKLOG itself.
    assert set(status_vocab.VALID_TRANSITIONS["TRIAGE"]) == (
        server_table["BACKLOG"] | {"BACKLOG"}
    )
    assert set(status_vocab.TERMINAL_STATUSES) == {"COMPLETE", "DUPLICATE"}, \
        "terminal means no outbound edges — ARCHIVED and CANCELED have one"
    assert set(status_vocab.CLOSED_STATUSES) == {
        "COMPLETE", "DUPLICATE", "ARCHIVED", "CANCELED"
    }


@test("'To Do' resolves the same on both sides of the wire (FLY-1265)")
def _():
    # `status-heuristic.ts` scores BACKLOG's "To Do" hint above READY's, so a
    # client suggesting READY sent people to a status the relay disagreed with.
    import status_vocab

    for spelling in ("to do", "To Do", "todo", "TO_DO"):
        assert status_vocab.suggest_canonical(spelling) == "BACKLOG", spelling


# ---------------------------------------------------------------------------
# Multi-repo: one session location per workspace (FLY-1084)
# ---------------------------------------------------------------------------

print("\n## multi-repo session state (FLY-1084)")


def _make_workspace(root: Path, repos=("repo-a", "repo-b"), ws_id="ws1") -> None:
    (root / ".flydocs-workspace.json").write_text(json.dumps(
        {"repos": {r: {"path": r} for r in repos}}))
    for r in repos:
        cfg = root / r / ".flydocs"
        cfg.mkdir(parents=True, exist_ok=True)
        (cfg / "config.json").write_text(json.dumps({"workspaceId": ws_id}))


@test("session dir is identical from every child repo (FLY-1084)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _make_workspace(root)
        a = m.resolve_session_dir(str(root / "repo-a")).resolve()
        b = m.resolve_session_dir(str(root / "repo-b")).resolve()
        assert a == b, f"repos disagree: {a} vs {b}"
        assert str(a).startswith(str(root / ".flydocs")), \
            f"session state should live at the workspace root, got {a}"


@test("a transition written from repo A is read back from repo B (FLY-1084)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _make_workspace(root)
        written = m.resolve_session_dir(str(root / "repo-a"))
        written.mkdir(parents=True, exist_ok=True)
        (written / "status").write_text("REVIEW")
        (written / "status-ref").write_text("FLY-1")
        read_back = m.resolve_session_dir(str(root / "repo-b"))
        assert (read_back / "status").read_text() == "REVIEW"
        assert (read_back / "status-ref").read_text() == "FLY-1"


@test("single-repo topology keeps state in the repo (FLY-1084)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        cfg = root / ".flydocs"
        cfg.mkdir(parents=True)
        (cfg / "config.json").write_text(json.dumps({"workspaceId": "ws1"}))
        # No workspace file — behavior must be unchanged.
        got = m.resolve_session_dir(str(root)).resolve()
        assert got == (root / ".flydocs" / "session" / "ws1").resolve(), got


@test("migration adopts the most recent per-repo state (FLY-1084)")
def _():
    import time
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _make_workspace(root)
        older = root / "repo-a" / ".flydocs" / "session" / "ws1"
        newer = root / "repo-b" / ".flydocs" / "session" / "ws1"
        for d, ref in ((older, "FLY-OLD"), (newer, "FLY-NEW")):
            d.mkdir(parents=True, exist_ok=True)
            (d / "focus.md").write_text(f"# Active Issue\n\n{ref}\n")
        # Make repo-b unambiguously newer.
        time.sleep(0.01)
        (newer / "focus.md").write_text("# Active Issue\n\nFLY-NEW\n")

        resolved = m.resolve_session_dir(str(root / "repo-a"))
        assert "FLY-NEW" in (resolved / "focus.md").read_text(), \
            "should adopt the most recently written per-repo state"


@test("stale per-repo state does not override workspace-level (FLY-1084)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _make_workspace(root)
        canonical = root / ".flydocs" / "session" / "ws1"
        canonical.mkdir(parents=True)
        (canonical / "focus.md").write_text("# Active Issue\n\nFLY-CANON\n")
        stale = root / "repo-a" / ".flydocs" / "session" / "ws1"
        stale.mkdir(parents=True)
        (stale / "focus.md").write_text("# Active Issue\n\nFLY-STALE\n")

        resolved = m.resolve_session_dir(str(root / "repo-a"))
        assert "FLY-CANON" in (resolved / "focus.md").read_text(), \
            "workspace-level state must win once it exists"


# ---------------------------------------------------------------------------
# Focus reaches the agent (FLY-1098)
# ---------------------------------------------------------------------------

print("\n## focus reaches the agent (FLY-1098)")


def _repo_with_config(root: Path, config: dict) -> Path:
    cfg = root / ".flydocs"
    cfg.mkdir(parents=True, exist_ok=True)
    (cfg / "config.json").write_text(json.dumps(config))
    return root


@test("context names the sprint when activeContexts supplies one (FLY-1098)")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = _repo_with_config(Path(tmp), {
            "activeContexts": [{"type": "board", "id": "B1", "sprintId": "S1",
                                "sprintName": "Cycle 33"}]})
        assert m.get_focus_descriptor(str(root)) == "Sprint: Cycle 33"


@test("context reports a sprint focus from the flat config key (FLY-1098)")
def _():
    # No name available and no relay call permitted — say a sprint focus exists
    # rather than printing a bare UUID.
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = _repo_with_config(Path(tmp), {"activeSprintId": "03a53d05"})
        got = m.get_focus_descriptor(str(root))
        assert got == "Sprint: active", got
        assert "03a53d05" not in got, "must not leak a raw id into the prompt"


@test("context names a kanban board when that is the focus (FLY-1098)")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = _repo_with_config(Path(tmp), {
            "activeContexts": [{"type": "board", "id": "B2", "name": "Flow"}]})
        assert m.get_focus_descriptor(str(root)) == "Board: Flow"


@test("context omits focus cleanly when none is configured (FLY-1098)")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    with tempfile.TemporaryDirectory() as tmp:
        root = _repo_with_config(Path(tmp), {"activeProjectId": "P1"})
        assert m.get_focus_descriptor(str(root)) is None
    with tempfile.TemporaryDirectory() as tmp:
        # No config at all must not raise.
        assert m.get_focus_descriptor(tmp) is None


@test("the dead acceptance-criteria snapshot read is gone (FLY-1098)")
def _():
    # FLY-1065 removed its writer; a reader that always finds nothing is worse
    # than no reader.
    src = (HOOKS_DIR / "prompt-submit.py").read_text()
    assert "acceptance-criteria.md" not in src.split("FLY-1098")[0] or \
        "ac_file" not in src, "stale snapshot read should be removed"


@test("skill guidance makes focused listing the default (FLY-1098)")
def _():
    skill = SCRIPT_DIR.parent / "SKILL.md"
    text = skill.read_text()
    assert "list --focused" in text, "SKILL.md should document focused listing"
    assert "Default" in text, "focused listing should be marked the default"
    assert "--all" in text, "wider listings should be documented as opt-out"


@test("/status leads with focus and counts the remainder (FLY-1098)")
def _():
    cmd = HOOKS_DIR.parent / "commands" / "status.md"
    text = cmd.read_text()
    assert "--focused" in text, "/status should lead with focused work"
    assert "count" in text.lower(), "remainder should be a count, not a list"


# ---------------------------------------------------------------------------
# Per-turn injection stays slim (FLY-1267)
# ---------------------------------------------------------------------------

print("\n## per-turn prompt injection (FLY-1267)")

PROJECT_UUID = "4b0b17b9-de92-48a4-a9da-7552fd9a729b"
FEATURE_LABEL_UUID = "25b0e16f-1f0e-4a1d-9f31-2b0a7d5c6e88"


def _prompt_fixture(root: Path, *, with_issue: bool = True) -> None:
    """A cloud-tier repo mid-implementation — the ordinary per-turn case."""
    from datetime import datetime, timezone

    flydocs = root / ".flydocs"
    session = flydocs / "session" / "default"
    session.mkdir(parents=True, exist_ok=True)
    (flydocs / "config.json").write_text(json.dumps({
        "tier": "cloud",
        "setupComplete": True,
        "onboardComplete": True,
        "activeProjectId": PROJECT_UUID,
        "activeContexts": [{"type": "board", "id": "b1", "name": "FlyDocs",
                            "boardType": "scrum", "sprintId": "s1",
                            "sprintName": "Cycle 36"}],
        "issueLabels": {"category": {"feature": FEATURE_LABEL_UUID}},
        "topology": {"type": 4, "label": "sibling-repos",
                     "siblingRepos": ["flydocs-app", "flydocs-core"]},
    }))
    (flydocs / "version").write_text("1.2.17\n")
    (flydocs / "validation-cache.json").write_text(json.dumps(
        {"timestamp": datetime.now(timezone.utc).isoformat()}))
    if with_issue:
        (session / "focus.md").write_text("# Active Issue\n\nFLY-1267\n")
        (session / "status").write_text("IMPLEMENTING")
        (session / "status-ref").write_text("FLY-1267")
    (session / "last-summary.json").write_text(json.dumps(
        {"issues": ["FLY-1231"], "pending": ["ship 1.2.18"], "blockers": ["x"]}))
    ctx = root / "flydocs" / "context"
    ctx.mkdir(parents=True, exist_ok=True)
    (ctx / "project.md").write_text("# FlyDocs Core\n")


def _run_prompt_hook(root: Path, payload: dict) -> tuple[int, str, list[dict]]:
    """Drive prompt-submit.py as the agent runtime does: payload on stdin."""
    import subprocess

    env = dict(os.environ)
    env.pop("CLAUDE_PROJECT_DIR", None)
    proc = subprocess.run(
        [sys.executable, str(HOOKS_DIR / "prompt-submit.py")],
        input=json.dumps(payload), capture_output=True, text=True,
        cwd=str(root), env=env, timeout=30,
    )
    log = root / ".flydocs" / "session" / "usage-attribution.jsonl"
    tuples = [json.loads(line) for line in log.read_text().splitlines()] \
        if log.exists() else []
    return proc.returncode, proc.stdout, tuples


@test("injection carries no raw ids and no label map (FLY-1267)")
def _():
    # F2 of the platform audit: ~120-250 non-cacheable tokens every turn. The
    # ids were the bulk of it and the agent cannot act on any of them.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _prompt_fixture(root)
        code, out, _ = _run_prompt_hook(
            root, {"session_id": "s", "cwd": str(root), "prompt": "carry on"})
        assert code == 0, f"hook must not fail: rc={code}"
        assert PROJECT_UUID not in out, "raw project UUID leaked into the prompt"
        assert FEATURE_LABEL_UUID[:8] not in out, "label id map is back"
        for banned in ("ActiveProject:", "Labels:", "FlyDocs: 1.2.17",
                       "Topology:", "Product:", "Last session:"):
            assert banned not in out, f"{banned} should no longer be injected"


@test("operational context and the directive survive the slimming (FLY-1267)")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _prompt_fixture(root)
        _, out, _ = _run_prompt_hook(
            root, {"session_id": "s", "cwd": str(root), "prompt": "carry on"})
        assert "Issue: FLY-1267" in out, "the active issue must still be named"
        assert "IMPLEMENTING" in out, "status must still be named"
        assert "Board: FlyDocs (Scrum)" in out, "board focus must survive"
        assert out.count("Tick AC checkboxes") == 1, \
            "the status directive must be emitted once, not twice"


@test("attribution tuple still records session, issue and project (FLY-1267)")
def _():
    # HARD requirement: AI Spend joins on this tuple. Slimming what the agent
    # reads must not touch what the collector reads.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _prompt_fixture(root)
        _, _, tuples = _run_prompt_hook(
            root, {"session_id": "sess-1", "cwd": str(root), "prompt": "go"})
        assert len(tuples) == 1, f"expected one tuple, got {tuples}"
        got = tuples[0]
        assert isinstance(got.get("ts"), int), f"ts must be an int: {got}"
        assert got.get("sid") == "sess-1", got
        assert got.get("issue") == "FLY-1267", got
        assert got.get("project") == PROJECT_UUID, got


@test("Cursor's input shape still attributes to its conversation id (FLY-1267)")
def _():
    # Cursor sends conversation_id/workspace_roots where Claude Code sends
    # session_id/cwd. Its conversation id is the id its usage records carry, so
    # losing this shape downgrades Cursor spend to a time-window guess.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _prompt_fixture(root)
        code, out, tuples = _run_prompt_hook(root, {
            "conversation_id": "conv-9",
            "workspace_roots": [str(root)],
            "hook_event_name": "beforeSubmitPrompt",
            "prompt": "go",
        })
        assert code == 0, f"hook must not fail on the Cursor shape: rc={code}"
        assert tuples and tuples[0].get("sid") == "conv-9", \
            f"Cursor conversation id must reach the attribution log: {tuples}"
        assert tuples[0].get("issue") == "FLY-1267", tuples
        assert "Issue: FLY-1267" in out, "Cursor must get the same context"


@test("the no-issue directive still fires with a slim line (FLY-1267)")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        _prompt_fixture(root, with_issue=False)
        _, out, tuples = _run_prompt_hook(
            root, {"session_id": "s", "cwd": str(root), "prompt": "go"})
        assert "No active issue" in out, "the activate nudge must survive"
        assert tuples and tuples[0].get("issue") is None, tuples
        assert tuples[0].get("project") == PROJECT_UUID, \
            "project attribution must survive with no issue (FLY-1054)"


# ---------------------------------------------------------------------------
# Repo-root resolution: never write from bare cwd (FLY-1142)
# ---------------------------------------------------------------------------

print("\n## repo root resolution (FLY-1142)")


@test("find_repo_root walks up from a nested subdirectory (FLY-1142)")
def _():
    # The live failure: an agent working in a docs subdirectory produced
    # `<that dir>/.flydocs/session/usage-attribution.jsonl` — attribution no
    # reader looks for.
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        (root / ".flydocs").mkdir(parents=True)
        (root / ".flydocs" / "config.json").write_text(json.dumps({"workspaceId": "ws1"}))
        nested = root / "flydocs" / "knowledge" / "product"
        nested.mkdir(parents=True)
        assert m.find_repo_root(str(nested)) == root, "should resolve to the repo root"


@test("find_repo_root recognises a workspace root (FLY-1142)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        (root / ".flydocs-workspace.json").write_text("{}")
        nested = root / "some" / "deep" / "path"
        nested.mkdir(parents=True)
        assert m.find_repo_root(str(nested)) == root


@test("find_repo_root prefers the nearest root (FLY-1142)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        (root / ".flydocs-workspace.json").write_text("{}")
        repo = root / "repo-a"
        (repo / ".flydocs").mkdir(parents=True)
        (repo / ".flydocs" / "config.json").write_text("{}")
        nested = repo / "src" / "lib"
        nested.mkdir(parents=True)
        assert m.find_repo_root(str(nested)) == repo, "child repo wins over workspace root"


@test("find_repo_root returns None with no root above (FLY-1142)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        nested = Path(tmp) / "a" / "b"
        nested.mkdir(parents=True)
        assert m.find_repo_root(str(nested)) is None


@test("find_repo_root skips the packaged template (FLY-1142)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp).resolve()
        (root / ".flydocs").mkdir(parents=True)
        (root / ".flydocs" / "config.json").write_text("{}")
        tpl = root / "template"
        (tpl / ".flydocs").mkdir(parents=True)
        (tpl / ".flydocs" / "config.json").write_text("{}")
        # The marker key, not the filename, is what identifies the template
        # (FLY-1144).
        (tpl / "manifest.json").write_text(json.dumps({"packagedTemplate": True}))
        # Starting inside the template must not resolve to the template.
        assert m.find_repo_root(str(tpl)) == root


# ---------------------------------------------------------------------------
# Template detection: the marker key, not the filename (FLY-1144)
# ---------------------------------------------------------------------------

print("\n## template marker (FLY-1144)")

# A root-level `manifest.json` is not FlyDocs-owned — a PWA / web app manifest
# is one of the most common files in a web project root. Identifying the
# template by that filename made resolution skip real repo roots, reinstating
# the stray-`.flydocs/` failure FLY-1142 fixed.
PWA_MANIFEST = json.dumps(
    {"name": "My App", "short_name": "App", "start_url": "/", "icons": []}
)


def _make_repo(root: Path, manifest: str | None = None) -> Path:
    """A live FlyDocs repo, optionally carrying a root manifest.json."""
    (root / ".flydocs").mkdir(parents=True, exist_ok=True)
    (root / ".flydocs" / "config.json").write_text(
        json.dumps({"workspaceId": "ws1", "setupComplete": True})
    )
    if manifest is not None:
        (root / "manifest.json").write_text(manifest)
    return root


@test("a repo with a PWA manifest.json resolves to itself, not cwd (FLY-1144)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = _make_repo(Path(tmp).resolve(), PWA_MANIFEST)
        nested = root / "src" / "components"
        nested.mkdir(parents=True)
        assert not m.is_template_dir(root), \
            "a customer's PWA manifest must not read as the packaged template"
        assert m.find_repo_root(str(nested)) == root, \
            "resolution must not skip a real root over an unrelated manifest.json"


@test("workspace child with a stale manifest.json resolves to the child (FLY-1144)")
def _():
    # Live case: flydocs-marketing carried a stale root manifest.json from a
    # pre-1.0 install, so its attribution resolved to the workspace root and
    # was silently misfiled to the wrong repo.
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        ws = Path(tmp).resolve()
        (ws / ".flydocs-workspace.json").write_text("{}")
        # Byte-identical to the template's own manifest, minus the marker —
        # which is why no content signature can separate the two.
        stale = json.dumps(
            {
                "version": "0.6.0-alpha.21",
                "description": "FlyDocs Core - Manifest of all managed files",
                "ownership": {"owned_directories": {"paths": [".claude/hooks"]}},
            }
        )
        repo = _make_repo(ws / "repo-a", stale)
        nested = repo / "src"
        nested.mkdir(parents=True)
        assert m.find_repo_root(str(nested)) == repo, \
            "a stale manifest must not push resolution up to the workspace root"


@test("the packagedTemplate marker still identifies the template (FLY-1144)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        root = _make_repo(Path(tmp).resolve())
        tpl = _make_repo(root / "template")
        (tpl / "manifest.json").write_text(json.dumps({"packagedTemplate": True}))
        assert m.is_template_dir(tpl), "marker must identify the template"
        assert m.find_repo_root(str(tpl)) == root, \
            "FLY-1067 must hold — the template never resolves as a live install"


@test("a non-JSON or marker-less manifest.json is not a template (FLY-1144)")
def _():
    m = _load_repo_context()
    with tempfile.TemporaryDirectory() as tmp:
        base = Path(tmp).resolve()
        cases = {
            "not-json": "<!doctype html>",
            "empty-obj": "{}",
            "marker-false": json.dumps({"packagedTemplate": False}),
            "marker-truthy-not-true": json.dumps({"packagedTemplate": "yes"}),
            "json-array": "[]",
        }
        for name, body in cases.items():
            repo = _make_repo(base / name, body)
            assert not m.is_template_dir(repo), \
                f"{name} must not read as the packaged template"


@test("the shipped template carries the packagedTemplate marker (FLY-1144)")
def _():
    # If the marker is ever dropped from the shipped manifest, template
    # detection silently stops working and session state leaks into it
    # (FLY-1067) — with nothing failing to say so.
    if not _source_repo_present():
        return  # installed workspace — no source tree to check
    manifest = TEMPLATE_DIR / "manifest.json"
    assert manifest.is_file(), "template manifest.json missing"
    data = json.loads(manifest.read_text(encoding="utf-8"))
    assert data.get("packagedTemplate") is True, \
        "template/manifest.json must carry \"packagedTemplate\": true"


# ---------------------------------------------------------------------------
# Workspace <-> template parity guard
# ---------------------------------------------------------------------------

print("\n## workspace/template parity guard")

# Files that legitimately differ between the workspace root and the shipped
# template. Everything else must match exactly.
#
#   settings.json     workspace carries extra local permissions
#   CLAUDE.md         workspace carries a fuller Skills Index
#
# Add to this list only with a reason, and remove an entry once the divergence
# it describes is gone — a stale exception is worse than no exception, because
# it silently exempts a file nobody is watching any more.
#
# `hooks/usage-capture.py` was listed here as a "DEV DOGFOOD SHIM posting to the
# dev deployment". Verified 2026-07-29: the two copies are byte-identical and the
# shim no longer exists — it was lost (almost certainly deleted by FLY-1155,
# where a skipPaths entry naming a file caused that file's deletion) and never
# restored. The entry was therefore exempting a file with no divergence, so a
# real future drift in the attribution writer would not have been caught.
PARITY_EXCEPTIONS = {
    "settings.json",
    "CLAUDE.md",
}


@test("workspace and template .claude trees agree (except known exceptions)")
def _():
    """Guard against the two-copy trap.

    `.claude/` at the workspace root is a synced artifact; `template/.claude/`
    is what ships. Editing one and not the other is invisible — nothing fails.
    It has bitten four times, and once destructively: a `cp workspace →
    template` on `prompt-submit.py` overwrote 94 lines, removing
    `append_usage_attribution()` (the FLY-1013/FLY-1054 writer AI Spend depends
    on) and `get_active_project_id()` (ADR-011), because the workspace copy was
    older. That would have silently stopped usage attribution for every
    customer.

    This catches both directions — a fix ported one way but not the other, and
    a blind copy that clobbers the newer side.
    """
    if not _source_repo_present():
        return  # installed workspace — no template to compare against

    workspace_root = HOOKS_DIR.parent          # .../.claude
    template_root = TEMPLATE_DIR / ".claude"
    if not template_root.is_dir():
        return

    drifted = []
    for path in sorted(workspace_root.rglob("*")):
        if not path.is_file():
            continue
        if "__pycache__" in path.parts or path.name == "settings.local.json":
            continue
        rel = path.relative_to(workspace_root).as_posix()
        if rel in PARITY_EXCEPTIONS:
            continue
        counterpart = template_root / rel
        if not counterpart.is_file():
            continue  # workspace-only file (e.g. a skill the template omits)
        if path.read_bytes() != counterpart.read_bytes():
            drifted.append(rel)

    assert not drifted, (
        "workspace and template copies diverge — port the change to BOTH, and "
        "never blind-copy between them (the older side may hold content the "
        f"newer one lacks): {drifted}"
    )


# ---------------------------------------------------------------------------
# Hook detection is structural, not textual (FLY-1163)
# ---------------------------------------------------------------------------

print("\n## hook trigger precision (FLY-1163)")


def _run_wrap_hook(command: str) -> bool:
    """True when post-session-wrap-check.py warns for `command`."""
    import subprocess
    hook = HOOKS_DIR / "post-session-wrap-check.py"
    if not hook.is_file():
        return False
    payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": command}})
    out = subprocess.run(
        [sys.executable, str(hook)], input=payload,
        capture_output=True, text=True,
    ).stdout
    return "missing required section" in out


_INCOMPLETE_WRAP_BODY = "Accomplished\nsomething"
_COMPLETE_WRAP_BODY = (
    "## Accomplished\nx\n## Next up\ny\n## Blockers\nnone\n## Progress\n50%"
)


@test("wrap hook ignores prose that merely mentions session.py (FLY-1163)")
def _():
    # The real false positive: a PR body naming `session.py` in a table and
    # containing the word "wrapper" — which matches " wrap" — satisfied all
    # three substring gates, so a pull request description was validated against
    # the session-wrap template.
    assert not _run_wrap_hook(
        'gh pr create --title "t" '
        '--body "uses session.py list-issues and a wrapper object"'
    ), "a PR body mentioning session.py must not read as a session wrap"


@test("wrap hook ignores a command that only echoes the trigger words (FLY-1163)")
def _():
    assert not _run_wrap_hook('echo "session.py wrap --body Accomplished"')


@test("wrap hook still fires on a genuine incomplete wrap (FLY-1163)")
def _():
    # The guard must keep catching what it exists to catch.
    assert _run_wrap_hook(
        f'python3 scripts/session.py wrap --body "{_INCOMPLETE_WRAP_BODY}"'
    ), "a real wrap with missing sections must still warn"


@test("wrap hook stays quiet on a genuine complete wrap (FLY-1163)")
def _():
    assert not _run_wrap_hook(
        f'python3 scripts/session.py wrap --body "{_COMPLETE_WRAP_BODY}"'
    )


@test("wrap hook fires on project-update and behind a cd compound (FLY-1163)")
def _():
    assert _run_wrap_hook(
        f'python3 scripts/session.py project-update --body "{_INCOMPLETE_WRAP_BODY}"'
    ), "project-update should be covered"
    assert _run_wrap_hook(
        f'cd flydocs-app && python3 ../scripts/session.py wrap '
        f'--body "{_INCOMPLETE_WRAP_BODY}"'
    ), "a real invocation behind a shell compound should still be found"


@test("wrap hook ignores other session.py subcommands (FLY-1163)")
def _():
    assert not _run_wrap_hook(
        'python3 scripts/session.py status-summary --body "irrelevant"'
    )

# ---------------------------------------------------------------------------
# Status vocabulary — one source (FLY-1272)
# ---------------------------------------------------------------------------

print("\n## status vocabulary — one source (FLY-1272)")

import status_vocab  # noqa: E402

_VOCAB_NAMES = (
    "CANONICAL_STATUSES",
    "SOURCE_ONLY_STATUSES",
    "ALL_STATUSES",
    "TERMINAL_STATUSES",
    # FLY-1265 split terminal from closed. Both belong to the no-redefinition
    # guard below: a second CLOSED_STATUSES elsewhere is precisely the drift
    # that let "terminal" and "closed" mean the same thing for so long.
    "CLOSED_STATUSES",
    "VALID_TRANSITIONS",
    "PROVIDER_SUGGESTIONS",
    "DEFAULT_STATUS_MAPPING",
    "GATED_STATUSES",
    "EDIT_OK_STATUSES",
)

STATUS_DOC = SCRIPT_DIR.parent / "reference" / "status-workflow.md"


def _md_table_rows(section: str) -> list[list[str]]:
    """Return the data rows of the first markdown table under `## <section>`."""
    text = STATUS_DOC.read_text()
    start = text.index(f"## {section}")
    body = text[start:].split("\n## ", 1)[0]
    rows = []
    for line in body.splitlines():
        line = line.strip()
        if not line.startswith("|") or set(line) <= set("|- "):
            continue
        cells = [c.strip() for c in line.strip("|").split("|")]
        rows.append(cells)
    return rows[1:]  # drop the header row


@test("only status_vocab.py defines the vocabulary (FLY-1272)")
def _():
    # The drift this consolidates was invisible precisely because each copy
    # looked authoritative in its own file. A second definition of any of these
    # names is that failure starting again.
    claude_root = HOOKS_DIR.parent
    pattern = re.compile(
        rf"^({'|'.join(_VOCAB_NAMES)})\s*(:[^=]+)?=", re.MULTILINE
    )
    offenders = []
    for path in sorted(claude_root.rglob("*.py")):
        if path.name in ("status_vocab.py", "test_enforcement.py"):
            continue
        if "__pycache__" in path.parts:
            continue
        for name in set(pattern.findall(path.read_text())):
            offenders.append(f"{path.relative_to(claude_root).as_posix()}:{name[0]}")
    assert not offenders, (
        f"status vocabulary redefined outside status_vocab.py: {offenders}"
    )


@test("every consumer imports the vocabulary (FLY-1272)")
def _():
    consumers = {
        SCRIPT_DIR / "issues.py",
        SCRIPT_DIR / "context_parser.py",
        SCRIPT_DIR / "workspace.py",
        SCRIPT_DIR / "_local" / "file_store.py",
        HOOKS_DIR / "stop-gate.py",
        HOOKS_DIR / "prompt-submit.py",
        HOOKS_DIR / "auto-approve.py",
        HOOKS_DIR / "post-transition-check.py",
    }
    missing = [
        p.name for p in sorted(consumers)
        if "from status_vocab import" not in p.read_text()
    ]
    assert not missing, f"these still hold their own status names: {missing}"


@test("hooks reach the module from any working directory (FLY-1272)")
def _():
    # Hooks run from wherever the agent happens to be. An import that only
    # resolves from the repo root is an import that fails in production and
    # passes here.
    import subprocess
    for name in ("stop-gate.py", "prompt-submit.py", "auto-approve.py",
                 "post-transition-check.py"):
        with tempfile.TemporaryDirectory() as tmp:
            result = subprocess.run(
                [sys.executable, str(HOOKS_DIR / name)], input="{}",
                capture_output=True, text=True, timeout=20, cwd=tmp,
            )
        assert "ModuleNotFoundError" not in result.stderr, \
            f"{name} cannot import status_vocab from an unrelated cwd"


@test("TRIAGE is a source state, never a transition target (FLY-1272)")
def _():
    # The drift that made this issue real: issues.py validated transitions FROM
    # TRIAGE while refusing to spell it, and the post-transition hook had never
    # heard of it. Both now read the same table, which says entry-only.
    assert "TRIAGE" in status_vocab.VALID_TRANSITIONS
    assert "TRIAGE" not in status_vocab.CANONICAL_STATUSES
    assert "TRIAGE" in status_vocab.ALL_STATUSES
    assert not any(
        "TRIAGE" in targets for targets in status_vocab.VALID_TRANSITIONS.values()
    ), "no state should transition INTO an inbox"

    import subprocess
    result = subprocess.run(
        [sys.executable, str(SCRIPT_DIR / "issues.py"), "transition",
         "FLY-999", "TRIAGE", "should be rejected"],
        capture_output=True, text=True, timeout=10, env=HERMETIC_ENV, cwd=HERMETIC_CWD,
    )
    assert result.returncode != 0, "TRIAGE must not be an accepted target"
    assert "not a valid FlyDocs status" in result.stderr, \
        f"expected a vocabulary rejection, got: {result.stderr[:200]}"


@test("the post-transition hook now recognises TRIAGE (FLY-1272)")
def _():
    m = _load_hook_module("post-transition-check.py", "post_transition_check")
    assert m.canonical_status("TRIAGE") == "TRIAGE", \
        "an issue in a provider triage inbox must resolve, not read as unknown"
    # The pre-existing behaviour it must not have cost:
    assert m.canonical_status("In Progress") == "IMPLEMENTING"
    assert m.canonical_status("Not A Status") is None


@test("local tier stores every canonical status (FLY-1272)")
def _():
    from _local.file_store import STATUSES
    assert set(STATUSES) == set(status_vocab.CANONICAL_STATUSES), \
        "a status with no local directory is an issue with nowhere to live"
    assert {STATUSES[s] for s in status_vocab.TERMINAL_STATUSES} == {"done"}


@test("every status carrying a directive is a real one (FLY-1272)")
def _():
    m = _load_hook_module("prompt-submit.py", "prompt_submit")
    for status in status_vocab.ALL_STATUSES:
        m.get_workflow_directive(status, has_issue=True)  # must not raise
    for status in ("IMPLEMENTING", "REVIEW", "BLOCKED"):
        assert m.get_workflow_directive(status, has_issue=True), \
            f"{status} lost its standing instruction"
    assert m.get_workflow_directive("NOT_A_STATUS", has_issue=True) is None

    a = _load_hook_module("auto-approve.py", "auto_approve")
    unknown = [s for s in a.COMMENT_TEMPLATES if not status_vocab.is_status(s)]
    assert not unknown, f"comment templates keyed on unknown statuses: {unknown}"


@test("status-workflow.md documents every status in the module (FLY-1272)")
def _():
    # The doc is not generated — there is no generation step to invent — so it
    # is checked instead. Adding a status to the module without documenting it
    # fails here, which is the whole point.
    documented = {row[0] for row in _md_table_rows("State Meanings")}
    documented |= {row[0] for row in _md_table_rows("Terminal States")}
    missing = [s for s in status_vocab.ALL_STATUSES if s not in documented]
    assert not missing, f"statuses in the module but not in the doc: {missing}"
    invented = [
        s for s in documented if not status_vocab.is_status(s)
    ]
    assert not invented, f"doc names statuses the code does not know: {invented}"


@test("the doc's terminal states match the module exactly (FLY-1272)")
def _():
    terminal = {row[0] for row in _md_table_rows("Terminal States")}
    assert terminal == set(status_vocab.TERMINAL_STATUSES), (
        f"doc terminal states {sorted(terminal)} != module "
        f"{sorted(status_vocab.TERMINAL_STATUSES)}"
    )


@test("every documented transition is one the code permits (FLY-1272)")
def _():
    # The reverse direction of the same tripwire: a doc that promises a
    # transition the validator rejects sends people to a wall.
    impossible = []
    for row in _md_table_rows("Valid Transitions"):
        source, target = row[0], row[1]
        if target not in status_vocab.VALID_TRANSITIONS.get(source, frozenset()):
            impossible.append(f"{source} -> {target}")
    assert not impossible, (
        f"documented transitions the code rejects: {impossible}"
    )


@test("the doc points at the module as the source (FLY-1272)")
def _():
    text = STATUS_DOC.read_text()
    assert "status-vocab:source-of-truth" in text, \
        "the doc must be marked as documentation of the module"
    assert "status_vocab.py" in text, "the marker must name the module"


@test("no test is defined after the summary block (meta)")
def _():
    # Tests appended below the summary run after the results are printed — and
    # not at all when `sys.exit(1)` fires on a failure — so they silently do not
    # count. Six FLY-1163 tests were lost this way before this guard existed.
    src = Path(__file__).read_text()
    marker = "# Summary\n# ---"
    pos = src.find(marker)
    assert pos != -1, "summary block marker not found"
    after = src[pos:]
    stray = [ln for ln in after.splitlines() if ln.startswith("@test(")]
    assert not stray, (
        f"{len(stray)} test(s) defined after the summary block will never be "
        f"counted — move them above it: {stray[:2]}"
    )


# ---------------------------------------------------------------------------
# Graph query correctness (FLY-1274) — spike fixtures T1-T8, 2026-08-09
# ---------------------------------------------------------------------------

print("\n## graph query correctness (FLY-1274)")

import graph_utils  # noqa: E402

GRAPH_QUERY = SCRIPT_DIR / "graph_query.py"
ACTIVATE_DOC = SCRIPT_DIR.parent / "stages" / "activate.md"

# The known-answer topology from the 2026-08-09 spike. Its point is the BLOCKS
# edge: FLY-1 blocks FLY-2, so the blocker of FLY-2 is reachable ONLY by
# walking that edge backwards. Every T-case below has a hand-checked answer.
GRAPH_FIXTURE = {
    "version": 1,
    "updated": "2026-08-09T00:00:00+00:00",
    "nodes": {
        "issue:FLY-1": {"type": "issue", "label": "Auth refactor", "status": "in_progress"},
        "issue:FLY-2": {"type": "issue", "label": "Login UI", "status": "ready"},
        "issue:FLY-3": {"type": "issue", "label": "Session docs", "status": "done"},
        "decision:001": {"type": "decision", "label": "Use REST", "status": "superseded"},
        "decision:002": {"type": "decision", "label": "Use GraphQL", "status": "accepted"},
        "repo:acme/core": {"type": "repo", "label": "core", "purpose": "CLI"},
        "repo:acme/app": {"type": "repo", "label": "app", "purpose": "Dashboard"},
    },
    "edges": [
        {"from": "issue:FLY-1", "to": "issue:FLY-2", "rel": "BLOCKS", "weight": 1.0},
        {"from": "issue:FLY-3", "to": "issue:FLY-2", "rel": "RELATES_TO", "weight": 0.5},
        {"from": "decision:002", "to": "decision:001", "rel": "SUPERSEDES", "weight": 1.0},
        {"from": "repo:acme/core", "to": "repo:acme/app", "rel": "PROVIDES",
         "weight": 1.0, "interface": "REST API /api/relay/*"},
        {"from": "repo:acme/app", "to": "repo:acme/core", "rel": "CONSUMES",
         "weight": 1.0, "interface": "REST API /api/relay/*"},
    ],
}

GRAPH_FIXTURE_ROOT = Path(tempfile.mkdtemp(prefix="flydocs-graph-fixture-"))
(GRAPH_FIXTURE_ROOT / "flydocs" / "context").mkdir(parents=True, exist_ok=True)
(GRAPH_FIXTURE_ROOT / "flydocs" / "context" / "graph.json").write_text(
    json.dumps(GRAPH_FIXTURE), encoding="utf-8"
)


def _graph_query(*args):
    """Run the real graph_query.py against the fixture graph."""
    import subprocess
    return subprocess.run(
        [sys.executable, str(GRAPH_QUERY), "--root", str(GRAPH_FIXTURE_ROOT), *args],
        capture_output=True, text=True, timeout=10, cwd=HERMETIC_CWD,
    )


def _assert_lists_every_relation(stderr):
    missing = [r for r in graph_utils.VALID_REL_TYPES if r not in stderr]
    assert not missing, f"error must name every valid relation, missing {missing}"


@test("T1: bare ref normalizes, BLOCKED_BY errors instead of missing (FLY-1274)")
def _():
    # Before: exit 1 "Node not found: FLY-2" — the ID never reached the graph.
    result = _graph_query("--node", "FLY-2", "--rel", "BLOCKS", "--rel", "BLOCKED_BY")
    assert result.returncode == 1, f"expected exit 1, got {result.returncode}"
    assert "Node not found" not in result.stderr, (
        f"bare FLY-2 must normalize to issue:FLY-2, got: {result.stderr}"
    )
    assert "Invalid relationship: BLOCKED_BY" in result.stderr, result.stderr
    _assert_lists_every_relation(result.stderr)


@test("T2: prefixed node with BLOCKED_BY errors, no longer silent empty (FLY-1274)")
def _():
    # Before: exit 0, "No related nodes found." — indistinguishable from a
    # genuinely unblocked issue. This is what made the gate useless.
    result = _graph_query("--node", "issue:FLY-2", "--rel", "BLOCKS", "--rel", "BLOCKED_BY")
    assert result.returncode == 1, f"expected exit 1, got {result.returncode}"
    assert "Invalid relationship: BLOCKED_BY" in result.stderr, result.stderr


@test("T3: BLOCKED_BY alone errors and names the flag that answers it (FLY-1274)")
def _():
    result = _graph_query("--node", "issue:FLY-2", "--rel", "BLOCKED_BY")
    assert result.returncode == 1, f"expected exit 1, got {result.returncode}"
    assert "--direction in" in result.stderr, (
        f"the error must point at the working invocation: {result.stderr}"
    )


@test("T4: --reverse still finds the incoming blocker, with a deprecation notice (FLY-1274)")
def _():
    result = _graph_query("--node", "issue:FLY-2", "--rel", "BLOCKS", "--reverse")
    assert result.returncode == 0, result.stderr
    assert "issue:FLY-1" in result.stdout, f"blocker not found: {result.stdout}"
    assert "incoming" in result.stdout, "an incoming edge must be labelled as such"
    assert "deprecated" in result.stderr, "callers need to hear about --direction"


@test("T5: forward traversal is unchanged and reports its direction (FLY-1274)")
def _():
    result = _graph_query("--node", "issue:FLY-1", "--rel", "BLOCKS", "--format", "json")
    assert result.returncode == 0, result.stderr
    payload = json.loads(result.stdout)
    assert payload["direction"] == "out"
    assert len(payload["related"]) == 1, payload["related"]
    edge = payload["related"][0]
    assert edge["id"] == "issue:FLY-2" and edge["depth"] == 1, edge
    assert edge["direction"] == "out", edge


@test("T6: SUPERSEDES chain resolves backwards, unpadded ADR number and all (FLY-1274)")
def _():
    result = _graph_query("--node", "decision:1", "--rel", "SUPERSEDES", "--direction", "in")
    assert result.returncode == 0, result.stderr
    assert "decision:002" in result.stdout, result.stdout


@test("T7: repo CONSUMES traversal is unchanged (FLY-1274)")
def _():
    result = _graph_query("--node", "repo:acme/app", "--rel", "CONSUMES")
    assert result.returncode == 0, result.stderr
    assert "repo:acme/core" in result.stdout, result.stdout


@test("T8: an unknown relation errors with the valid list (FLY-1274)")
def _():
    # Before: exit 0, empty result — a typo was indistinguishable from an answer.
    result = _graph_query("--node", "issue:FLY-2", "--rel", "TOTALLY_FAKE")
    assert result.returncode == 1, f"expected exit 1, got {result.returncode}"
    assert "Invalid relationship: TOTALLY_FAKE" in result.stderr, result.stderr
    _assert_lists_every_relation(result.stderr)


@test("--direction both answers the blocker question from a bare ref (FLY-1274)")
def _():
    # The whole point of the fix: the activation gate's actual query.
    result = _graph_query("--node", "FLY-2", "--rel", "BLOCKS",
                          "--direction", "both", "--depth", "1", "--format", "json")
    assert result.returncode == 0, result.stderr
    payload = json.loads(result.stdout)
    assert payload["node"] == "issue:FLY-2", payload["node"]
    blockers = [e for e in payload["related"] if e["direction"] == "in"]
    assert [e["id"] for e in blockers] == ["issue:FLY-1"], payload["related"]


@test("the documented activation invocation actually runs (FLY-1274)")
def _():
    """AC 4: activate.md's command is executed, not just eyeballed.

    The old line passed a bare ref against prefixed IDs and asked for a
    relation that does not exist. Nothing tested it, so it stayed wrong for
    months while the gate silently reported "no blockers" for every issue.
    """
    doc = ACTIVATE_DOC.read_text(encoding="utf-8")
    # FLY-929: the documented surface is the operation ID, not the script path.
    # The property is unchanged — whatever activate.md tells an agent to run is
    # executed here against a known-answer graph.
    lines = [ln.strip() for ln in doc.splitlines() if "flydocs run graph.query" in ln]
    assert lines, "activate.md must document the blocker query"
    command = lines[0]
    assert "--rel BLOCKED_BY" not in command, (
        f"activate.md still asks for a non-existent relation: {command}"
    )

    argv = command.split()
    argv = argv[argv.index("--node"):]          # drop `flydocs run graph.query`
    argv = ["FLY-2" if a == "<issue-id>" else a for a in argv]
    result = _graph_query(*argv)
    assert result.returncode == 0, (
        f"documented invocation failed: {result.stderr}"
    )
    assert "issue:FLY-1" in result.stdout, (
        f"documented invocation misses the blocker: {result.stdout}"
    )


@test("no stage doc asks for a relation the schema lacks (FLY-1274)")
def _():
    stages = SCRIPT_DIR.parent / "stages"
    offenders = []
    for doc in sorted(stages.glob("*.md")):
        for match in re.finditer(r"--rel\s+([A-Za-z_]+)", doc.read_text(encoding="utf-8")):
            if match.group(1).upper() not in graph_utils.VALID_REL_TYPES:
                offenders.append(f"{doc.name}: {match.group(1)}")
    assert not offenders, f"docs pass relations that now hard-error: {offenders}"


@test("normalize_node_id follows the build side's ID scheme (FLY-1274)")
def _():
    nodes = GRAPH_FIXTURE["nodes"]
    cases = {
        "FLY-2": "issue:FLY-2",          # bare ref, as stage docs write it
        "fly-2": "issue:FLY-2",          # issue identifiers are uppercase
        "ISSUE:fly-2": "issue:FLY-2",    # prefixes are lowercase
        "decision:1": "decision:001",    # ADR numbers are zero-padded to 3
        "  issue:FLY-2  ": "issue:FLY-2",
        "repo:ACME/app": "repo:acme/app",  # slugs match case-insensitively
        "issue:FLY-2": "issue:FLY-2",
    }
    for given, expected in cases.items():
        actual = graph_utils.normalize_node_id(given, nodes)
        assert actual == expected, f"{given!r} -> {actual!r}, expected {expected!r}"


@test("an exact node ID is never normalized away from itself (FLY-1274)")
def _():
    # A graph holding an unconventional ID must stay queryable.
    odd = {"FLY-2": {"type": "issue", "label": "legacy"}}
    assert graph_utils.normalize_node_id("FLY-2", odd) == "FLY-2"


@test("a lowercase --rel is accepted rather than silently filtering out (FLY-1274)")
def _():
    result = _graph_query("--node", "issue:FLY-1", "--rel", "blocks")
    assert result.returncode == 0, result.stderr
    assert "issue:FLY-2" in result.stdout, result.stdout


@test("--reverse and a conflicting --direction is an error, not a guess (FLY-1274)")
def _():
    result = _graph_query("--node", "issue:FLY-2", "--rel", "BLOCKS",
                          "--reverse", "--direction", "out")
    assert result.returncode == 1, f"expected exit 1, got {result.returncode}"
    assert "--reverse" in result.stderr, result.stderr


# ---------------------------------------------------------------------------
# Description writer revision token (FLY-1468 — spec §8)
# ---------------------------------------------------------------------------
#
# `issues.py description` replaces the whole document, so `expectedRevision`
# is the only thing between "I edited the description I read" and "I reverted
# whatever landed while I was writing". FLY-1292 found every REVISION_REQUIRED
# would-block verdict in the warn window came from this one writer sending no
# token at all — under `enforce` that is a 400 on every description write.
#
# Same harness as the acceptance tests: `cmd_description` in-process against a
# fake client, plus one test through the real `FlyDocsClient` method to pin the
# wire shape (body field, not header).

print("\n## description writer revision token (FLY-1468)")


class _FakeDescriptionClient:
    """Cloud client whose description route plays a scripted list of outcomes."""

    def __init__(self, issue=None, outcomes=(), tier="cloud"):
        self.tier = tier
        self.is_cloud = tier == "cloud"
        self.issue = (
            issue if issue is not None
            else {"identifier": "FLY-1", "revision": "rev-1"}
        )
        self.outcomes = list(outcomes) or [{"success": True, "issue": "FLY-1"}]
        self.reads = []
        self.calls = []

    def get_issue(self, ref, **kwargs):
        self.reads.append({"ref": ref, "fields": kwargs.get("fields")})
        return self.issue

    def update_description(self, ref, text, expected_revision=None,
                           expected_description_hash=None):
        self.calls.append({
            "ref": ref, "text": text, "revision": expected_revision,
            "hash": expected_description_hash,
        })
        outcome = self.outcomes[min(len(self.calls) - 1, len(self.outcomes) - 1)]
        if isinstance(outcome, Exception):
            raise outcome
        return outcome


def _run_description(client, ref="FLY-1", **flags):
    """Run `issues.py description` in-process. Returns (exit_code, out, err)."""
    m = _load_issues_module()
    args = _types.SimpleNamespace(
        ref=ref, text="rewritten body", file=None, expected_revision=None,
        expected_description_hash=None,
    )
    for key, value in flags.items():
        setattr(args, key, value)
    out, err = _io.StringIO(), _io.StringIO()
    code = 0
    with patch.object(m, "get_client", lambda: client), \
            _contextlib.redirect_stdout(out), _contextlib.redirect_stderr(err):
        try:
            m.cmd_description(args)
        except SystemExit as exit_error:
            code = exit_error.code or 0
    return code, out.getvalue(), err.getvalue()


@test("description sends the revision it read (FLY-1468)")
def _():
    client = _FakeDescriptionClient()
    code, out, err = _run_description(client)
    assert code == 0, f"exit {code}: {err}"
    assert len(client.reads) == 1, client.reads
    assert client.reads[0]["fields"] == "basic", \
        "the token rides on the basic field set — do not pay for comments"
    assert len(client.calls) == 1, client.calls
    assert client.calls[0]["revision"] == "rev-1", \
        "the whole point: the write echoes the revision the read returned"
    assert client.calls[0]["text"] == "rewritten body"
    assert json.loads(out)["success"] is True


@test("--expected-revision is sent as given, with no second read (FLY-1468)")
def _():
    # The honest token is from the read the new text was written against.
    client = _FakeDescriptionClient()
    code, _out, err = _run_description(client, expected_revision="rev-from-my-read")
    assert code == 0, f"exit {code}: {err}"
    assert client.reads == [], "a caller who has the token pays for no extra read"
    assert client.calls[0]["revision"] == "rev-from-my-read", client.calls


@test("REVISION_MISMATCH refuses the rewrite instead of retrying it (FLY-1468)")
def _():
    conflict = _relay_error("REVISION_MISMATCH", {
        "error": "Issue revision does not match.",
        "code": "REVISION_MISMATCH",
        "revision": "rev-2",
        "hint": "re-issue with the returned revision",
    })
    client = _FakeDescriptionClient(outcomes=[conflict])
    code, out, err = _run_description(client)
    assert code == 1, f"a whole-description rewrite must not be replayed: {err}"
    assert len(client.calls) == 1, \
        f"exactly one attempt — retrying is the clobber: {client.calls}"
    assert "changed since you read it" in err, err
    assert "re-read and retry" in err, err
    assert out == "", "a refused write must not print a success payload"
    assert "rev-2" in err, "hand back the fresh token the relay returned"


@test("REVISION_REQUIRED with no token to send is refused, not retried (FLY-1468)")
def _():
    # Defensive: a provider with no revision string means nothing can be
    # echoed, so a workspace on `requireRevision` rejects the write. Say so.
    required = _relay_error("REVISION_REQUIRED", {
        "error": "This operation requires expectedRevision.",
        "code": "REVISION_REQUIRED",
    }, status=400)
    client = _FakeDescriptionClient(
        issue={"identifier": "FLY-1", "revision": ""}, outcomes=[required]
    )
    code, out, err = _run_description(client)
    assert code == 1, err
    assert len(client.calls) == 1, client.calls
    assert client.calls[0]["revision"] is None, \
        "an empty provider token is 'unknown' — never send it as a value"
    assert "revision token" in err, err
    assert "--expected-revision" in err, "name the flag that fixes it"
    assert out == "", "nothing written, nothing announced"


@test("a non-lifecycle relay rejection still renders as itself (FLY-1468)")
def _():
    client = _FakeDescriptionClient(outcomes=[_relay_error(
        "VALIDATION_ERROR", {"error": "text exceeds the field limit"}, status=400
    )])
    code, _out, err = _run_description(client)
    assert code == 1 and "VALIDATION_ERROR" in err, err
    assert "text exceeds the field limit" in err, err


class _RecordingRelay:
    """Just enough RelayBackend for FlyDocsClient.update_description."""

    def __init__(self, result=None):
        self.puts = []
        self.result = result or {"success": True, "issue": "FLY-1"}

    def put(self, path, body=None, raise_on_error=False):
        self.puts.append(
            {"path": path, "body": body, "raise_on_error": raise_on_error}
        )
        return self.result


def _cloud_client(relay):
    from flydocs_api import FlyDocsClient

    client = object.__new__(FlyDocsClient)
    client.tier = "cloud"
    client._relay = relay
    return client


@test("update_description puts expectedRevision in the BODY (FLY-1468)")
def _():
    # Wire shape, pinned: `issues/[ref]/description/route.ts` reads
    # `parsed.body.expectedRevision` — the same body field the acceptance
    # route takes. There is no If-Match header on this route.
    relay = _RecordingRelay()
    result = _cloud_client(relay).update_description("FLY-1", "body", "rev-1")
    assert len(relay.puts) == 1, relay.puts
    put = relay.puts[0]
    assert put["path"] == "/issues/FLY-1/description", put
    assert put["body"] == {"text": "body", "expectedRevision": "rev-1"}, put
    assert put["raise_on_error"] is True, \
        "a 409 is a recovery the caller renders, not a bare exit"
    assert result["success"] is True


@test("update_description omits expectedRevision when there is none (FLY-1468)")
def _():
    # Absent means absent: the route pass-opens on a missing field while
    # `requireRevision` is off, but an empty string would be compared and
    # would fail every time.
    relay = _RecordingRelay()
    _cloud_client(relay).update_description("FLY-1", "body", None)
    assert relay.puts[0]["body"] == {"text": "body"}, relay.puts[0]


# -- The guard has to be load-bearing, not advisory (FLY-1468 review) --------
#
# A token read milliseconds before the PUT authenticates almost nothing: the
# refusal it produces is undone by the most natural recovery there is, running
# the same command again without the flag. So on the cloud tier `--file` — text
# composed earlier, by definition — must carry `--expected-revision` or be
# refused before anything is read or written. `--text`/stdin keep the self-read
# for short inline edits, and say out loud what it does not cover.


def _description_file(text="rewritten body"):
    handle = tempfile.NamedTemporaryFile(
        "w", suffix=".md", delete=False, dir=HERMETIC_CWD
    )
    handle.write(text)
    handle.close()
    return handle.name


@test("a cloud --file rewrite without --expected-revision is refused (FLY-1468)")
def _():
    client = _FakeDescriptionClient()
    code, out, err = _run_description(
        client, text=None, file=_description_file()
    )
    assert code == 1, f"exit {code}: {err}"
    assert client.reads == [], "refuse before the read, not after"
    assert client.calls == [], "nothing may reach the relay"
    assert "must carry the guard it was written against" in err, err
    assert "--expected-revision" in err, err
    assert out == ""


@test("the naive re-run of a refused --file rewrite is refused too (FLY-1468)")
def _():
    # The whole review finding in one test. Step A is the honest attempt and
    # is correctly refused. Step B is what an agent does next — same file, no
    # flag. Before the fix that wrote the stale text over the concurrent edit
    # with exit 0; the refusal was advice, not a guard.
    path = _description_file("text composed against rev-1")

    conflict = _relay_error("REVISION_MISMATCH", {
        "error": "Issue revision does not match.",
        "revision": "rev-2",
    })
    step_a = _FakeDescriptionClient(outcomes=[conflict])
    code_a, _out_a, _err_a = _run_description(
        step_a, text=None, file=path, expected_revision="rev-1"
    )
    assert code_a == 1, "the honest attempt is refused — the issue moved"
    assert len(step_a.calls) == 1, step_a.calls

    # A client whose relay would happily accept the write: the only thing
    # standing between the stale text and the description is this guard.
    step_b = _FakeDescriptionClient(outcomes=[{"success": True, "issue": "FLY-1"}])
    code_b, out_b, err_b = _run_description(step_b, text=None, file=path)
    assert code_b == 1, "the re-run must not launder a refused rewrite"
    assert step_b.calls == [], f"the stale text reached the relay: {step_b.calls}"
    assert step_b.reads == [], "and it must not self-read its way around the guard"
    assert out_b == ""
    assert "--expected-revision" in err_b, err_b


@test("--file with --expected-revision writes with no self-read (FLY-1468)")
def _():
    client = _FakeDescriptionClient()
    code, out, err = _run_description(
        client, text=None, file=_description_file("refined body"),
        expected_revision="rev-1",
    )
    assert code == 0, f"exit {code}: {err}"
    assert client.reads == [], "the caller brought the token; buy no other"
    assert client.calls[0] == {
        "ref": "FLY-1", "text": "refined body", "revision": "rev-1",
        "hash": None,
    }, client.calls
    assert json.loads(out)["success"] is True


@test("the --text self-read says what it does not protect (FLY-1468)")
def _():
    client = _FakeDescriptionClient()
    code, _out, err = _run_description(client, text="a one-line fix")
    assert code == 0, err
    assert client.calls[0]["revision"] == "rev-1"
    assert "no --expected-revision" in err, err
    assert "not detected" in err, \
        f"the caller has to know the self-read is not the real guarantee: {err}"


@test("REVISION_MISMATCH warns that a tokenless re-run would overwrite (FLY-1468)")
def _():
    conflict = _relay_error("REVISION_MISMATCH", {
        "error": "Issue revision does not match.",
        "revision": "rev-2",
    })
    client = _FakeDescriptionClient(outcomes=[conflict])
    code, _out, err = _run_description(client, text="inline edit")
    assert code == 1, err
    assert "without --expected-revision" in err, err
    assert "overwrite" in err, \
        f"name the recovery that silently loses the other edit: {err}"


@test("local tier warns that --expected-revision is ignored (FLY-1468)")
def _():
    client = _FakeDescriptionClient(tier="local")
    code, _out, err = _run_description(client, expected_revision="rev-1")
    assert code == 0, err
    assert client.reads == [], "there is no relay to be raced on local tier"
    assert client.calls[0]["revision"] is None, client.calls
    assert "--expected-revision" in err and "local" in err, \
        f"a silently dropped safety flag is worse than no flag: {err}"


@test("local tier --file needs no token and writes the file store (FLY-1468)")
def _():
    # The cloud-only guard must not leak onto local tier, and the local branch
    # is exercised for real here rather than through a fake client: this is
    # `FlyDocsClient.update_description` writing an actual issue file.
    from flydocs_api import FlyDocsClient

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        issue_dir = root / "flydocs" / "issues" / "backlog"
        issue_dir.mkdir(parents=True)
        issue_file = issue_dir / "FLY-1-a-thing.md"
        issue_file.write_text(
            "---\nid: FLY-1\ntitle: A thing\n---\n\nthe old description\n"
        )

        client = object.__new__(FlyDocsClient)
        client.tier = "local"
        client.project_root = root
        result = client.update_description("FLY-1", "the new description")

        assert result["success"] is True, result
        written = issue_file.read_text()
        assert "the new description" in written, written
        assert "the old description" not in written, written
        assert "expectedRevision" not in written, \
            "the token is a relay concept — it must not leak into the file"


@test("the acceptance fallback hint sends you to re-read, not to its own token (FLY-1468)")
def _():
    # This branch fires after `cmd_acceptance` has read the issue, so it is
    # holding a revision — and must not offer it. An agent that already has the
    # description in context from an earlier read would edit THAT text and send
    # it with this newer token, clobbering anything that landed in between. The
    # script never prints the description, so a re-read is required anyway; the
    # token would buy nothing but false confidence.
    client = _FakeAcceptanceClient(issue={"revision": "rev-1"})
    code, _out, err = _run_acceptance(client, check=["1"])
    assert code == 1, err
    assert client.calls == []
    assert "re-read it first" in err, err
    assert "--expected-revision" in err, "still name the flag — it is required"
    # One token on the command line, its source in the prose under it
    # (FLY-1488) — a placeholder that reads as a sentence gets substituted as
    # one, and the shell splits it into arguments.
    assert "--expected-revision <REVISION>" in err, err
    assert "<REVISION> is the `revision` field from that re-read" in err, err
    assert "rev-1" not in err, \
        f"a concrete token here launders the same clobber through a hint: {err}"


# ---------------------------------------------------------------------------
#
# `issues.py update --description` was the second whole-document writer, and
# the one with no revision check on either side of the wire: the relay's
# `PATCH /issues/:ref` full-replaced the description and recorded no verdict,
# so FLY-1468's guard could be walked around by spelling the same write
# differently. FLY-1469 retires it — `issues.py description` is the one
# description writer, and it is the one that carries the token.

print("\n## update --description is retired (FLY-1469)")


class _FakeUpdateClient:
    """Records what `update` would have sent.

    No `tier` / `is_cloud`: `cmd_update` never asks, so carrying them would
    imply a branch that does not exist. The test below proves the stronger
    property directly — the refusal lands before any client is built.
    """

    def __init__(self):
        self.calls = []

    def update_issue(self, ref, **fields):
        self.calls.append({"ref": ref, "fields": fields})
        return {"success": True, "issue": ref, "updated": list(fields)}


def _run_update(client, ref="FLY-1", **flags):
    """Run `issues.py update` in-process. Returns (exit_code, out, err)."""
    m = _load_issues_module()
    args = _types.SimpleNamespace(
        ref=ref, title=None, priority=None, estimate=None, assignee=None,
        state=None, description=None, description_file=None, labels=None,
        milestone=None, due_date=None, comment=None, project=None,
    )
    for key, value in flags.items():
        setattr(args, key, value)
    out, err = _io.StringIO(), _io.StringIO()
    code = 0
    with patch.object(m, "get_client", lambda: client), \
            _contextlib.redirect_stdout(out), _contextlib.redirect_stderr(err):
        try:
            m.cmd_update(args)
        except SystemExit as exit_error:
            code = exit_error.code or 0
    return code, out.getvalue(), err.getvalue()


@test("update --description is refused and reaches no relay (FLY-1469)")
def _():
    client = _FakeUpdateClient()
    code, out, err = _run_update(client, description="a rewritten body")
    assert code == 1, f"exit {code}: {err}"
    assert client.calls == [], f"the unguarded write still went out: {client.calls}"
    assert out == "", "a refused write must not print a success payload"
    assert "issues.py description" in err, \
        f"name the command that does carry the revision check: {err}"


@test("update --description-file is refused too — same write, other spelling (FLY-1469)")
def _():
    # The file path is the dangerous one: text composed earlier, against a read
    # that may be minutes old. FLY-1468 refuses it without a token; leaving it
    # reachable here would have made that refusal advisory.
    client = _FakeUpdateClient()
    for flag in ("description_file",):
        code, _out, err = _run_update(client, **{flag: "/tmp/does-not-matter.md"})
        assert code == 1, f"--{flag} still ran: {err}"
        assert "issues.py description" in err, err
    assert client.calls == []


@test("the refusal names --expected-revision, not just the new command (FLY-1469)")
def _():
    client = _FakeUpdateClient()
    _code, _out, err = _run_update(client, description="body")
    assert "--expected-revision" in err, \
        f"an agent that reads this has to know the token is the point: {err}"


@test("update refuses before touching any other field it was given (FLY-1469)")
def _():
    # Half-applying the batch would be worse than refusing it: the caller
    # would have to work out which fields landed before retrying.
    client = _FakeUpdateClient()
    code, _out, _err = _run_update(
        client, title="New title", description="body", priority=1
    )
    assert code == 1
    assert client.calls == [], f"the rest of the batch was applied: {client.calls}"


@test("update still updates everything that is not a description (FLY-1469)")
def _():
    client = _FakeUpdateClient()
    code, out, err = _run_update(client, title="New title", priority=1)
    assert code == 0, f"exit {code}: {err}"
    assert len(client.calls) == 1, client.calls
    assert client.calls[0]["fields"] == {"title": "New title", "priority": 1}, \
        client.calls[0]
    assert json.loads(out)["success"] is True


@test("the refusal is ours, not argparse's — the flag still parses (FLY-1469)")
def _():
    # Retiring the flag by deleting it from the parser would answer
    # `unrecognized arguments: --description`, which tells an agent nothing
    # about where the write moved to. argparse exits 2; we exit 1 with a
    # redirect. This pins that choice.
    m = _load_issues_module()
    out, err = _io.StringIO(), _io.StringIO()
    code = 0
    argv = ["issues.py", "update", "FLY-1", "--description", "body"]
    with patch.object(sys, "argv", argv), \
            patch.object(m, "get_client", lambda: _FakeUpdateClient()), \
            _contextlib.redirect_stdout(out), _contextlib.redirect_stderr(err):
        try:
            m.main()
        except SystemExit as exit_error:
            code = exit_error.code or 0
    stderr = err.getvalue()
    assert code == 1, f"argparse rejected the flag itself (exit {code}): {stderr}"
    assert "issues.py description" in stderr, stderr


@test("the refusal precedes the client, so no tier can reach the write (FLY-1469)")
def _():
    # Stated as what is actually checkable. A fake client cannot prove
    # tier-independence — `cmd_update` would have to ask it something, and it
    # never does. What CAN be proven is stronger: the refusal happens before
    # `get_client()` is called at all, so there is no branch for a tier to take
    # and no local escape hatch to grow later. (`issues.py description` writes
    # the file store directly on local tier — FLY-1468 — so nothing is lost.)
    m = _load_issues_module()

    def _no_client():
        raise AssertionError(
            "cmd_update built a client before refusing — a tier could branch here"
        )

    args = _types.SimpleNamespace(
        ref="FLY-1", title=None, priority=None, estimate=None, assignee=None,
        state=None, description="body", description_file=None, labels=None,
        milestone=None, due_date=None, comment=None, project=None,
    )
    out, err = _io.StringIO(), _io.StringIO()
    code = 0
    with patch.object(m, "get_client", _no_client), \
            _contextlib.redirect_stdout(out), _contextlib.redirect_stderr(err):
        try:
            m.cmd_update(args)
        except SystemExit as exit_error:
            code = exit_error.code or 0
    assert code == 1, f"exit {code}: {err.getvalue()}"
    assert "issues.py description" in err.getvalue(), err.getvalue()
    assert out.getvalue() == ""


# ---------------------------------------------------------------------------
# Description content guard (FLY-1470 — spec §8, §11)
# ---------------------------------------------------------------------------
#
# `expectedRevision` is a last-modified timestamp (Linear `Issue.updatedAt`,
# Jira `fields.updated`), so it answers "did anything about this issue change".
# That is not what a whole-document description rewrite is asking. Measured on
# 2026-08-29: a comment by another actor did NOT move the token on the current
# mapping, while a status change DID — so a correctly-read token 409s on
# activity that never touched a word of the prose, which is exactly what an
# agent produces between reading an issue and rewriting its description.
#
# `expectedDescriptionHash` guards the document instead. The normalization is
# defined once in `lifecycle/description-hash.ts` on the relay and mirrored in
# `flydocs_api.description_hash`; the fixtures below are copied verbatim from
# `src/lib/relay/lifecycle/__tests__/description-hash.test.ts`, so a change to
# either implementation that the other does not follow goes red in both suites
# instead of 409-ing in production.

print("\n## description content guard (FLY-1470)")

# Shared with the TS suite — keep the two copies byte-identical.
# (name, input, normalized, sha256(normalized))
DESCRIPTION_HASH_FIXTURES = [
    (
        "plain",
        "## What\n\nA test issue.",
        "## What\n\nA test issue.",
        "0e148727fb41cd3166253c82c391535967016d4ceb65f175253663810192e58c",
    ),
    (
        "crlf",
        "## What\r\n\r\nA test issue.\r\n",
        "## What\n\nA test issue.",
        "0e148727fb41cd3166253c82c391535967016d4ceb65f175253663810192e58c",
    ),
    (
        "attribution",
        "**@matt elsey** (via FlyDocs)\n\n## What\n\nA test issue.",
        "## What\n\nA test issue.",
        "0e148727fb41cd3166253c82c391535967016d4ceb65f175253663810192e58c",
    ),
    (
        "doubled-attribution",
        "**@matt elsey** (via FlyDocs)\n\n**@kyle gritzan** (via FlyDocs)\n\n## What\n\nA test issue.",
        "## What\n\nA test issue.",
        "0e148727fb41cd3166253c82c391535967016d4ceb65f175253663810192e58c",
    ),
    (
        "attribution-single-newline",
        "**@matt elsey** (via FlyDocs)\nBody line",
        "Body line",
        "cad32d9e7ae2a41f8930387426cd5a15a168c6d6a45a08e8eb06d6cec2ef721f",
    ),
    (
        "trailing-whitespace",
        "## What   \n\nA test issue.\t\n\n\n",
        "## What\n\nA test issue.",
        "0e148727fb41cd3166253c82c391535967016d4ceb65f175253663810192e58c",
    ),
    (
        "interior-blank-lines",
        "a\n\n\nb",
        "a\n\n\nb",
        "2282b3efda183ac2a3e762d8cf310bda78e79126b43846b41e1875fd043463cb",
    ),
    (
        "look-alike-prefix",
        "**@matt** (via Linear)\n\nBody",
        "**@matt** (via Linear)\n\nBody",
        "6831dc5beda56575efe9dac3b5ba36186b96c6df84d1a8e1a9fb35043209bad5",
    ),
    (
        "empty",
        "",
        "",
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    ),
    (
        "checkboxes",
        "## Acceptance Criteria\n\n- [x] One  \n- [ ] Two\r\n",
        "## Acceptance Criteria\n\n- [x] One\n- [ ] Two",
        "6475348197a9372343cab404ab3c92f38e4c45040ab37b0ac047a4fb4daae186",
    ),
    (
        # An unpaired surrogate survives `json.loads` in Python and reaches
        # `.encode("utf-8")`, which raises. Node's Buffer.from silently
        # substitutes U+FFFD, so the digests only agree if Python substitutes
        # the same code point — `errors="replace"` would write `?` (0x3f) and
        # diverge. Normalization leaves the character alone; the hash scrubs.
        "lone-surrogate",
        "a\ud800b",
        "a\ud800b",
        "05087813392efc16fe8ff448920c6328e53af865df39419436659d9ffda90f7b",
    ),
]

READ_TEXT = "## What\n\nA test issue."


@test("description_hash normalization matches the pinned fixtures (FLY-1470)")
def _():
    from flydocs_api import normalize_description_for_hash

    for name, raw, normalized, _sha in DESCRIPTION_HASH_FIXTURES:
        actual = normalize_description_for_hash(raw)
        assert actual == normalized, f"{name}: {actual!r} != {normalized!r}"
        assert normalize_description_for_hash(actual) == actual, \
            f"{name}: normalization is not idempotent"


@test("description_hash digests match the TypeScript suite byte for byte (FLY-1470)")
def _():
    # The cross-language contract. If this drifts, every hash-carrying write
    # 409s with DESCRIPTION_CHANGED against a description nobody touched.
    from flydocs_api import description_hash

    for name, raw, _normalized, sha in DESCRIPTION_HASH_FIXTURES:
        actual = description_hash(raw)
        assert actual == sha, f"{name}: {actual} != {sha}"


@test("the attribution header the relay adds does not change the digest (FLY-1470)")
def _():
    # FLY-560 prepends `**@handle** (via FlyDocs)` to descriptions written with
    # workspace credentials, so what the client composed and what the provider
    # stores differ by a line the client never wrote.
    from flydocs_api import description_hash

    assert description_hash(
        "**@matt elsey** (via FlyDocs)\n\n" + READ_TEXT
    ) == description_hash(READ_TEXT)


@test("a lone surrogate is hashed, not raised on (FLY-1470)")
def _():
    # `.encode("utf-8")` raises UnicodeEncodeError on an unpaired surrogate, so
    # without the scrub a description carrying one crashes the CLI instead of
    # writing. The scrub also has to match Node's substitution byte for byte.
    from flydocs_api import description_hash

    digest = description_hash("a\ud800b")
    assert digest == (
        "05087813392efc16fe8ff448920c6328e53af865df39419436659d9ffda90f7b"
    ), digest


@test("update_description puts expectedDescriptionHash in the BODY (FLY-1470)")
def _():
    relay = _RecordingRelay()
    _cloud_client(relay).update_description(
        "FLY-1", "body", "rev-1", "abc123"
    )
    assert relay.puts[0]["body"] == {
        "text": "body",
        "expectedRevision": "rev-1",
        "expectedDescriptionHash": "abc123",
    }, relay.puts[0]


@test("update_description sends the hash alone when there is no token (FLY-1470)")
def _():
    # The route accepts either guard; the hash is the stronger one, so a
    # tokenless hash-carrying write is a first-class shape, not a degraded one.
    relay = _RecordingRelay()
    _cloud_client(relay).update_description(
        "FLY-1", "body", None, "abc123"
    )
    assert relay.puts[0]["body"] == {
        "text": "body",
        "expectedDescriptionHash": "abc123",
    }, relay.puts[0]


@test("update_description omits the hash when there is none (FLY-1470)")
def _():
    relay = _RecordingRelay()
    _cloud_client(relay).update_description("FLY-1", "body", "rev-1", None)
    assert relay.puts[0]["body"] == {
        "text": "body", "expectedRevision": "rev-1"
    }, relay.puts[0]


@test("the self-read sends the server's descriptionHash, not a recomputed one (FLY-1470)")
def _():
    # `cmd_description` reads the issue on the --text path anyway. Taking the
    # digest the read returned removes the one way the client and the relay can
    # disagree about a document they both hold.
    client = _FakeDescriptionClient(issue={
        "identifier": "FLY-1",
        "revision": "rev-1",
        "description": READ_TEXT,
        "descriptionHash": "server-computed-digest",
    })
    code, _out, err = _run_description(client)
    assert code == 0, f"exit {code}: {err}"
    assert client.calls[0]["hash"] == "server-computed-digest", client.calls


@test("the self-read computes the hash locally when the server sent none (FLY-1470)")
def _():
    # Old relay, new CLI: the field is absent, so the client hashes the
    # description the same read returned. Same normalization, same digest.
    from flydocs_api import description_hash

    client = _FakeDescriptionClient(issue={
        "identifier": "FLY-1", "revision": "rev-1", "description": READ_TEXT,
    })
    code, _out, err = _run_description(client)
    assert code == 0, f"exit {code}: {err}"
    assert client.calls[0]["hash"] == description_hash(READ_TEXT), client.calls


@test("an empty description hashes to sha256(\"\"), like any other document (FLY-1470)")
def _():
    # An issue with an empty description is a document like any other, and its
    # digest is the empty-document digest the relay computes for the same text.
    # Both branches agree: served or locally computed, `""` produces the hash
    # that matches server-side and lets the write proceed.
    from flydocs_api import description_hash

    empty = description_hash("")
    served = _FakeDescriptionClient(issue={
        "identifier": "FLY-1", "revision": "rev-1",
        "description": "", "descriptionHash": empty,
    })
    code, _out, err = _run_description(served)
    assert code == 0, f"exit {code}: {err}"
    assert served.calls[0]["hash"] == empty, served.calls

    computed = _FakeDescriptionClient(issue={
        "identifier": "FLY-1", "revision": "rev-1", "description": "",
    })
    code, _out, err = _run_description(computed)
    assert code == 0, f"exit {code}: {err}"
    assert computed.calls[0]["hash"] == empty, computed.calls


@test("a read that returned no description at all sends no hash (FLY-1470)")
def _():
    # Distinct from an empty description, and the distinction is load-bearing:
    # a degraded read that omitted the field is not evidence that the
    # description IS empty. Sending sha256("") there would 409 a perfectly good
    # write against a document nobody touched.
    client = _FakeDescriptionClient(
        issue={"identifier": "FLY-1", "revision": "rev-1"}
    )
    code, _out, err = _run_description(client)
    assert code == 0, f"exit {code}: {err}"
    assert client.calls[0]["hash"] is None, client.calls


@test("--expected-description-hash alone lets a --file rewrite through (FLY-1470)")
def _():
    # FLY-1468's refusal exists because a token fetched here proves nothing
    # about the read the file was written against. A hash carried from that
    # read proves exactly what the refusal wanted proved, so it satisfies it.
    client = _FakeDescriptionClient()
    code, out, err = _run_description(
        client, text=None, file=_description_file("refined body"),
        expected_description_hash="hash-from-my-read",
    )
    assert code == 0, f"exit {code}: {err}"
    assert client.reads == [], "the caller brought a guard; buy no other"
    assert client.calls[0] == {
        "ref": "FLY-1", "text": "refined body",
        "revision": None, "hash": "hash-from-my-read",
    }, client.calls
    assert json.loads(out)["success"] is True


@test("--file with both guards sends both, with no self-read (FLY-1470)")
def _():
    client = _FakeDescriptionClient()
    code, _out, err = _run_description(
        client, text=None, file=_description_file("refined body"),
        expected_revision="rev-1", expected_description_hash="hash-1",
    )
    assert code == 0, f"exit {code}: {err}"
    assert client.reads == [], client.reads
    assert client.calls[0]["revision"] == "rev-1", client.calls
    assert client.calls[0]["hash"] == "hash-1", client.calls


@test("a --file rewrite with NEITHER guard is still refused (FLY-1468, FLY-1470)")
def _():
    # FLY-1470 widens what satisfies the refusal; it does not remove it.
    client = _FakeDescriptionClient()
    code, out, err = _run_description(
        client, text=None, file=_description_file()
    )
    assert code == 1, f"exit {code}: {err}"
    assert client.reads == [] and client.calls == []
    assert "--expected-revision" in err, err
    assert "--expected-description-hash" in err, \
        f"the refusal has to name both ways out of it: {err}"
    # FLY-1488: a placeholder on a printed command line is ONE shell word, and
    # where the value comes from is said in the prose beside it. A bracketed
    # sentence reads as several arguments to the agent that pastes it.
    assert "--expected-description-hash <HASH>" in err, err
    assert "--expected-revision <REVISION>" in err, err
    assert "`descriptionHash` field" in err and "`revision` field" in err, \
        f"a placeholder is only usable if the message says where it comes from: {err}"
    assert out == ""


@test("DESCRIPTION_CHANGED is refused with the fresh hash, never retried (FLY-1470)")
def _():
    changed = _relay_error("DESCRIPTION_CHANGED", {
        "error": "The description changed since it was read.",
        "code": "DESCRIPTION_CHANGED",
        "currentHash": "hash-2",
        "revision": "rev-2",
        "hint": "re-read the description, redo the edit against it",
    })
    client = _FakeDescriptionClient(outcomes=[changed])
    code, out, err = _run_description(client)
    assert code == 1, f"a whole-description rewrite must not be replayed: {err}"
    assert len(client.calls) == 1, \
        f"exactly one attempt — retrying is the clobber: {client.calls}"
    assert "description changed" in err, err
    assert "hash-2" in err, "hand back the fresh digest the relay returned"
    assert "--expected-description-hash" in err, \
        f"name the flag that carries the recovery: {err}"
    assert out == "", "a refused write must not print a success payload"


@test("local tier warns that --expected-description-hash is ignored (FLY-1470)")
def _():
    client = _FakeDescriptionClient(tier="local")
    code, _out, err = _run_description(
        client, expected_description_hash="hash-1"
    )
    assert code == 0, err
    assert client.reads == [], "there is no relay to be raced on local tier"
    assert client.calls[0]["hash"] is None, client.calls
    assert "--expected-description-hash" in err and "local" in err, \
        f"a silently dropped safety flag is worse than no flag: {err}"


@test("the description parser accepts --expected-description-hash (FLY-1470)")
def _():
    # Declared on the parser, not only honoured by the function: an agent
    # following the runner catalog must not hit `unrecognized arguments`.
    m = _load_issues_module()
    client = _FakeDescriptionClient()
    argv = [
        "issues.py", "description", "FLY-1", "--text", "body",
        "--expected-description-hash", "hash-1",
    ]
    out, err = _io.StringIO(), _io.StringIO()
    code = 0
    with patch.object(sys, "argv", argv), \
            patch.object(m, "get_client", lambda: client), \
            _contextlib.redirect_stdout(out), _contextlib.redirect_stderr(err):
        try:
            m.main()
        except SystemExit as exit_error:
            code = exit_error.code or 0
    assert code == 0, f"exit {code}: {err.getvalue()}"
    assert client.calls[0]["hash"] == "hash-1", client.calls

#
# Command-line placeholders are read by an agent that will substitute them and
# run the result. A placeholder carrying its own instructions —
# `--expected-revision <revision from the read this rewrite was based on>` —
# reads as several shell words, so the substitution that "obviously" follows is
# to leave the prose in place and let the shell split it. FLY-1473 settled the
# form for the Stop gate; FLY-1488 makes it the rule for every runnable command
# printed under `.claude/`: the placeholder is ONE token (`<REVISION>`,
# `<PATH>`, `<NAME>`, `<REF>`), and where the value comes from is said in the
# prose next to the command, where prose belongs.
#
# Nothing is allow-listed. A new offender is fixed, not excused.

print("\n## command-line placeholders are single tokens (FLY-1488)")

CLAUDE_ROOT = HOOKS_DIR.parent
TEMPLATE_ROOT = CLAUDE_ROOT.parent

# Files the scan could not read. A swallowed parse error is coverage quietly
# going away, so it is recorded and asserted on rather than passed over.
_UNPARSED: list[str] = []

# The word a rendered interpolation collapses to. It is deliberately not a
# placeholder: what `{ref}` holds at runtime is a value, never a bracket.
_INTERPOLATED = "VALUE"

# The CLI names, which are not files in this tree. `flydocs` is the bare verb
# on purpose — `init`, `auth` and `update` are as runnable as `run`, and a
# `flydocs run ` prefix would have let `flydocs init --key <...>` through.
# `curl` is kept deliberately: the relay docs will grow examples, and it costs
# one static entry to have them covered on arrival.
_CLI_PREFIXES = ("python3 ", "python ", "flydocs ", "curl ")


def _parse(path: Path):
    """Parse a script, recording — never swallowing — a failure."""
    try:
        return _ast.parse(path.read_text())
    except SyntaxError:
        _UNPARSED.append(str(path))
        return None


def _command_prefixes() -> tuple[str, ...]:
    """Every token that opens a runnable command, derived from the tree.

    Hand-listing them rots both ways — names that never appear, and shipped
    scripts nobody remembered to add. The scripts and hooks on disk ARE the
    surface, so read them.
    """
    scripts = {
        path.name
        for directory in (SCRIPT_DIR, HOOKS_DIR)
        for path in directory.glob("*.py")
    }
    return tuple(sorted(_CLI_PREFIXES + tuple(f"{name} " for name in scripts)))


def _subcommand_words() -> frozenset[str]:
    """The verbs the scripts register with argparse.

    Needed for the hints whose runner is an interpolation that cannot be
    resolved: the joined line opens with the stand-in word, and only a real
    subcommand behind it says the line is a command rather than a sentence.
    """
    words = set()
    for directory in (SCRIPT_DIR, HOOKS_DIR):
        for path in sorted(directory.glob("*.py")):
            tree = _parse(path)
            if tree is None:
                continue
            for node in _ast.walk(tree):
                if (isinstance(node, _ast.Call)
                        and isinstance(node.func, _ast.Attribute)
                        and node.func.attr == "add_parser"
                        and node.args
                        and isinstance(node.args[0], _ast.Constant)
                        and isinstance(node.args[0].value, str)):
                    words.add(node.args[0].value)
    return frozenset(words)


# A command opens either at a name that runs something, or at the stand-in
# word followed by a verb one of these scripts answers to.
_STRONG_PREFIX_RE = re.compile(
    "(?:"
    + "|".join(re.escape(prefix) for prefix in _command_prefixes())
    + ")|(?:"
    + re.escape(_INTERPOLATED)
    + r" (?:"
    + "|".join(
        re.escape(word)
        for word in sorted(_subcommand_words(), key=len, reverse=True)
    )
    + r")\b)"
)

# A bare flag fragment — `--file <PATH>` quoted on its own — is copied straight
# onto a command line, so it is one. Only where it is already code, though: a
# flag NAMED in a sentence takes the rest of the sentence with it.
_WEAK_PREFIX_RE = re.compile(r"(?<![\w-])--[a-z][a-z0-9-]*")

# What a placeholder looks like, and what it does not. The opener carries the
# whole distinction: a generic (`Record<string, number>`) opens against a word
# character, a comparison (`a < b`) against a space, an HTML comment and a
# closing tag against punctuation. Inside the brackets anything goes, because
# `<path, absolute>` and `<key=value pair>` are offenders like any other.
_PLACEHOLDER_RE = re.compile(r"(?<!\w)<([A-Za-z0-9_][^<>]*?)>")


def _comment_start(line: str, start: int, end: int) -> int | None:
    """Where a shell comment opens in `line[start:end]`, if one does.

    Quote-aware, because `#` is only a comment outside quotes: truncating at
    the first one swallowed the tail of every command carrying an issue number
    in a quoted argument — `comment REF "fix #12 …"` — and the placeholder
    after it.
    """
    quote = None
    index = start
    while index < end:
        char = line[index]
        if quote is not None:
            if char == "\\":
                index += 2
                continue
            if char == quote:
                quote = None
        elif char in "\"'":
            quote = char
        elif char == "#" and index > start and line[index - 1].isspace():
            return index - 1
        index += 1
    return None


def _range_from(line: str, start: int) -> tuple[int, int]:
    """The stretch of `line` a shell would be handed, opening at `start`.

    A command runs to end of line — unless it opens inside backticks, where it
    ends at the closing one. Parity, not the preceding character: a span whose
    prefix is not first inside the quotes (`` `cd repo && flydocs run …` ``)
    would otherwise read the sentence after the closing backtick as arguments.
    """
    if line.count("`", 0, start) % 2 == 1:
        end = line.find("`", start)
        end = len(line) if end == -1 else end
    else:
        end = len(line)
    comment = _comment_start(line, start, end)
    return (start, comment if comment is not None else end)


def _command_ranges(line: str, code_context: bool = False) -> list[tuple[int, int]]:
    """The character ranges of `line` that read as a command."""
    ranges = [
        _range_from(line, match.start())
        for match in _STRONG_PREFIX_RE.finditer(line)
    ]
    for match in _WEAK_PREFIX_RE.finditer(line):
        in_backticks = line.count("`", 0, match.start()) % 2 == 1
        if code_context or in_backticks or ranges:
            ranges.append(_range_from(line, match.start()))
    return [(start, end) for start, end in ranges if end > start]


def _offenders_in(line: str, ranges: list[tuple[int, int]]) -> list[str]:
    """Placeholders inside a command range that are more than one shell word.

    Matched against the whole line and filtered by range, so a placeholder
    covered by two overlapping commands is still counted once.
    """
    found = []
    for match in _PLACEHOLDER_RE.finditer(line):
        inside = any(
            start <= match.start() and match.end() <= end
            for start, end in ranges
        )
        if inside and re.search(r"\s", match.group(1)):
            found.append(match.group(0))
    return found


def _render(node, consts: dict) -> str | None:
    """The text a string expression prints as, or None if it prints none.

    Every way this tree assembles a hint has to render, because every one of
    them is a way for a command line to hide: an f-string whose runner is a
    named constant looked like prose to the first cut of this scan, and so did
    `+`, `.format`, `%` and `join`.
    """
    if isinstance(node, _ast.Constant):
        return node.value if isinstance(node.value, str) else None
    if isinstance(node, _ast.JoinedStr):
        parts = []
        for part in node.values:
            if isinstance(part, _ast.Constant) and isinstance(part.value, str):
                parts.append(part.value)
            elif isinstance(part, _ast.FormattedValue):
                parts.append(_render_interpolation(part.value, consts))
            else:  # pragma: no cover — JoinedStr holds nothing else
                parts.append(_INTERPOLATED)
        return "".join(parts)
    if isinstance(node, _ast.BinOp) and isinstance(node.op, _ast.Add):
        left = _render(node.left, consts)
        right = _render(node.right, consts)
        if left is None and right is None:
            return None
        return (left if left is not None else _INTERPOLATED) + (
            right if right is not None else _INTERPOLATED
        )
    if isinstance(node, _ast.BinOp) and isinstance(node.op, _ast.Mod):
        left = _render(node.left, consts)
        if left is None:
            return None
        return re.sub(
            r"%(?:\([^)]*\))?[-#0 +]*[\d.*]*[hlL]?[a-zA-Z%]",
            _INTERPOLATED,
            left,
        )
    if isinstance(node, _ast.Call) and isinstance(node.func, _ast.Attribute):
        target = _render(node.func.value, consts)
        if target is None:
            return None
        if node.func.attr == "format":
            return re.sub(r"\{[^{}]*\}", _INTERPOLATED, target)
        if node.func.attr == "join":
            if node.args and isinstance(
                node.args[0], (_ast.List, _ast.Tuple, _ast.Set)
            ):
                pieces = [
                    _render(element, consts) or _INTERPOLATED
                    for element in node.args[0].elts
                ]
            else:
                pieces = [_INTERPOLATED]
            return target.join(pieces)
    return None


def _render_interpolation(node, consts: dict) -> str:
    """What `{…}` contributes to the printed line.

    A name bound to a string literal resolves — that is the whole point:
    `f"  {script} description …"` is a command line only once `script` is the
    runner it holds. Anything else is a runtime value, and stands in as one.
    """
    if isinstance(node, _ast.Name) and node.id in consts:
        return consts[node.id]
    rendered = _render(node, consts)
    return rendered if rendered is not None else _INTERPOLATED


_SCOPE_NODES = (_ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)


def _scope_constants(body, consts: dict) -> dict:
    """Names bound to a string by the statements of ONE scope.

    Descends into `if` / `try` / loop bodies, which are the same scope, and
    stops at a nested def, which is not. Reading the whole file at once — what
    `ast.walk` does — lets one local rebinding win everywhere in the file, and
    a single `runner = "the phrase"` inside a helper then hides every hint the
    module builds from a runner of that name.
    """
    found = dict(consts)
    for node in body:
        if isinstance(node, _SCOPE_NODES):
            continue
        if isinstance(node, (_ast.Assign, _ast.AnnAssign)):
            targets = (
                node.targets if isinstance(node, _ast.Assign) else [node.target]
            )
            if node.value is not None:
                rendered = _render(node.value, found)
                if rendered is not None:
                    for target in targets:
                        if isinstance(target, _ast.Name):
                            found[target.id] = rendered
            continue
        for field in ("body", "orelse", "finalbody", "handlers"):
            nested = getattr(node, field, None)
            if isinstance(nested, list):
                found = _scope_constants(
                    [item for item in nested if isinstance(item, _ast.stmt)],
                    found,
                )
    return found


def _locate(lines: list[str], node, placeholder: str) -> int:
    """The source line the placeholder is written on.

    A hint spans as many lines as it needs; the node's own `lineno` is where
    the expression opened, which for a five-line message is a paren.
    """
    first = getattr(node, "lineno", 1)
    last = getattr(node, "end_lineno", first) or first
    for number in range(first, min(last, len(lines)) + 1):
        if placeholder in lines[number - 1]:
            return number
    return first


def _scan_text(
    text: str, code_context: bool
) -> list[tuple[int, str]]:
    """Offenders in a block of rendered text, line by line.

    A backslash continuation carries the command onto the next line, in a
    script exactly as in a doc.
    """
    offenders = []
    continued = False
    for number, line in enumerate(text.splitlines(), 1):
        ranges = _command_ranges(line, code_context=code_context)
        if continued:
            ranges.append((len(line) - len(line.lstrip()), len(line)))
        continued = bool(ranges) and line.rstrip().endswith("\\")
        for found in _offenders_in(line, ranges):
            offenders.append((number, found))
    return offenders


def _scan_markdown(path: Path) -> list[tuple[int, str]]:
    """Command lines in a doc: fenced blocks, inline spans, bare lines.

    A fence is code context — a bare flag there is an argument list. Outside
    one, a flag has to be in backticks or share the line with a real command
    before the words around it are read as arguments.
    """
    offenders = []
    fenced = False
    continued = False
    for number, line in enumerate(path.read_text().splitlines(), 1):
        if line.lstrip().startswith("```"):
            fenced = not fenced
            continued = False
            continue
        ranges = _command_ranges(line, code_context=fenced)
        if fenced and continued:
            ranges.append((len(line) - len(line.lstrip()), len(line)))
        if fenced:
            continued = bool(ranges) and line.rstrip().endswith("\\")
        for found in _offenders_in(line, ranges):
            offenders.append((number, found))
    return offenders


def _scan_python(path: Path) -> list[tuple[int, str]]:
    """Command lines inside printed strings.

    Read through the AST rather than the raw source: a hint is assembled from
    literals, constants and interpolations, and only the joined text shows
    that `comment {ref} "…"` is the tail of a `python3 …/issues.py` command.
    A rendered expression is not descended into — its parts are fragments of
    the one line it prints as, and counting them again would double-report.
    """
    offenders: list[tuple[int, str]] = []
    tree = _parse(path)
    if tree is None:
        return offenders
    lines = path.read_text().splitlines()

    def visit(node, consts: dict) -> None:
        rendered = _render(node, consts)
        if rendered is not None and hasattr(node, "lineno"):
            for _, found in _scan_text(rendered, code_context=True):
                offenders.append((_locate(lines, node, found), found))
            return
        if isinstance(node, _SCOPE_NODES):
            consts = _scope_constants(node.body, consts)
        for child in _ast.iter_child_nodes(node):
            visit(child, consts)

    visit(tree, _scope_constants(tree.body, {}))
    return offenders


def _scan_targets() -> list[Path]:
    """Every file the rule covers.

    `.claude/` is the bulk of it, but AGENTS.md and the Cursor rules print the
    same commands to the same agents — a placeholder is no safer for sitting
    in the file Cursor reads instead of the one Claude Code reads.
    """
    targets = [
        path
        for path in sorted(CLAUDE_ROOT.rglob("*"))
        if path.suffix in (".md", ".mdc", ".py")
        and "__pycache__" not in path.parts
    ]
    agents = TEMPLATE_ROOT / "AGENTS.md"
    if agents.is_file():
        targets.append(agents)
    rules = TEMPLATE_ROOT / ".cursor" / "rules"
    if rules.is_dir():
        targets.extend(
            sorted(
                path for path in rules.rglob("*")
                if path.suffix in (".md", ".mdc")
            )
        )
    return targets


def _scan_tree() -> list[str]:
    offenders = set()
    for path in _scan_targets():
        found = (
            _scan_python(path) if path.suffix == ".py" else _scan_markdown(path)
        )
        for number, placeholder in found:
            rel = path.relative_to(TEMPLATE_ROOT).as_posix()
            offenders.add(f"{rel}:{number} {placeholder}")
    return sorted(offenders)


@test("every command-line placeholder under the template is a single token (FLY-1488)")
def _():
    offenders = _scan_tree()
    assert not offenders, (
        "these placeholders sit on a runnable command line and carry their "
        "sourcing inside the brackets — make each one token and move the "
        "sourcing to the prose beside the command:\n  "
        + "\n  ".join(offenders)
    )


@test("nothing in scope was skipped for being unreadable (FLY-1488)")
def _():
    # A swallowed SyntaxError shrinks the scan silently — coverage would fall
    # to whatever still parses, and the suite would go on saying "green".
    targets = _scan_targets()
    assert _UNPARSED == [], f"these were skipped, not scanned: {_UNPARSED}"
    assert len(targets) > 40, f"the scan found almost nothing to read: {len(targets)}"


@test("the scan reaches the agent instructions outside .claude/ (FLY-1488)")
def _():
    # AGENTS.md and the Cursor rules print the same commands to the same
    # agents. They are clean today, and this is what keeps them that way.
    targets = {path.name for path in _scan_targets()}
    assert "AGENTS.md" in targets, sorted(targets)[:20]
    assert any(name.endswith(".mdc") for name in targets), \
        "the Cursor rules are markdown under another extension"


def _fixture_dir() -> Path:
    return Path(tempfile.mkdtemp(prefix="flydocs-placeholder-"))


# Every offending placeholder below is assembled at runtime and interpolated,
# never spelled out as a literal: this file sits inside the tree the scan
# reads, so a fixture written out longhand would report itself as an offender.
# `%` interpolation is one of the shapes `_render` deliberately abstracts to a
# stand-in word, so the source really does hold no bad command line — the
# fixtures are built, not excused.
BAD = "<%s>" % "two words"


@test("the placeholder scan still catches an offender (FLY-1488)")
def _():
    # A scan that finds nothing because it looks at nothing passes forever.
    # Each fixture is a shape the rule was written against.
    doc = _fixture_dir() / "sample.md"
    doc.write_text(
        # 1. inline span mid-sentence  2. backslash continuation in a fence
        # 3. a span that opens inside backticks with the command NOT first —
        #    it has to stop at the closing backtick, or the sentence after it
        #    is read as arguments.
        f'Inline: `flydocs run issue.comment REF "{BAD}"` in a sentence.\n'
        f'\n'
        f'```bash\n'
        f'flydocs run issue.description FLY-1 \\\n'
        f'  --expected-revision {BAD}\n'
        f'```\n'
        f'\n'
        f'Run `cd repo && flydocs run issue.get FLY-1` to see {BAD}.\n'
        f'A bare flag fragment counts too: `--file {BAD}`.\n'
    )
    found = _scan_markdown(doc)
    # The backticked `cd repo && …` line is the one that must NOT contribute:
    # its placeholder is prose after the closing backtick.
    assert len(found) == 3, found
    assert [number for number, _ in found] == [1, 5, 9], found

    script = doc.with_name("sample.py")
    script.write_text(
        f'msg = (\n'
        f'    "run this: "\n'
        f'    "issues.py description REF --file {BAD} "\n'
        f'    "--expected-revision {BAD}"\n'
        f')\n'
    )
    # Implicit concatenation joins the three literals into one command line,
    # so both placeholders land on it.
    assert len(_scan_python(script)) == 2, _scan_python(script)


@test("every way a hint is assembled reaches the scan (FLY-1488)")
def _():
    # The first cut read f-strings only, and rendered every interpolation as a
    # stand-in word. That left the most common hint shape in this tree
    # invisible: `f"  {script} description REF --file <PATH>"`, where `script`
    # is the constant holding `python3 .claude/.../issues.py`. Nothing in the
    # joined text started with a command, so nothing was checked — the Stop
    # gate's own hints could have regressed silently. Concatenation,
    # `.format`, `%` and `join` are the same hole spelled four other ways.
    script = _fixture_dir() / "shapes.py"
    script.write_text(
        f'RUNNER = "python3 .claude/skills/flydocs-workflow/scripts/issues.py"\n'
        f'ref = "FLY-1"\n'
        f'\n'
        f'a = f"  {{RUNNER}} description {{ref}} --file {BAD}"\n'
        f'b = "  " + RUNNER + " acceptance REF --check {BAD}"\n'
        f'c = "  {{}} transition REF --to {BAD}".format(RUNNER)\n'
        f'd = "  %s comment REF --body {BAD}" % RUNNER\n'
        f'e = "\\n".join(["  " + RUNNER + " get REF --field {BAD}"])\n'
    )
    found = _scan_python(script)
    assert len(found) == 5, found
    assert sorted(number for number, _ in found) == [4, 5, 6, 7, 8], found


@test("an unresolvable runner is still read as a command (FLY-1488)")
def _():
    # When the runner comes from a parameter there is no constant to resolve,
    # so the joined line opens with the stand-in word. A stand-in followed by
    # a real subcommand is a command line all the same — that is exactly the
    # `{script} description …` shape, minus the luck of a resolvable name.
    script = _fixture_dir() / "unresolvable.py"
    script.write_text(
        f'def hint(runner, ref):\n'
        f'    return f"  {{runner}} description {{ref}} --file {BAD}"\n'
    )
    assert len(_scan_python(script)) == 1, _scan_python(script)


@test("a name resolves in its own scope, not the last one parsed (FLY-1488)")
def _():
    # `ast.walk` is breadth-first, so a name rebound anywhere in the file used
    # to win everywhere in it — one local `R = "the phrase"` inside a helper
    # and the module-level runner of the same name went dark, taking every
    # hint built from it along. Scope has to be honoured in both directions:
    # the module binding still reaches module-level text, and the local one
    # really does mean the text below it is prose, not a command.
    script = _fixture_dir() / "scopes.py"
    script.write_text(
        f'R = "python3 x/issues.py"\n'
        f'\n'
        f'\n'
        f'def shadowed():\n'
        f'    R = "the phrase"\n'
        f'    return f"  {{R}} get REF {BAD}"\n'
        f'\n'
        f'\n'
        f'module_level = f"  {{R}} get REF {BAD}"\n'
    )
    found = _scan_python(script)
    assert [number for number, _ in found] == [9], found


@test("a hash inside quotes is an argument, not a comment (FLY-1488)")
def _():
    # `#` ends a command only where a shell would end it. Truncating at the
    # first one swallowed the rest of any command carrying an issue number in
    # a quoted argument — and the placeholder after it.
    doc = _fixture_dir() / "hash.md"
    doc.write_text(
        f'```bash\n'
        f'issues.py comment REF "fix #12 {BAD}"\n'
        f'```\n'
    )
    assert len(_scan_markdown(doc)) == 1, _scan_markdown(doc)


@test("a continued command in a script carries onto the next line (FLY-1488)")
def _():
    # Handled in markdown from the start, and not in Python — the same
    # command, the same backslash, printed by a script instead of a doc.
    script = _fixture_dir() / "continued.py"
    script.write_text(
        f'msg = (\n'
        f'    "  python3 x/issues.py description \\\\\\n"\n'
        f'    "    REF {BAD}\\n"\n'
        f')\n'
    )
    assert len(_scan_python(script)) == 1, _scan_python(script)


@test("a placeholder may hold punctuation and still be one bracket (FLY-1488)")
def _():
    # The character class that killed a TS generic also excused three real
    # offenders. The opener does that work — a generic opens against a word
    # character, a comparison against a space — so the class can hold what a
    # placeholder legitimately holds, and a leading digit or underscore is a
    # placeholder too.
    doc = _fixture_dir() / "punctuation.md"
    doc.write_text(
        "```bash\n"
        + "flydocs run issue.get FLY-1 --file %s\n" % ("<%s>" % "path, absolute")
        + "flydocs run issue.get FLY-1 --set %s\n" % ("<%s>" % "key=value pair")
        + "flydocs run issue.get FLY-1 --mode %s\n" % ("<%s>" % "a|b choice")
        + "flydocs run issue.get FLY-1 --take %s\n" % ("<%s>" % "2 words here")
        + "flydocs run issue.get FLY-1 --name %s\n" % ("<%s>" % "_leading token")
        + "```\n"
    )
    assert len(_scan_markdown(doc)) == 5, _scan_markdown(doc)


@test("the offender is reported on the line it is written on (FLY-1488)")
def _():
    # A hint spans as many source lines as it needs; reporting where the
    # expression opened sends the reader to a paren.
    script = _fixture_dir() / "multiline.py"
    script.write_text(
        f'msg = (\n'
        f'    "  python3 x/issues.py description REF "\n'
        f'    "--file {BAD}"\n'
        f')\n'
    )
    assert _scan_python(script) == [(3, "<two words>")], _scan_python(script)


@test("angle brackets that are not placeholders are left alone (FLY-1488)")
def _():
    # The negative case has to sit ON a command line, or it proves nothing but
    # that the prefix test short-circuits. Every line below carries a real
    # command; none of them holds a placeholder a shell would split.
    doc = _fixture_dir() / "not-placeholders.md"
    doc.write_text(
        "```bash\n"
        "flydocs run issue.description FLY-1 --file body.md <!-- fill this in -->\n"
        "python3 gen.py  # emits Record<string, number>\n"
        "python3 check.py  # passes when a < b and b > c\n"
        "flydocs run issue.get FLY-1 --field <REVISION>\n"
        "```\n"
        "\n"
        "Prose: `flydocs run issue.get FLY-1` returns <the current revision>.\n"
    )
    assert _scan_markdown(doc) == [], _scan_markdown(doc)


@test("a flag named in a sentence is not a command line (FLY-1488)")
def _():
    # A bare `--flag` fragment is scanned because a quoted one gets copied
    # straight onto a command. A flag NAMED in prose is a different thing, and
    # the sentence around it is not an argument list.
    doc = _fixture_dir() / "flag-in-prose.md"
    doc.write_text(
        f"Pass --repo when the working directory is not {BAD}.\n"
    )
    assert _scan_markdown(doc) == [], _scan_markdown(doc)


@test("the command surface is derived from the scripts, not hand-listed (FLY-1488)")
def _():
    # A hand-kept prefix list rots: the first cut missed a dozen scripts that
    # ship — `projects.py`, `graph_update.py`, `bridge.py`. Deriving them from
    # the tree means a new script is covered the day it lands. The four CLI
    # names stay static because they are not files here; `curl` is kept
    # deliberately, as cover for the relay examples this tree will grow.
    prefixes = set(_command_prefixes())
    on_disk = {
        path.name
        for directory in (SCRIPT_DIR, HOOKS_DIR)
        for path in directory.glob("*.py")
    }
    missing = sorted(name for name in on_disk if f"{name} " not in prefixes)
    assert not missing, f"these scripts are invisible to the scan: {missing}"
    assert "flydocs " in prefixes, \
        "the CLI prefix has to be the bare verb — `flydocs init`, `flydocs " \
        "auth` and `flydocs update` are as runnable as `flydocs run`"

    doc = _fixture_dir() / "surface.md"
    doc.write_text(
        f'```bash\n'
        f'flydocs init --key {BAD}\n'
        f'projects.py set-active-project {BAD}\n'
        f'```\n'
    )
    assert len(_scan_markdown(doc)) == 2, _scan_markdown(doc)


@test("prose placeholders off the command line are left alone (FLY-1488)")
def _():
    # The rule is about words a shell will split, not about every angle
    # bracket. A content skeleton and a sentence are neither — and neither is
    # reachable from a command prefix.
    doc = _fixture_dir() / "prose.md"
    doc.write_text(
        "```\n"
        "H1: <Primary keyword phrased naturally>\n"
        "```\n"
        "\n"
        "<!-- Fill during setup: the parts that vary -->\n"
        "Check off `<criterion text>` when it is verified.\n"
    )
    assert _scan_markdown(doc) == [], _scan_markdown(doc)




# ---------------------------------------------------------------------------
print("\n## post-pr-check body-file resolution (FLY-1533)")


def _run_post_pr_check(command: str, cwd: str | None = None):
    """Run the hook end-to-end the way Claude Code does, return its stdout.

    Unit-testing `extract_body` alone would miss the part that actually
    regressed: `main()` deciding between three outcomes — warn about missing
    sections, warn about the missing dispatcher, or say nothing.
    """
    import subprocess
    payload = {"tool_name": "Bash", "tool_input": {"command": command}}
    if cwd is not None:
        payload["cwd"] = cwd
    result = subprocess.run(
        [sys.executable, str(HOOKS_DIR / "post-pr-check.py")],
        input=json.dumps(payload),
        capture_output=True, text=True, timeout=15,
    )
    assert result.returncode == 0, f"hook exited {result.returncode}: {result.stderr}"
    return result.stdout.strip()


def _notice(stdout: str) -> str:
    """The additionalContext string, or '' when the hook stayed silent."""
    if not stdout:
        return ""
    return json.loads(stdout)["hookSpecificOutput"]["additionalContext"]


GOOD_BODY = "## Summary\n\nDid a thing.\n\n## Test Plan\n\nRan the suite.\n"


@test("a --body-file PR whose file has the sections is not warned about")
def _():
    """The regression: the path string was checked as if it were the body.

    `.flydocs/scratch/pr-1533.md` contains neither heading, so every
    --body-file PR was told it was missing both — including this one.
    """
    with tempfile.TemporaryDirectory() as tmp:
        body = Path(tmp) / "pr-1533.md"
        body.write_text(GOOD_BODY)
        out = _notice(_run_post_pr_check(f'gh pr create --body-file {body}'))
    assert out == "", f"expected silence, got: {out}"


@test("a --body-file PR whose file really lacks the sections is still warned")
def _():
    """The fix must not turn the check off — only point it at the right text."""
    with tempfile.TemporaryDirectory() as tmp:
        body = Path(tmp) / "thin.md"
        body.write_text("just some prose, no headings\n")
        out = _notice(_run_post_pr_check(f'gh pr create --body-file {body}'))
    assert "missing required sections" in out, out
    assert "## Summary" in out and "## Test Plan" in out, out


@test("a relative --body-file resolves against the payload cwd, not the hook's")
def _():
    """The multi-repo case: a PR filed from a child repo passes
    `.flydocs/scratch/…` relative to that repo, which is not where the hook
    runs. Resolving against the hook's own cwd finds nothing, and 'nothing'
    used to mean 'no body at all'."""
    with tempfile.TemporaryDirectory() as tmp:
        scratch = Path(tmp) / ".flydocs" / "scratch"
        scratch.mkdir(parents=True)
        (scratch / "pr.md").write_text(GOOD_BODY)
        out = _notice(_run_post_pr_check(
            'gh pr create --body-file .flydocs/scratch/pr.md', cwd=tmp))
    assert out == "", f"expected silence, got: {out}"


@test("--body-file - says nothing rather than guessing")
def _():
    """stdin is gone by PostToolUse. A body WAS supplied, so claiming the PR
    was 'created without using issues.py pr' is a false statement, not a
    lesser one."""
    out = _notice(_run_post_pr_check('gh pr create --body-file -'))
    assert out == "", f"expected silence, got: {out}"


@test("an unreadable --body-file path says nothing rather than guessing")
def _():
    with tempfile.TemporaryDirectory() as tmp:
        missing = Path(tmp) / "already-cleaned-up.md"
        out = _notice(_run_post_pr_check(f'gh pr create --body-file {missing}'))
    assert out == "", f"expected silence, got: {out}"


@test("a PR with no body flag at all still gets the dispatcher notice")
def _():
    """The distinction the sentinel exists to preserve: no body is still a
    real finding, and must not be silenced along with the unreadable ones."""
    out = _notice(_run_post_pr_check('gh pr create --title "x"'))
    assert "without using `issues.py pr`" in out, out


@test("an inline --body is still read as the body, not as a path")
def _():
    out = _notice(_run_post_pr_check(f'gh pr create --body {json.dumps(GOOD_BODY)}'))
    assert out == "", f"expected silence, got: {out}"


@test("heredoc bodies still parse — 0d168b6 does not regress")
def _():
    """The fix this one sits on top of. Splitting the flag sets moved the
    tokenisation path, so the heredoc case is asserted here rather than
    assumed."""
    command = (
        'gh pr create --title "x" --body "$(cat <<\'EOF\'\n'
        f"{GOOD_BODY}"
        'EOF\n)"'
    )
    out = _notice(_run_post_pr_check(command))
    assert out == "", f"expected silence, got: {out}"


@test("the dispatcher's own PRs are never warned about")
def _():
    out = _notice(_run_post_pr_check(
        'python3 .claude/skills/flydocs-workflow/scripts/issues.py pr --issue FLY-1533'))
    assert out == "", f"expected silence, got: {out}"


@test("--body-file is not in the inline flag set")
def _():
    """The bug in one line, guarded structurally as well as behaviourally:
    0d168b6 added --body-file to BODY_FLAGS alongside --body, and that single
    membership is what made the path get checked as prose."""
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "post_pr_check", HOOKS_DIR / "post-pr-check.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    assert "--body-file" not in mod.INLINE_BODY_FLAGS, mod.INLINE_BODY_FLAGS
    assert "--body-file" in mod.BODY_FILE_FLAGS, mod.BODY_FILE_FLAGS
    assert mod.ALL_BODY_FLAGS == mod.INLINE_BODY_FLAGS | mod.BODY_FILE_FLAGS


# ---------------------------------------------------------------------------
# Audit signal (FLY-1436)
# ---------------------------------------------------------------------------

print("\n## audit reads what it can read (FLY-1436)")


def _audit_module():
    """issues.py with a pinned status mapping.

    `canonical_status` consults the workspace's `.flydocs/config.json` through
    an lru_cached `_status_mapping`, so on a developer machine its answer
    depends on which directory the suite was started from. Pinning the default
    mapping makes "In Progress" mean IMPLEMENTING here for the same reason it
    does on a freshly-initialised workspace.
    """
    m = _load_issues_module()
    m._status_mapping = lambda: DEFAULT_STATUS_MAPPING
    return m


# A record as the SINGLE-ISSUE read returns it: description, labels and all.
_FULL_ISSUE = {
    "identifier": "FLY-1",
    "title": "A properly specified issue",
    "status": "In Progress",
    "assignee": "Matt Elsey",
    "priority": 3,
    "labels": ["Feature"],
    "description": "## Context\n\nSome context.\n\n## Acceptance Criteria\n\n- [ ] It works\n",
}

# The same issue as the LIST route returns it — `IssueListItem`, which carries
# no description and no labels. This is the record the audit was reading.
_LIST_ISSUE = {
    "identifier": "FLY-1",
    "title": "A properly specified issue",
    "status": "In Progress",
    "assignee": "Matt Elsey",
    "priority": 3,
}


@test("a described, labelled, AC-bearing issue produces no findings")
def _():
    m = _audit_module()
    findings, not_evaluated = m.audit_issue(_FULL_ISSUE)
    assert findings == [], findings
    assert not_evaluated == [], not_evaluated


@test("the list projection reports not-evaluated, never missing_description")
def _():
    """The bug itself: no description column, so every row was flagged."""
    m = _audit_module()
    findings, not_evaluated = m.audit_issue(_LIST_ISSUE)
    assert "missing_description" not in findings, findings
    assert "no_labels" not in findings, findings
    assert "missing_description" in not_evaluated, not_evaluated
    assert "no_labels" in not_evaluated, not_evaluated
    assert "no_acceptance_criteria" in not_evaluated, not_evaluated


@test("a genuinely empty description produces exactly missing_description")
def _():
    m = _audit_module()
    findings, not_evaluated = m.audit_issue({**_FULL_ISSUE, "description": "   "})
    assert findings == ["missing_description"], findings
    assert not_evaluated == [], not_evaluated


@test("a null description is not supplied, not empty")
def _():
    """`description: null` is a field the read did not carry. Guessing either
    way is what FLY-1436 was."""
    m = _audit_module()
    findings, not_evaluated = m.audit_issue({**_FULL_ISSUE, "description": None})
    assert findings == [], findings
    assert "missing_description" in not_evaluated, not_evaluated


@test("a genuinely unlabelled issue produces exactly no_labels")
def _():
    m = _audit_module()
    findings, not_evaluated = m.audit_issue({**_FULL_ISSUE, "labels": []})
    assert findings == ["no_labels"], findings


@test("no_acceptance_criteria actually fires (FLY-1436 AC3)")
def _():
    """It never had: it was gated behind a truthy description the list never
    supplied, AND compared a provider-native status to a canonical name."""
    m = _audit_module()
    findings, _ = m.audit_issue(
        {**_FULL_ISSUE, "description": "## Context\n\nProse, no checkboxes.\n"}
    )
    assert findings == ["no_acceptance_criteria"], findings


@test("an empty description is not also reported as no_acceptance_criteria")
def _():
    m = _audit_module()
    findings, _ = m.audit_issue({**_FULL_ISSUE, "description": ""})
    assert findings == ["missing_description"], findings


@test("acceptance criteria are not expected of a backlog issue")
def _():
    m = _audit_module()
    findings, _ = m.audit_issue(
        {**_FULL_ISSUE, "status": "Backlog", "description": "Just prose.\n"}
    )
    assert findings == [], findings


@test("unassigned_active fires on a provider-native status")
def _():
    """Status arrives as "In Progress", not "IMPLEMENTING" — the old code
    upper-cased it and compared, so this check could never fire on a cloud
    workspace."""
    m = _audit_module()
    findings, _ = m.audit_issue({**_FULL_ISSUE, "assignee": ""})
    assert findings == ["unassigned_active"], findings


@test("an untranslatable status leaves its checks not evaluated")
def _():
    m = _audit_module()
    findings, not_evaluated = m.audit_issue(
        {**_FULL_ISSUE, "status": "Awaiting Sign-off", "assignee": ""}
    )
    assert "unassigned_active" in not_evaluated, not_evaluated
    assert "no_acceptance_criteria" in not_evaluated, not_evaluated
    assert findings == [], findings


@test("no_priority fires on an unset priority and skips canceled work")
def _():
    m = _audit_module()
    findings, _ = m.audit_issue({**_FULL_ISSUE, "priority": 0})
    assert findings == ["no_priority"], findings
    findings, _ = m.audit_issue(
        {**_FULL_ISSUE, "priority": 0, "status": "Canceled"}
    )
    assert "no_priority" not in findings, findings


@test("a list-projection audit reports zero findings and a --deep hint")
def _():
    m = _audit_module()
    report = m.build_audit_report([_LIST_ISSUE] * 3)
    assert report["total_checked"] == 3, report
    assert report["issues_with_findings"] == 0, report
    skipped = {entry["check"]: entry["issues"] for entry in report["not_evaluated"]}
    assert skipped == {
        "missing_description": 3,
        "no_acceptance_criteria": 3,
        "no_labels": 3,
    }, skipped
    assert "--deep" in report["hint"], report["hint"]


@test("a hydrated audit reports real findings and no hint")
def _():
    m = _audit_module()
    report = m.build_audit_report(
        [_FULL_ISSUE, {**_FULL_ISSUE, "identifier": "FLY-2", "labels": []}],
        deep=True,
    )
    assert report["issues_with_findings"] == 1, report
    assert report["findings"][0]["ref"] == "FLY-2", report["findings"]
    assert report["findings"][0]["findings"] == ["no_labels"], report["findings"]
    assert "not_evaluated" not in report, report
    assert "hint" not in report, report


@test("every check declares the fields it reads")
def _():
    m = _audit_module()
    assert set(m.AUDIT_CHECKS) == set(m.AUDIT_CHECK_FIELDS), m.AUDIT_CHECKS
    for check, fields in m.AUDIT_CHECK_FIELDS.items():
        assert fields, f"{check} declares no fields"
        assert set(fields) <= m.AUDIT_FIELDS, (check, fields)


@test("--deep is a declared audit flag")
def _():
    """The runner refuses an undeclared flag, so the parser and
    `operations.ts` have to agree."""
    import subprocess
    result = subprocess.run(
        [sys.executable, str(SCRIPT_DIR / "issues.py"), "audit", "--help"],
        capture_output=True, text=True, timeout=10,
        env=HERMETIC_ENV, cwd=HERMETIC_CWD,
    )
    assert result.returncode == 0, result.stderr[:200]
    assert "--deep" in result.stdout, result.stdout[:400]


# ---------------------------------------------------------------------------
# context.py push (FLY-1592)
# ---------------------------------------------------------------------------
#
# The push exists for a repo the portal cannot crawl — a GitHub Enterprise or
# clone-URL repo with no App install — where `context.pull` has nothing to
# return. Everything below is either a pure function or a call against a stub
# client that fails the test if it is asked to reach the network.

print("\n## context.py push (FLY-1592)")


def _load_context_module():
    import importlib.util
    spec = importlib.util.spec_from_file_location("context_push", SCRIPT_DIR / "context.py")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


# A `project.md` as it actually sits on disk after `flydocs update` /
# `context.py pull`: the relay's `buildRepoContext` strips every
# `<!-- flydocs:* -->` marker from the stored document and appends the rules
# and the status workflow as plain markdown (app:
# `src/app/api/relay/config/generate/route.ts`). This is the fixture that
# matters — the marked-up form is what the PORTAL stores, not what we read.
_SERVED_PROJECT_MD = """# Project Context

## What This Is

The narrative the agent wrote.

## Workspace Rules

Every transition gets a comment.

## Repo Rules

Run the suite before opening a PR.

## Status Workflow

Provider: Linear | Last synced: 2026-09-20

Mapping:

- BACKLOG → Backlog
"""

# The other shape `pull` writes: when `/auth/statuses` answers, it re-wraps the
# served content — rules tail and all — in ai-start/ai-end markers.
_REWRAPPED_PROJECT_MD = (
    "<!-- flydocs:ai-start version=3 generated=2026-09-20 -->\n"
    + _SERVED_PROJECT_MD
    + "<!-- flydocs:ai-end -->\n"
    "\n"
    "<!-- flydocs:workspace-rules-start synced-from=workspace -->\n"
    "<!-- flydocs:status-workflow -->\n"
    "## Status Workflow\n"
    "<!-- flydocs:status-workflow -->\n"
    "<!-- flydocs:workspace-rules-end -->\n"
    "\n"
    "<!-- flydocs:repo-rules-start -->\n"
    "<!-- flydocs:repo-rules-end -->\n"
)

_NARRATIVE = "# Project Context\n\n## What This Is\n\nThe narrative the agent wrote."

_VALID_DESCRIPTOR = {
    "version": 2,
    "name": "Demo Service",
    "repoSlug": "acme/demo",
    "purpose": "Serves the demo API.",
    "stack": ["typescript"],
    "apis": [],
    "dependencies": [],
    "structure": {"entryPoints": ["src/index.ts"], "sharedTypes": [], "buildSystem": "tsup"},
}


class _RefusingRelay:
    """A relay that fails the test if anything asks it for the network."""

    repo_slug = None

    def get(self, path, params=None, best_effort=False, raise_on_error=False):
        raise AssertionError(f"a request was made: GET {path}")

    def put(self, path, body=None):
        raise AssertionError(f"a request was made: PUT {path}")


class _RecordingRelay(_RefusingRelay):
    """Records the PUT and answers the rules read from a canned document."""

    def __init__(self, stored_project_md="", context_version=4):
        self.stored_project_md = stored_project_md
        self.context_version = context_version
        self.puts = []
        self.gets = []

    def get(self, path, params=None, best_effort=False, raise_on_error=False):
        assert path == "/context", path
        self.gets.append(params)
        return {"repos": [{
            "repoName": "acme/demo",
            "projectMd": self.stored_project_md,
            "contextVersion": self.context_version,
        }]}

    def put(self, path, body=None):
        self.puts.append((path, body))
        return {"contextVersion": self.context_version + 1}


class _UnreadableRelay(_RecordingRelay):
    """The stored context cannot be read — the relay predates the route."""

    def get(self, path, params=None, best_effort=False, raise_on_error=False):
        from flydocs_api import RelayError
        raise RelayError(404, "NOT_FOUND", "no such route", {})


class _StubClient:
    def __init__(self, relay, config=None):
        self.relay = relay
        self.config = config if config is not None else {"repoSlug": "acme/demo"}

    def require_cloud(self, operation):
        return None


def _push_root(project_md=_SERVED_PROJECT_MD, descriptor=None):
    """A repo tree on disk. Returns its root."""
    root = Path(tempfile.mkdtemp(prefix="flydocs-push-"))
    (root / "flydocs" / "context").mkdir(parents=True)
    if project_md is not None:
        (root / "flydocs" / "context" / "project.md").write_text(project_md)
    if descriptor is not None:
        (root / "flydocs" / "context" / "service.json").write_text(json.dumps(descriptor))
    return root


def _run_cmd_push(mod, client, root, **flags):
    """Call cmd_push directly with a stub client; returns the parsed JSON."""
    import argparse as _argparse
    import contextlib as _cl
    import io as _io
    fields = {"root": str(root), "project_md": False, "service_json": False,
              "dry_run": False, "with_read": False, "no_preserve_rules": False,
              **flags}
    args = _argparse.Namespace(**fields)
    buf = _io.StringIO()
    with patch.object(mod, "get_client", lambda: client), _cl.redirect_stdout(buf):
        mod.cmd_push(args)
    return json.loads(buf.getvalue())


@test("push cuts the relay's appended rules tail out of the served project.md")
def _():
    """The premise the first cut of this feature got wrong: the file on disk
    is flat markdown with the rules appended, not a marked-up document."""
    m = _load_context_module()
    narrative, warnings = m.local_narrative(_SERVED_PROJECT_MD)
    assert narrative == _NARRATIVE, repr(narrative)
    assert "Workspace Rules" not in narrative, narrative
    assert "Status Workflow" not in narrative, narrative
    tail_warning = [w for w in warnings if "Stripped server-appended" in w]
    assert tail_warning, warnings
    for heading in m.SERVER_APPENDED_HEADINGS:
        assert heading in tail_warning[0], tail_warning[0]


@test("the tail is cut from a pull that re-wrapped the served content in markers")
def _():
    m = _load_context_module()
    narrative, warnings = m.local_narrative(_REWRAPPED_PROJECT_MD)
    assert narrative == _NARRATIVE, repr(narrative)
    assert any("Stripped server-appended" in w for w in warnings), warnings


@test("a project.md with no markers and no appended tail is sent whole")
def _():
    m = _load_context_module()
    narrative, warnings = m.local_narrative("# Hand-written\n\nNo markers here.\n")
    assert narrative == "# Hand-written\n\nNo markers here.", repr(narrative)
    assert not any("Stripped server-appended" in w for w in warnings), warnings
    assert warnings, "the marker-less case must still say so"


@test("the rules tail never reaches the payload")
def _():
    m = _load_context_module()
    narrative, _ = m.local_narrative(_SERVED_PROJECT_MD)
    payload = m.build_push_payload("acme/demo", m.assemble_push_document(narrative), None)
    body = json.dumps(payload)
    assert "Every transition gets a comment" not in body, body
    assert "Run the suite before opening a PR" not in body, body
    assert "Status Workflow" not in body, body


@test("the pushed document is the marked-up shape the portal stores")
def _():
    """`generate-context-orchestrator.ts` persists assembleProjectMd(...), and
    the portal's context-rules panel parses that back into three editors."""
    m = _load_context_module()
    document = m.assemble_push_document(_NARRATIVE, version=5)
    assert "<!-- flydocs:ai-start" in document, document
    assert "version=5" in document, document
    assert "<!-- flydocs:ai-end -->" in document, document
    assert "<!-- flydocs:workspace-rules-start synced-from=workspace -->" in document, document
    assert "<!-- flydocs:repo-rules-start -->" in document, document
    # Round-trips through the parser the portal uses.
    from context_parser import parse_project_md
    sections = parse_project_md(document)["sections"]
    assert sections["ai"] == _NARRATIVE, repr(sections["ai"])
    assert sections["workspace_rules"] == "", repr(sections["workspace_rules"])


@test("rules the server already stores are preserved into the pushed document")
def _():
    m = _load_context_module()
    document = m.assemble_push_document(
        _NARRATIVE, {"workspace_rules": "WS RULE", "repo_rules": "REPO RULE"}
    )
    from context_parser import parse_project_md
    sections = parse_project_md(document)["sections"]
    assert sections["workspace_rules"] == "WS RULE", sections
    assert sections["repo_rules"] == "REPO RULE", sections
    assert sections["ai"] == _NARRATIVE, sections


@test("validate_descriptor names every missing required field")
def _():
    m = _load_context_module()
    assert m.validate_descriptor(_VALID_DESCRIPTOR) == [], m.validate_descriptor(_VALID_DESCRIPTOR)
    missing = m.validate_descriptor({"version": 2, "name": "Demo", "purpose": "", "stack": []})
    assert missing == ["repoSlug", "purpose", "stack"], missing
    assert m.validate_descriptor("not a descriptor") == list(m.REQUIRED_DESCRIPTOR_KEYS)


@test("stamp_descriptor records provenance and the generator, keeping structure")
def _():
    m = _load_context_module()
    provenance = {"generator": "agent", "branch": "main", "commit": "abc123",
                  "dirty": False, "at": "2026-09-20T00:00:00+00:00"}
    stamped = m.stamp_descriptor(_VALID_DESCRIPTOR, provenance)
    assert stamped["generatedBy"] == "agent", stamped
    assert stamped["generatedAt"] == "2026-09-20T00:00:00+00:00", stamped
    assert stamped["provenance"] == provenance, stamped
    # `structure` is what the server writes back to service.json on the next
    # update — strip it and the repo loses its own orientation section.
    assert stamped["structure"] == _VALID_DESCRIPTOR["structure"], stamped
    assert "provenance" not in _VALID_DESCRIPTOR, "the input must not be mutated"


@test("the push payload matches the PUT /api/relay/context contract")
def _():
    m = _load_context_module()
    payload = m.build_push_payload("acme/demo", "narrative", {"version": 2})
    assert payload == {
        "repoSlug": "acme/demo",
        "contextSource": "cli-push",
        "projectMd": "narrative",
        "serviceJson": {"version": 2},
    }, payload
    # Absent means absent: an explicit null would read as "clear it".
    only_md = m.build_push_payload("acme/demo", "narrative", None)
    assert "serviceJson" not in only_md, only_md
    only_json = m.build_push_payload("acme/demo", None, {"version": 2})
    assert "projectMd" not in only_json, only_json


@test("git_provenance never fails a push on a tree with no git")
def _():
    m = _load_context_module()
    provenance = m.git_provenance(Path(tempfile.mkdtemp(prefix="flydocs-nogit-")))
    assert provenance["generator"] == "agent", provenance
    assert provenance["at"], provenance
    assert "commit" not in provenance, provenance


@test("git_provenance omits the branch on a detached HEAD and reports dirty")
def _():
    """`rev-parse --abbrev-ref HEAD` answers the literal 'HEAD' when detached,
    which is not a branch name."""
    import subprocess
    m = _load_context_module()
    root = Path(tempfile.mkdtemp(prefix="flydocs-git-"))
    env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
           "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
    def git(*argv):
        subprocess.run(["git", *argv], cwd=str(root), check=True,
                       capture_output=True, env=env, timeout=20)
    git("init", "-q")
    (root / "a.txt").write_text("one")
    git("add", "a.txt")
    git("commit", "-q", "-m", "one")
    on_branch = m.git_provenance(root)
    assert on_branch.get("branch"), on_branch
    assert on_branch["dirty"] is False, on_branch

    head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=str(root),
                                   env=env, timeout=20).decode().strip()
    git("checkout", "-q", head)
    (root / "a.txt").write_text("two")
    detached = m.git_provenance(root)
    assert "branch" not in detached, detached
    assert detached["commit"] == head, detached
    assert detached["dirty"] is True, detached


@test("--dry-run makes no request")
def _():
    """Asserted against the relay itself: every method on the stub raises."""
    m = _load_context_module()
    root = _push_root(descriptor=_VALID_DESCRIPTOR)
    out = _run_cmd_push(m, _StubClient(_RefusingRelay()), root, dry_run=True)
    assert out["dryRun"] is True, out
    assert out["repoSlug"] == "acme/demo", out
    assert out["sent"] == ["projectMd", "serviceJson"], out
    assert "Every transition gets a comment" not in json.dumps(out), out


@test("a push reads the stored rules and sends them back in the document")
def _():
    m = _load_context_module()
    stored = (
        "<!-- flydocs:ai-start version=4 -->\nold narrative\n<!-- flydocs:ai-end -->\n\n"
        "<!-- flydocs:workspace-rules-start synced-from=workspace -->\nWS RULE\n"
        "<!-- flydocs:workspace-rules-end -->\n\n"
        "<!-- flydocs:repo-rules-start -->\nREPO RULE\n<!-- flydocs:repo-rules-end -->\n"
    )
    relay = _RecordingRelay(stored)
    out = _run_cmd_push(m, _StubClient(relay), _push_root())
    assert out["contextVersion"] == 5, out
    (path, body), = relay.puts
    assert path == "/context", path
    assert body["contextSource"] == "cli-push", body
    assert "version=5" in body["projectMd"], body["projectMd"][:200]
    from context_parser import parse_project_md
    sections = parse_project_md(body["projectMd"])["sections"]
    assert sections["ai"] == _NARRATIVE, sections
    assert sections["workspace_rules"] == "WS RULE", sections
    assert sections["repo_rules"] == "REPO RULE", sections


@test("--project-md refuses a file with no narrative of its own")
def _():
    m = _load_context_module()
    root = _push_root(project_md="## Workspace Rules\n\nEvery transition gets a comment.\n")
    try:
        _run_cmd_push(m, _StubClient(_RefusingRelay()), root, project_md=True, dry_run=True)
    except SystemExit as e:
        assert e.code == 1, e.code
    else:
        raise AssertionError("expected a refusal")


@test("without --project-md an empty narrative is a warning and the descriptor still goes")
def _():
    m = _load_context_module()
    root = _push_root(
        project_md="## Workspace Rules\n\nEvery transition gets a comment.\n",
        descriptor=_VALID_DESCRIPTOR,
    )
    relay = _RecordingRelay()
    out = _run_cmd_push(m, _StubClient(relay), root)
    assert out["sent"] == ["serviceJson"], out
    assert any("no narrative of its own" in w for w in out["warnings"]), out["warnings"]
    (_, body), = relay.puts
    assert "projectMd" not in body, body
    assert body["serviceJson"]["generatedBy"] == "agent", body


@test("push refuses a descriptor whose repoSlug is another repo's, naming both")
def _():
    m = _load_context_module()
    foreign = {**_VALID_DESCRIPTOR, "repoSlug": "acme/other"}
    root = _push_root(descriptor=foreign)
    import contextlib as _cl
    import io as _io
    err = _io.StringIO()
    try:
        with _cl.redirect_stderr(err):
            _run_cmd_push(m, _StubClient(_RefusingRelay()), root, service_json=True, dry_run=True)
    except SystemExit as e:
        assert e.code == 1, e.code
    else:
        raise AssertionError("expected a refusal")
    assert "acme/other" in err.getvalue() and "acme/demo" in err.getvalue(), err.getvalue()


# --- The residual duplication path (FLY-1592 review, round two) -------------
#
# `stripSectionMarkers` removes the marker LINES and keeps the section CONTENT,
# so a repo whose stored document has non-empty rules sections is served as
# narrative + those bodies unlabelled + the same rules again under headings.
# Cutting at the first heading leaves the unlabelled pair in the narrative, and
# each cycle then adds a copy.

_WS_RULES = "Every transition gets a comment."
_REPO_RULES = "Run the suite before opening a PR."
_WORKFLOW_BLOCK = "## Status Workflow\n\nProvider: Linear | Last synced: 2026-09-20"


def _strip_markers(document):
    """The app's `stripSectionMarkers`, verbatim in behaviour."""
    import re as _re
    out = _re.sub(r"^[ \t]*<!--\s*/?flydocs:[^\n]*?-->\s*$", "", document, flags=_re.M)
    return _re.sub(r"\n{3,}", "\n\n", out).strip()


def _served(stored_document, workspace_column=_WS_RULES, repo_column=_REPO_RULES):
    """What `config/generate` serves for a stored document."""
    content = _strip_markers(stored_document)
    if workspace_column.strip():
        content += "\n\n## Workspace Rules\n\n" + workspace_column.strip()
    if repo_column.strip():
        content += "\n\n## Repo Rules\n\n" + repo_column.strip()
    return content + "\n\n" + _WORKFLOW_BLOCK


def _stored_with_rules(narrative=_NARRATIVE, version=4):
    m = _load_context_module()
    return m.assemble_push_document(
        narrative, {"workspace_rules": _WS_RULES, "repo_rules": _REPO_RULES},
        version=version,
    )


@test("the unlabelled rules bodies the marker strip leaves behind are cut too")
def _():
    m = _load_context_module()
    served = _served(_stored_with_rules())
    # Precondition: both copies really are in the served file.
    assert served.count(_WS_RULES) == 2, served
    narrative, warnings = m.local_narrative(
        served, {"workspace_rules": _WS_RULES, "repo_rules": _REPO_RULES}
    )
    assert narrative == _NARRATIVE, repr(narrative)
    assert _WS_RULES not in narrative and _REPO_RULES not in narrative, narrative
    assert any("workspace's workspace rules" in w for w in warnings), warnings
    assert any("workspace's repo rules" in w for w in warnings), warnings


@test("without the stored rules the unlabelled bodies survive — hence the read first")
def _():
    """The reason `remote_rules` runs before the cut, stated as a test."""
    m = _load_context_module()
    narrative, _ = m.local_narrative(_served(_stored_with_rules()))
    assert _WS_RULES in narrative, narrative


@test("stored rules that are not at the tail are reported, not silently kept")
def _():
    m = _load_context_module()
    served = "# Project Context\n\n" + _WS_RULES + "\n\nMore narrative.\n\n## Workspace Rules\n\n" + _WS_RULES
    narrative, warnings = m.local_narrative(served, {"workspace_rules": _WS_RULES})
    assert "More narrative." in narrative, narrative
    assert any("not at the end" in w for w in warnings), warnings


# Rules are free markdown from the portal's Custom Rules editor, so a rules
# body carrying its own `##` heading is ordinary — and it sits between two
# appended headings, which is what broke the backwards heading walk.
_WS_RULES_WITH_HEADING = (
    "## Coding Standards\n\nUse TypeScript strict mode.\n\n"
    "## Reviews\n\nEvery transition gets a comment."
)


def _cycle_three_times(workspace_rules, repo_rules):
    """Serve → push → serve, three times. Returns each stored document."""
    m = _load_context_module()
    stored = m.assemble_push_document(
        _NARRATIVE,
        {"workspace_rules": workspace_rules, "repo_rules": repo_rules},
        version=4,
    )
    root = _push_root()
    documents = []
    for cycle in range(3):
        (root / "flydocs" / "context" / "project.md").write_text(
            _served(stored, workspace_column=workspace_rules, repo_column=repo_rules)
        )
        relay = _RecordingRelay(stored, context_version=4 + cycle)
        out = _run_cmd_push(m, _StubClient(relay), root)
        assert out["success"] is True, out
        (_, body), = relay.puts
        stored = body["projectMd"]
        documents.append(stored)
    return documents


def _assert_stable(documents, workspace_rules, repo_rules):
    from context_parser import parse_project_md
    for cycle, stored in enumerate(documents):
        sections = parse_project_md(stored)["sections"]
        assert sections["ai"] == _NARRATIVE, (cycle, sections["ai"])
        assert sections["workspace_rules"] == workspace_rules, (cycle, sections)
        assert sections["repo_rules"] == repo_rules, (cycle, sections)
        assert stored.count(repo_rules) == 1, (cycle, stored)
    assert len({len(d) for d in documents}) == 1, [len(d) for d in documents]


@test("three push/serve cycles keep one copy of the rules and a stable narrative")
def _():
    """The measurement the reviewer made (2 → 3 → 4) as a regression test."""
    documents = _cycle_three_times(_WS_RULES, _REPO_RULES)
    _assert_stable(documents, _WS_RULES, _REPO_RULES)


@test("a rules body with its own ## heading does not break the anchor, over three cycles")
def _():
    """The second measurement (156 → 262 → 368 bytes): `## Coding Standards`
    inside the workspace rules sat between two appended headings."""
    documents = _cycle_three_times(_WS_RULES_WITH_HEADING, _REPO_RULES)
    _assert_stable(documents, _WS_RULES_WITH_HEADING, _REPO_RULES)
    # The inner heading lives in the rules section and nowhere else.
    assert documents[-1].count("## Coding Standards") == 1, documents[-1]


@test("the appended tail is anchored in canonical order, headings inside rules and all")
def _():
    m = _load_context_module()
    served = _served(
        m.assemble_push_document(
            _NARRATIVE,
            {"workspace_rules": _WS_RULES_WITH_HEADING, "repo_rules": _REPO_RULES},
        ),
        workspace_column=_WS_RULES_WITH_HEADING,
        repo_column=_REPO_RULES,
    )
    narrative, stripped = m.strip_server_appended(served)
    assert narrative.endswith(_WS_RULES_WITH_HEADING) or "Coding Standards" in narrative, narrative
    assert stripped == list(m.SERVER_APPENDED_HEADINGS), stripped
    # What is left is exactly the narrative plus the unlabelled bodies, which
    # `local_narrative` then subtracts against the stored sections.
    full, _ = m.local_narrative(
        served,
        {"workspace_rules": _WS_RULES_WITH_HEADING, "repo_rules": _REPO_RULES},
    )
    assert full == _NARRATIVE, repr(full)


@test("a narrative section called ## Status Workflow is not mistaken for the appended block")
def _():
    """`buildWorkflowText` writes nothing at all without a provider mapping, so
    a header with no 'Provider: … | Last synced: …' under it is the repo's."""
    m = _load_context_module()
    content = (
        "# Project Context\n\n## Status Workflow\n\nHow this team moves issues.\n"
    )
    narrative, stripped = m.strip_server_appended(content)
    assert stripped == [], stripped
    assert "How this team moves issues." in narrative, narrative


@test("the tail cut stops at the earliest appended heading with nothing but appended after it")
def _():
    """A narrative section called '## Status Workflow' must not take the
    '## Interfaces' that follows it."""
    m = _load_context_module()
    content = (
        "# Project Context\n\n"
        "## Status Workflow\n\nHow we move issues here.\n\n"
        "## Interfaces\n\nThe REST surface.\n\n"
        "## Workspace Rules\n\n" + _WS_RULES + "\n\n"
        "## Repo Rules\n\n" + _REPO_RULES + "\n"
    )
    narrative, stripped = m.strip_server_appended(content)
    assert "## Interfaces" in narrative, narrative
    assert "How we move issues here." in narrative, narrative
    assert stripped == ["## Workspace Rules", "## Repo Rules"], stripped


@test("a push refuses when the stored context cannot be read, naming the reason")
def _():
    m = _load_context_module()
    import contextlib as _cl
    import io as _io
    err = _io.StringIO()
    try:
        with _cl.redirect_stderr(err):
            _run_cmd_push(m, _StubClient(_UnreadableRelay()), _push_root())
    except SystemExit as e:
        assert e.code == 1, e.code
    else:
        raise AssertionError("expected a refusal")
    assert "NOT_FOUND" in err.getvalue(), err.getvalue()
    assert "--no-preserve-rules" in err.getvalue(), err.getvalue()


@test("--no-preserve-rules pushes anyway, with empty rules sections and a warning")
def _():
    m = _load_context_module()
    relay = _UnreadableRelay()
    out = _run_cmd_push(m, _StubClient(relay), _push_root(), no_preserve_rules=True)
    assert out["success"] is True, out
    assert any("empty rules sections" in w for w in out["warnings"]), out["warnings"]
    (_, body), = relay.puts
    from context_parser import parse_project_md
    sections = parse_project_md(body["projectMd"])["sections"]
    assert sections["workspace_rules"] == "", sections
    assert sections["ai"] == _NARRATIVE, sections


@test("--dry-run --with-read performs the read and nothing else")
def _():
    m = _load_context_module()

    class _ReadOnlyRelay(_RecordingRelay):
        def put(self, path, body=None):
            raise AssertionError("a write was made in a dry run")

    relay = _ReadOnlyRelay(_stored_with_rules())
    root = _push_root(project_md=_served(_stored_with_rules()))
    out = _run_cmd_push(m, _StubClient(relay), root, dry_run=True, with_read=True)
    assert out["readPerformed"] is True, out
    assert relay.gets == [{"repo": "acme/demo"}], relay.gets
    assert _WS_RULES in out["preview"], out["preview"]
    plain = _run_cmd_push(m, _StubClient(_RefusingRelay()), root, dry_run=True)
    assert plain["readPerformed"] is False, plain
    assert "no request at all" in plain["note"], plain



@test("push refuses a descriptor missing required fields, naming them")
def _():
    m = _load_context_module()
    root = _push_root(descriptor={"version": 2, "name": "Demo", "repoSlug": "acme/demo"})
    import contextlib as _cl
    import io as _io
    err = _io.StringIO()
    try:
        with _cl.redirect_stderr(err):
            _run_cmd_push(m, _StubClient(_RefusingRelay()), root, service_json=True, dry_run=True)
    except SystemExit as e:
        assert e.code == 1, e.code
    else:
        raise AssertionError("expected a refusal")
    assert "purpose" in err.getvalue() and "stack" in err.getvalue(), err.getvalue()


# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------

print(f"\n{'='*50}")
print(f"Results: {passed} passed, {failed} failed, {passed + failed} total")
if failed > 0:
    sys.exit(1)


