From 60b4980b8348b76cd8f0e881cbdef6162f7f01fe Mon Sep 17 00:00:00 2001 From: chelsealong Date: Wed, 30 Sep 2026 07:47:11 +0000 Subject: [PATCH 1/3] fix(junie): avoid $ARGUMENTS/$speckit-... becoming required Junie arguments Fixes #4794 --- .../integrations/junie/__init__.py | 23 ++++++++++++++- tests/integrations/test_integration_junie.py | 28 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 2d4a6b32d9..9fdea2b43b 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -132,6 +132,27 @@ def _rewrite_handoff_references(content: str) -> str: lambda m: f"{m.group(1)}{format_junie_command_name(m.group(2))}", content, ) + + @staticmethod + def _adapt_argument_placeholders(content: str) -> str: + """Stop Junie from auto-detecting required ``$identifier`` arguments. + + Junie treats every ``$name`` token in a command body as a required + named argument. Map ``$ARGUMENTS`` to Junie's free-text ``$prompt`` + (enabled via ``allowPromptArgument`` frontmatter) and drop the prose + example ``$speckit-...`` describing other agents' invocation syntax. + """ + content = content.replace(" or `$speckit-...`", "") + content = content.replace("$ARGUMENTS", "$prompt") + if "$prompt" in content and "allowPromptArgument:" not in content: + content = re.sub( + r"\A---(\r?\n)", + lambda m: f"---{m.group(1)}allowPromptArgument: true{m.group(1)}", + content, + count=1, + ) + return content + def post_process_command_content(self, content: str) -> str: """Apply Junie-specific transformations to command content. @@ -142,7 +163,7 @@ def post_process_command_content(self, content: str) -> str: """ updated = self._inject_hook_command_note(content) updated = self._rewrite_handoff_references(updated) - return updated + return self._adapt_argument_placeholders(updated) def setup( self, diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index a6234ba734..3ca9ba8d7c 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -109,6 +109,34 @@ def test_junie_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_junie_argument_placeholders_adapted(self): + """$ARGUMENTS / $speckit-... must not become required Junie arguments.""" + junie = get_integration("junie") + content = ( + "---\ndescription: x\n---\n$ARGUMENTS\n" + "run as `/skill:speckit-...` or `$speckit-...`).\n" + ) + out = junie.post_process_command_content(content) + assert out.startswith("---\nallowPromptArgument: true\n") + assert "$prompt" in out + assert "$ARGUMENTS" not in out + assert "$speckit" not in out + + def test_junie_setup_leaves_no_stray_dollar_arguments(self, tmp_path): + """Installed commands only reference Junie's own ``$prompt`` token.""" + import re + from specify_cli.integrations.manifest import IntegrationManifest + + junie = get_integration("junie") + m = IntegrationManifest("junie", tmp_path) + junie.setup(tmp_path, m, script_type="sh") + files = list((tmp_path / ".junie" / "commands").glob("*.md")) + assert files + for f in files: + text = f.read_text(encoding="utf-8") + assert set(re.findall(r"\$[A-Za-z_][A-Za-z0-9_-]*", text)) <= {"$prompt"}, f + assert "allowPromptArgument: true" in text + # -- Overrides for MarkdownIntegrationTests --------------------------- def test_setup_creates_files(self, tmp_path): From dda92576b637a05f72ea273b13c19185b6bd71fc Mon Sep 17 00:00:00 2001 From: chelsealong Date: Wed, 30 Sep 2026 13:45:12 +0000 Subject: [PATCH 2/3] fix(junie): force allowPromptArgument: true in frontmatter when $prompt is used AI-assisted (Claude Code). --- src/specify_cli/integrations/junie/__init__.py | 14 ++++++++------ tests/integrations/test_integration_junie.py | 9 +++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 9fdea2b43b..2851e1ce8d 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -144,13 +144,15 @@ def _adapt_argument_placeholders(content: str) -> str: """ content = content.replace(" or `$speckit-...`", "") content = content.replace("$ARGUMENTS", "$prompt") - if "$prompt" in content and "allowPromptArgument:" not in content: - content = re.sub( - r"\A---(\r?\n)", - lambda m: f"---{m.group(1)}allowPromptArgument: true{m.group(1)}", - content, - count=1, + fm = re.match(r"\A---(\r?\n)(.*?)(\r?\n)---(?=\r?\n|\Z)", content, re.S) + if "$prompt" in content and fm: + nl, block = fm.group(1), fm.group(2) + block, n = re.subn( + r"(?m)^allowPromptArgument:[^\r\n]*", "allowPromptArgument: true", block ) + if not n: + block = f"allowPromptArgument: true{nl}{block}" + content = f"---{nl}{block}{content[fm.end(2):]}" return content def post_process_command_content(self, content: str) -> str: diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index 3ca9ba8d7c..84232f1274 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -122,6 +122,15 @@ def test_junie_argument_placeholders_adapted(self): assert "$ARGUMENTS" not in out assert "$speckit" not in out + def test_junie_allow_prompt_argument_false_is_enabled(self): + junie = get_integration("junie") + content = "---\nallowPromptArgument: false\n---\n$ARGUMENTS\n" + out = junie.post_process_command_content(content) + assert out == "---\nallowPromptArgument: true\n---\n$prompt\n" + body = "---\ndescription: x\n---\nallowPromptArgument: no\n$ARGUMENTS\n" + out = junie.post_process_command_content(body) + assert out.startswith("---\nallowPromptArgument: true\ndescription: x\n---\n") + def test_junie_setup_leaves_no_stray_dollar_arguments(self, tmp_path): """Installed commands only reference Junie's own ``$prompt`` token.""" import re From 1ca963cee57b833283f4e6ea7843dac14c3687a0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:20:27 -0500 Subject: [PATCH 3/3] test(workflows): make catalog ZIP fixture deterministic Pin the workflow ZIP member timestamp so independently generated catalog and download fixtures remain byte-identical across DOS timestamp boundaries. Add a regression using controlled wall-clock values two seconds apart; it fails with the original helper and passes with the fixed timestamp. Production integrity checks and existing negative tests are unchanged. Validation: targeted workflow catalog and Junie tests: 78 passed; Ruff: passed. Full pytest collection: 9014 before, 9015 after. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/test_catalog_versions.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index f9828b3278..005c5989d0 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -4,6 +4,7 @@ import hashlib import io +import time import zipfile import pytest @@ -49,10 +50,24 @@ def _archive(version: str, workflow_id: str = "history-wf", requires=None) -> by document["requires"] = requires output = io.BytesIO() with zipfile.ZipFile(output, "w") as archive: - archive.writestr("workflow.yml", yaml.safe_dump(document)) + archive.writestr( + zipfile.ZipInfo("workflow.yml", date_time=(2020, 1, 1, 0, 0, 0)), + yaml.safe_dump(document), + ) return output.getvalue() +def test_archive_is_independent_of_wall_clock(monkeypatch): + archives = [] + for second in (2, 4): + wall_clock = time.struct_time((2026, 9, 30, 14, 39, second, 2, 273, -1)) + monkeypatch.setattr( + zipfile.time, "localtime", lambda timestamp=None, value=wall_clock: value + ) + archives.append(_archive("1.0.0", requires={"integrations": ["copilot"]})) + assert archives[0] == archives[1] + + def _entry() -> dict: old = _archive("1.0.0", requires={"integrations": ["copilot"]}) current = _archive("2.0.0")