diff --git a/docs/reference/agentic-sdd.md b/docs/reference/agentic-sdd.md index dc38e76a5a..62caedf929 100644 --- a/docs/reference/agentic-sdd.md +++ b/docs/reference/agentic-sdd.md @@ -111,7 +111,7 @@ Verify each stage works before moving to the next. ## `/speckit.converge` -Assesses the codebase against the feature's spec, plan, and tasks to confirm nothing was missed. It is **append-only**: it never edits or deletes code, and its only possible write is adding tasks to `tasks.md`. Run it only after `/speckit.implement` has run on the current `tasks.md`. +Assesses the codebase against the feature's spec, plan, and tasks to confirm nothing was missed. It is **append-only**: it never edits or deletes code, and its only possible write is adding tasks to `tasks.md`. Run it only after `/speckit.implement` has completed every task in `tasks.md`: if any task is still unchecked, converge stops before assessing anything, lists the unchecked tasks, and tells you to run `/speckit.implement` first, leaving `tasks.md` unchanged. ```text /speckit.converge @@ -120,4 +120,4 @@ Assesses the codebase against the feature's spec, plan, and tasks to confirm not It first prints a severity-graded findings summary, then resolves to one of two outcomes: - **Converged** — no gaps found. `tasks.md` is left byte-for-byte unchanged and you'll see a clean result like `✅ Converged — the implementation satisfies the spec, plan, and tasks.` You're done; proceed to review or open a PR. -- **Tasks appended** — gaps found. Converge appends them as new tasks under a Convergence section in `tasks.md` and tells you how many. Run `/speckit.implement` again to complete them, then `/speckit.converge` once more. Each pass finds fewer items; repeat until it reports converged. +- **Tasks appended** — gaps found. Converge appends them as new tasks under a Convergence section in `tasks.md` and tells you how many. Run `/speckit.implement` again to complete them, then `/speckit.converge` once more (until they are checked off, converge stops at its prerequisite check rather than appending them a second time). Each pass finds fewer items; repeat until it reports converged. diff --git a/templates/commands/converge.md b/templates/commands/converge.md index 5d29b74db6..aeaee57582 100644 --- a/templates/commands/converge.md +++ b/templates/commands/converge.md @@ -63,7 +63,7 @@ state of the code, determine which requirements, acceptance criteria, plan decis existing tasks are unmet, incomplete, or only partially satisfied, and **append each piece of remaining work as a new, traceable task** at the bottom of `tasks.md` so that `__SPECKIT_COMMAND_IMPLEMENT__` can complete it. This command MUST run only after -`__SPECKIT_COMMAND_IMPLEMENT__` has run on the current `tasks.md`, and after `__SPECKIT_COMMAND_TASKS__` has produced a complete `tasks.md`. +`__SPECKIT_COMMAND_IMPLEMENT__` has completed every task in the current `tasks.md` (Step 1 checks, and stops if any task is unchecked), and after `__SPECKIT_COMMAND_TASKS__` has produced a complete `tasks.md`. This is **not** a diff tool and does **not** track changes. It assesses the present state of the code relative to the feature's artifacts — no git, no branch comparison, no history. @@ -100,6 +100,17 @@ Run `{SCRIPT}` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_ If `spec.md`, `plan.md`, or `tasks.md` is missing, STOP with a clear, actionable message naming the prerequisite command to run (`__SPECKIT_COMMAND_SPECIFY__` for a missing spec, `__SPECKIT_COMMAND_PLAN__` for a missing plan, `__SPECKIT_COMMAND_TASKS__` for missing tasks). Do not produce partial output. + +**Enforce the implement prerequisite before assessing anything.** Scan `tasks.md` for +unchecked tasks — lines matching `- [ ]` outside code fences, the rule `__SPECKIT_COMMAND_IMPLEMENT__` counts by — +and if there are any, STOP: report how many are unchecked and list their task IDs, and tell +the user to run `__SPECKIT_COMMAND_IMPLEMENT__` to complete them before converging. Leave +`tasks.md` byte-for-byte unchanged, report no findings, and do not continue to Step 2. This +is neither outcome of Step 7: an unchecked task is work that is already tracked but not yet +built, so assessing the code while one is open would report that same work again as a new +gap, and a run that reached `converged` would claim the implementation is complete while +tracked work remains. Converge assesses a finished implementation; it does not re-plan an +unfinished one. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). ### 2. Load Artifacts (Progressive Disclosure) @@ -137,6 +148,10 @@ Create an internal model (do not echo raw artifacts): - **Requirements inventory**: one stable key per FR-### / SC-### / user-story acceptance scenario (e.g. `US1/AC2`), plus the plan decisions and constitution principles that impose buildable obligations. +- **Task inventory**: every task in `tasks.md` — all of them checked by this point — with + the work it describes and the file paths it names. A task marked done whose work is + absent from the code, or only partly there, is a finding like any other, traced to + that task's ID. - **Code-scope map**: from the file paths named in `plan.md` and `tasks.md`, plus a keyword search for the concepts each requirement describes, derive the set of source files and components in scope for assessment. Bound the assessment to these — do **not** infer @@ -210,7 +225,8 @@ Append to the **end** of `tasks.md`, per the append contract: ``` `` traces the task to its origin: e.g. `FR-003`, `SC-002`, - `US1/AC2`, `plan: storage decision`, `Constitution II`. + `US1/AC2`, `plan: storage decision`, `Constitution II`, or a task ID such as `T017` + when a task marked done is not reflected in the code. `` is one of `missing`, `partial`, `contradicts`, `unrequested`. @@ -228,8 +244,9 @@ Append to the **end** of `tasks.md`, per the append contract: ### 8. Provide Next Actions (Handoff) - On `tasks_appended`: state how many tasks were appended under which phase, and recommend - running `__SPECKIT_COMMAND_IMPLEMENT__` to complete them; note that a follow-up converge - run will find fewer or no remaining items. + running `__SPECKIT_COMMAND_IMPLEMENT__` to complete them; note that converge will stop at + its prerequisite check until those tasks are checked off, and re-assess everything once + they are. - On `converged`: recommend proceeding to review / opening a PR. No further implement pass is needed for this feature's specified scope. diff --git a/templates/commands/implement.md b/templates/commands/implement.md index f98ba525de..2fd7c4854f 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -58,10 +58,11 @@ You **MUST** consider the user input before proceeding (if not empty). - `checklists/requirements.md` is the built-in spec-quality checklist maintained by `__SPECKIT_COMMAND_SPECIFY__` and `__SPECKIT_COMMAND_CLARIFY__`; custom checklists generated by `__SPECKIT_COMMAND_CHECKLIST__` are reviewer-owned requirements-quality review artifacts - For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete - Scan all checklist files in the checklists/ directory + - Count only checkbox lines **outside of code fences**, the same rule `__SPECKIT_COMMAND_CLARIFY__` applies. A checklist that documents the checkbox format inside a fence is showing an example, not tracking work, and counting those examples blocks implementation on items nobody can ever tick - For each checklist, count: - - Total items: All lines matching `- [ ]` or `- [X]` or `- [x]` - - Checked items: Lines matching `- [X]` or `- [x]` - - Unchecked items: Lines matching `- [ ]` + - Total items: All lines matching `- [ ]` or `- [X]` or `- [x]` outside code fences + - Checked items: Lines matching `- [X]` or `- [x]` outside code fences + - Unchecked items: Lines matching `- [ ]` outside code fences - Create a status table: ```text diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index f982448906..484b3bfe8e 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -64,9 +64,10 @@ git config --get remote.origin.url > [!CAUTION] > ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL -1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `__SPECKIT_COMMAND_CONVERGE__` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked. -1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: `, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`). - - **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`). +1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `__SPECKIT_COMMAND_CONVERGE__` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue **only if the title also carries this feature's identifier** (see below). Task IDs restart at `T001` in every feature's `tasks.md`, so an unscoped match means the first feature to reach the tracker permanently suppresses `T001` for every later feature -- a silent gap in exactly the multi-feature repos this command is for. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked. +1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `[] T001: `, where `` is the basename of FEATURE_DIR parsed in step 1 (the `NNN-name` spec directory, e.g. `002-billing`), followed by the ID written once and then the task description (for example, the line `- [ ] T001 Create project structure` in feature `002-billing` becomes the title `[002-billing] T001: Create project structure`). The ID keeps its own word boundaries, so the `\bT\d{3,}\b` matching above is unchanged by the prefix. + - **Skip** a task only when an existing issue matches **both** this feature's identifier and the task ID, and report it (for example, `[002-billing] T001 already has an issue, skipping`). A `T001` belonging to another feature is a different task and must not suppress this one. + - Issues created before this scoping exists carry a bare `T001: ...` title, and a bare title carries no feature identity: it cannot show which feature's `T001` it tracks, and the absence of a scoped title for that ID does not show it either. So never decide a bare match on your own, in either direction -- skipping silently drops this feature's task when the issue belongs to another feature, and creating duplicates it when the issue is this feature's. Before creating anything, list every task ID that matched only a bare title, each with the issue number, the issue title and this feature's description for that task, and ask the user which of those issues already track this feature's tasks. Skip the ones the user confirms and create the rest with the scoped title. Then offer to retitle each confirmed issue to the scoped form (`[] T001: ...`) so later runs match it without asking, and retitle only the issues the user agrees to. - Only create issues for tasks that do not yet have a matching issue. > [!CAUTION] diff --git a/tests/unit/test_checklist_scan_contract.py b/tests/unit/test_checklist_scan_contract.py new file mode 100644 index 0000000000..1edf5d48cc --- /dev/null +++ b/tests/unit/test_checklist_scan_contract.py @@ -0,0 +1,60 @@ +"""Every command that scans checkbox markers must say it skips code fences. + +A checklist is free to *document* the checkbox format inside a fenced block. Counting +those example markers reports items nobody can tick, and `/speckit-implement` treats a +non-zero unchecked count as a reason to stop — so an example fence blocks implementation +(#4272). `/speckit-clarify` already scoped its scan to markers outside code fences; this +keeps the two commands from drifting apart again, and holds any future command that +starts counting markers to the same rule. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +COMMAND_DIRS = [ + PROJECT_ROOT / "templates" / "commands", + *sorted((PROJECT_ROOT / "presets").glob("*/commands")), +] + +# The instruction that tells the agent which lines are checkbox markers. Written to catch +# the phrasing both commands use rather than one exact sentence. The first marker may be +# checked or unchecked: `/speckit-implement` defines its checked count on `- [X]` alone, +# and that definition needs the same exclusion as the other two. +SCAN_INSTRUCTION = re.compile(r"lines matching\s+`- \[[ xX]\]`", re.IGNORECASE) +FENCE_EXCLUSION = re.compile(r"outside\s+(?:of\s+)?code\s+fences", re.IGNORECASE) + + +def scan_instructions() -> list[tuple[Path, int, str]]: + """Every line in a command template that defines what counts as a checkbox marker.""" + found: list[tuple[Path, int, str]] = [] + for directory in COMMAND_DIRS: + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.md")): + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if SCAN_INSTRUCTION.search(line): + found.append((path, number, line)) + return found + + +def test_the_contract_is_actually_stated_somewhere() -> None: + """Guard against the regex silently matching nothing and the test passing vacuously.""" + assert scan_instructions(), "no command template defines a checkbox-marker scan any more" + + +@pytest.mark.parametrize( + ("path", "number", "line"), + scan_instructions(), + ids=lambda value: value.name if isinstance(value, Path) else str(value), +) +def test_marker_scans_exclude_code_fences(path: Path, number: int, line: str) -> None: + assert FENCE_EXCLUSION.search(line), ( + f"{path.relative_to(PROJECT_ROOT)}:{number} tells the agent to match checkbox " + f"markers without excluding fenced code blocks, so an example fence is counted " + f"as real work:\n {line.strip()}" + ) diff --git a/tests/unit/test_converge_prerequisite.py b/tests/unit/test_converge_prerequisite.py new file mode 100644 index 0000000000..f2528811ee --- /dev/null +++ b/tests/unit/test_converge_prerequisite.py @@ -0,0 +1,121 @@ +"""`/speckit-converge` assesses a finished implementation, so it enforces that first. + +Run while `tasks.md` still had unchecked tasks, converge assessed the code anyway. The +work those tasks track is not built yet, so it came back as fresh gaps and was appended +again under new IDs — every re-run duplicated its own remediation tasks (#4269). And when +nothing new turned up, the run reported `converged`, telling the user the implementation +was complete while tracked work was still open. + +The fix is the lifecycle, not a dedup rule: converge stops before analysis while any task +is unchecked and sends the user to `/speckit-implement`. Once every task is checked it +assesses the code against every artifact, each task included, and anything it appends +makes the next run stop again until that work is done. These pin that shape. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +TEMPLATE = PROJECT_ROOT / "templates" / "commands" / "converge.md" +REFERENCE = PROJECT_ROOT / "docs" / "reference" / "agentic-sdd.md" + +GATE_HEADING = "Enforce the implement prerequisite" + + +@pytest.fixture(scope="module") +def template_text() -> str: + assert TEMPLATE.is_file(), f"missing command template: {TEMPLATE}" + return TEMPLATE.read_text(encoding="utf-8") + + +def _section(text: str, heading: str) -> str: + """The body of the `### ` section whose heading contains *heading*.""" + match = re.search( + rf"^###[^\n]*{re.escape(heading)}[^\n]*\n(.*?)(?=^### |\Z)", + text, + re.MULTILINE | re.DOTALL, + ) + assert match, f"no section headed {heading!r} any more" + return match.group(1) + + +def _gate(text: str) -> str: + """The prerequisite paragraph, up to the next blank line.""" + start = text.find(GATE_HEADING) + assert start != -1, "converge no longer checks that implement has finished" + end = text.find("\n\n", start) + return text[start : end if end != -1 else len(text)] + + +def test_the_gate_runs_before_anything_is_assessed(template_text: str) -> None: + """It has to sit in Step 1: a check after the assessment cannot stop it.""" + step_one = _section(template_text, "1. Initialize Convergence Context") + assert GATE_HEADING in step_one, ( + "the prerequisite check is not part of Step 1, so the codebase is assessed " + "before converge knows whether implement has finished" + ) + assert "do not continue to Step 2" in _gate(template_text) + + +def test_an_unchecked_task_stops_the_run_and_names_implement(template_text: str) -> None: + gate = _gate(template_text) + assert "STOP" in gate, gate + assert "__SPECKIT_COMMAND_IMPLEMENT__" in gate, ( + f"the stop must tell the user which command finishes the work:\n{gate}" + ) + assert re.search(r"list their task IDs", gate), ( + f"the stop must say which tasks are open, not only that some are:\n{gate}" + ) + assert "byte-for-byte unchanged" in gate, ( + f"a stopped run must not write to tasks.md:\n{gate}" + ) + + +def test_the_gate_counts_tasks_the_way_implement_does(template_text: str) -> None: + """An example checkbox inside a fence is not an open task (#4272).""" + gate = _gate(template_text) + assert "`- [ ]`" in gate, gate + assert re.search(r"outside\s+code\s+fences", gate), ( + f"the gate would treat a documented example checkbox as unfinished work:\n{gate}" + ) + + +def test_a_stopped_run_is_not_reported_as_converged(template_text: str) -> None: + """`converged` tells the user the implementation is complete; open work says otherwise.""" + gate = _gate(template_text) + assert "neither outcome of Step 7" in gate, gate + append_step = _section(template_text, "7. Append Convergence Tasks") + assert not re.search(r"take the `converged` path", append_step), ( + "already-tracked work is being routed to `converged`, whose report says the " + "implementation satisfies the spec" + ) + + +def test_every_task_is_part_of_what_is_assessed(template_text: str) -> None: + """A task ticked off without its work in the code is a gap too.""" + inventory = _section(template_text, "3. Build the Intent Inventory") + assert "**Task inventory**" in inventory, ( + "the intent inventory no longer includes the tasks themselves, so a task " + "marked done but never built is invisible" + ) + assert re.search(r"marked done whose work is\s+absent", inventory), inventory + + +def test_the_handoff_describes_the_gate(template_text: str) -> None: + handoff = _section(template_text, "8. Provide Next Actions") + assert "prerequisite check" in handoff, ( + "after appending, the handoff should say the next converge stops until the new " + f"tasks are done:\n{handoff}" + ) + + +def test_the_reference_docs_describe_the_gate() -> None: + text = REFERENCE.read_text(encoding="utf-8") + section = text[text.find("## `/speckit.converge`") :] + assert re.search(r"still unchecked, converge stops", section), ( + "docs/reference/agentic-sdd.md no longer says converge stops on unchecked tasks" + ) diff --git a/tests/unit/test_taskstoissues_feature_scope.py b/tests/unit/test_taskstoissues_feature_scope.py new file mode 100644 index 0000000000..9120b63210 --- /dev/null +++ b/tests/unit/test_taskstoissues_feature_scope.py @@ -0,0 +1,92 @@ +"""Issue dedup in `/speckit-taskstoissues` must be scoped to one feature. + +Task IDs are local to a feature: every `tasks.md` restarts at `T001`. Matching existing +issues by task ID alone means the first feature to reach the tracker permanently +suppresses `T001` for every later feature — the tasks are silently never created, which +is worse than the duplicates the matching was tightened to prevent (#4271). + +These assert the template still carries the scoping, so a later edit to that step cannot +quietly drop it again. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +TEMPLATE = PROJECT_ROOT / "templates" / "commands" / "taskstoissues.md" + + +@pytest.fixture(scope="module") +def template_text() -> str: + assert TEMPLATE.is_file(), f"missing command template: {TEMPLATE}" + return TEMPLATE.read_text(encoding="utf-8") + + +def _line_containing(text: str, needle: str) -> str: + matches = [line for line in text.splitlines() if needle in line] + assert matches, f"no instruction line contains {needle!r} any more" + return "\n".join(matches) + + +def test_the_canonical_title_carries_the_feature_identifier(template_text: str) -> None: + """Without the feature in the title there is nothing for the dedup to scope on.""" + title_rule = _line_containing(template_text, "canonical title") + assert re.search(r"`\[\]\s+T001:", title_rule), ( + "the canonical issue title no longer names the feature, so two features' T001 " + f"issues are indistinguishable:\n {title_rule.strip()}" + ) + assert "FEATURE_DIR" in title_rule, ( + "the title rule should say where comes from (the FEATURE_DIR parsed in " + f"step 1):\n {title_rule.strip()}" + ) + + +def test_the_skip_rule_requires_both_feature_and_task_id(template_text: str) -> None: + skip_rule = _line_containing(template_text, "**Skip**") + assert "both" in skip_rule.lower(), ( + "the skip rule must require the feature identity as well as the task ID, or a " + f"sibling feature's T001 suppresses this one:\n {skip_rule.strip()}" + ) + assert "feature" in skip_rule.lower(), skip_rule.strip() + + +def test_pre_existing_unscoped_issues_are_still_recognised(template_text: str) -> None: + """Upgrading must not re-create issues that were filed before the prefix existed.""" + assert re.search(r"before this scoping exists|bare `T001", template_text), ( + "the template no longer says what to do with issues created before the feature " + "prefix, so an upgrade would duplicate every already-tracked task" + ) + + +def test_a_bare_title_is_never_decided_on_the_agents_own(template_text: str) -> None: + """A bare `T001: ...` title names no feature, so nothing in it can settle a match. + + Treating it as this feature's whenever no scoped title exists for the ID brings + #4271 straight back on the first run after upgrading: a bare `T001` filed for + `001-auth` suppresses `002-billing`'s `T001` because billing has no scoped issue + yet. Treating it as another feature's duplicates every task an existing user + already tracks. Only the user can say which it is. + """ + legacy_rule = _line_containing(template_text, "before this scoping exists") + assert "ask the user" in legacy_rule, ( + "the rule for pre-prefix issues must hand the ambiguous matches to the user " + f"rather than resolve them:\n {legacy_rule.strip()}" + ) + assert not re.search(r"treat (those|them) as matching", legacy_rule, re.IGNORECASE), ( + "a bare title is being treated as a match on the agent's own inference, which " + f"lets another feature's T001 suppress this one:\n {legacy_rule.strip()}" + ) + assert re.search(r"before creating anything", legacy_rule, re.IGNORECASE), ( + "the question has to come before any issue is created, or the answer can no " + f"longer prevent a duplicate:\n {legacy_rule.strip()}" + ) + + +def test_confirmed_legacy_issues_are_only_retitled_with_consent(template_text: str) -> None: + """Retitling is what stops the question recurring, and it edits the user's issues.""" + legacy_rule = _line_containing(template_text, "before this scoping exists") + assert "retitle" in legacy_rule and "agrees" in legacy_rule, legacy_rule.strip()