Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions templates/commands/converge.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,23 @@ Append to the **end** of `tasks.md`, per the append contract:

1. Scan all existing task IDs; let `M` be the maximum. Determine the next phase number `N`
(highest existing phase + 1).

**Drop findings that existing unchecked tasks already cover.** Compare each actionable
finding against every `- [ ]` task already in `tasks.md` — outside code fences, the same
rule `__SPECKIT_COMMAND_CLARIFY__` applies — matching on the work described and the file
paths or `<source-ref>` it names, not on wording. A finding that an unchecked task
already represents is **not** appended: it is unbuilt work that is already tracked, and
appending it again splits one piece of work across two IDs.

This is what makes the command idempotent. Converge run twice, or run before
`__SPECKIT_COMMAND_IMPLEMENT__` has worked through the list, would otherwise append the
same remediation tasks under fresh IDs each time — the second run cannot tell its own
previous output apart from the plan's original tasks.

Report the dropped ones in the summary rather than silently discarding them:
`F3 — already tracked by T017, not appended`. If **every** finding is already tracked,
there is nothing to append: take the `converged` path below and say why, so the operator
sees "the work is known" rather than "the codebase is complete".
2. Write a single new section header `## Phase N: Convergence`.
3. Emit one checklist item per actionable finding, ordered CRITICAL/HIGH first, assigning
zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`:
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/test_converge_idempotency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""`/speckit-converge` must not append work that an unchecked task already tracks.

The command's only write is appending remediation tasks to `tasks.md`. It knew how to
compute the next task ID, but not whether a finding was already represented — so running
it twice, or running it before `/speckit-implement` had worked through the list, appended
the same work again under fresh IDs and split one piece of work across two entries
(#4269). The dedup rule is what makes the command idempotent, so it is pinned here rather
than left to survive on prose alone.
"""

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"


@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 test_the_append_step_still_exists(template_text: str) -> None:
"""Guard against the assertions below passing vacuously if the step is renamed away."""
assert re.search(r"Append Convergence Tasks", template_text), (
"the append step is gone; the rules below no longer describe this command"
)


def test_findings_are_compared_against_existing_unchecked_tasks(template_text: str) -> None:
assert re.search(r"unchecked task", template_text, re.IGNORECASE), (
"converge no longer says to compare findings against existing unchecked tasks, so "
"a second run appends the same work again under new IDs"
)
assert re.search(r"`- \[ \]`", template_text), (
"the comparison should name the marker it scans for, so the rule is executable"
)


def test_the_comparison_skips_code_fences(template_text: str) -> None:
"""Same rule the other commands apply — an example checkbox is not a tracked task."""
assert re.search(r"outside\s+(?:of\s+)?code\s+fences", template_text, re.IGNORECASE), (
"the scan must exclude fenced blocks, or a checklist documenting the checkbox "
"format reads as tracked work"
)


def test_already_tracked_findings_are_reported_not_silently_dropped(template_text: str) -> None:
assert re.search(r"already tracked", template_text, re.IGNORECASE), (
"a finding dropped as already-tracked must be reported; dropping it silently is "
"indistinguishable from not having found it"
)