From a04a990caad1d2cdc546d06e8e5151b995149ec9 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Sat, 15 Aug 2026 21:56:15 +0500 Subject: [PATCH] fix(cline): stop unrelated prose from suppressing the hook command note `ClineIntegration._inject_hook_command_note` guarded idempotency with a whole-document substring scan -- `if "replace dots" in content: return content` -- instead of the per-instruction check the shared `SkillsIntegration._inject_hook_command_note` helper uses (base.py:1637-1642), which compares only the line immediately above each match. Two consequences, both verified on main: A. unrelated prose -> note injected? False (base: True) B. two hook sections, one already noted -> notes: 1 (want 2) (a) Any command or extension markdown whose prose happens to contain the phrase "replace dots" loses the note entirely, so the generated workflow tells the agent to emit `/speckit.git.commit` -- a dotted command Cline never registers, since Cline installs `speckit-git-commit.md`. (b) A document with one already-noted hook section never gets a note on a second, un-noted one -- exactly what the base helper was changed to handle. Also aligns the capture group with the base helper: `^([ \t]*)` rather than `^(\s*)`. Because `\s` matches newlines the captured "indent" could swallow a preceding blank line, which was then re-emitted between the note and the instruction: '## Hooks\n\n \n\n - For each executable hook, ...' The per-instruction check cannot line up until this is fixed. Co-Authored-By: Claude Opus 5 (1M context) --- .../integrations/cline/__init__.py | 26 +++++++-- tests/integrations/test_integration_cline.py | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c3ea3cc409..c48a4dd5f8 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -101,14 +101,25 @@ def _inject_hook_command_note(content: str) -> str: Targets the line ``- For each executable hook, output the following`` and inserts the note on the line before it, matching its indentation. - Skips if the note is already present. + Skips individual instructions that already have the note immediately + above them. """ - if "replace dots" in content: - return content + note = _HOOK_COMMAND_NOTE.rstrip("\n") def repl(m: re.Match[str]) -> str: indent = m.group(1) instruction = m.group(2) + # Check the line immediately above this instruction, mirroring the + # shared ``SkillsIntegration`` helper. The previous whole-document + # ``if "replace dots" in content`` scan meant any command or + # extension markdown whose prose merely contained that phrase lost + # the note entirely -- leaving the agent told to emit a dotted + # ``/speckit.git.commit``, which Cline never registers -- and a + # document with one already-noted section never got a note on a + # second, un-noted one. + previous_lines = content[:m.start()].splitlines() + if previous_lines and previous_lines[-1] == indent + note: + return m.group(0) # ``eol`` is empty when the regex matched via ``$`` because the # instruction was the final line of a file with no trailing # newline. Default to ``\n`` so the note never collapses onto @@ -116,15 +127,20 @@ def repl(m: re.Match[str]) -> str: eol = m.group(3) or "\n" return ( indent - + _HOOK_COMMAND_NOTE.rstrip("\n") + + note + eol + indent + instruction + eol ) + # ``[ \t]*`` rather than ``\s*``: ``\s`` matches newlines, so the + # captured "indent" could swallow a preceding blank line and the note + # was then emitted with a spurious blank line between it and the + # instruction. This also matches the shared base helper, without which + # the per-instruction check above cannot line up. return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", repl, content, ) diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 3c813e8300..d7350a9633 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -106,6 +106,59 @@ def test_cline_hook_instruction_injection_no_trailing_newline(self): # Instruction stays on its own line rather than being mashed onto the note. assert "\n- For each executable hook, output the following:" in injected + def test_cline_hook_note_not_suppressed_by_unrelated_prose(self): + """Unrelated prose must not suppress the note for a real instruction. + + The idempotency guard used to be a whole-document substring scan + (``if "replace dots" in content``), so any command or extension + markdown whose prose merely contained that phrase lost the note + entirely -- leaving the agent told to emit ``/speckit.git.commit``, + a dotted command Cline never registers. + """ + cline = get_integration("cline") + content = ( + "# DB extension command\n\n" + "When normalizing table names, replace dots with underscores.\n\n" + "## Pre-Execution Hooks\n" + "- For each executable hook, output the following:\n" + ) + injected = cline._inject_hook_command_note(content) + assert "`/speckit-git-commit`" in injected, injected + # The user's own prose is untouched. + assert "replace dots with underscores" in injected + + def test_cline_hook_note_added_to_every_un_noted_instruction(self): + """A second, un-noted hook section must still get its own note.""" + cline = get_integration("cline") + instruction = "- For each executable hook, output the following:\n" + # Section 1 already carries the note; section 2 does not. + first = cline._inject_hook_command_note("## Hooks A\n" + instruction) + content = first + "\n## Hooks B\n" + instruction + + injected = cline._inject_hook_command_note(content) + assert injected.count("replace dots (`.`) with hyphens (`-`)") == 2, injected + # Still idempotent: re-running adds nothing. + assert cline._inject_hook_command_note(injected) == injected + + def test_cline_hook_note_sits_directly_above_indented_instruction(self): + """No blank line may be inserted between the note and the instruction. + + The regex captured indentation with ``\\s*``, which matches newlines, + so a preceding blank line was swallowed into the "indent" and re-emitted + between the note and the instruction. + """ + cline = get_integration("cline") + content = "## Hooks\n\n - For each executable hook, output the following:\n" + injected = cline._inject_hook_command_note(content) + lines = injected.splitlines() + instruction_idx = next( + i for i, line in enumerate(lines) if "For each executable hook" in line + ) + assert "replace dots" in lines[instruction_idx - 1], injected + # Indentation is preserved on both lines. + assert lines[instruction_idx].startswith(" - For each") + assert lines[instruction_idx - 1].startswith(" - When constructing") + # -- Overrides for MarkdownIntegrationTests --------------------------- def test_setup_creates_files(self, tmp_path):