Skip to content
Draft
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
14 changes: 11 additions & 3 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import yaml

from .._invocation_style import is_dollar_skills_agent
from .._toml_string import escape_toml_basic as _escape_toml_basic
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control

Expand Down Expand Up @@ -1520,12 +1521,13 @@ def skills_dest(self, project_root: Path) -> Path:
return project_root / folder / subdir

def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
"""Build the agent's native invocation for a hyphenated skill name."""
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]

invocation = "/speckit-" + stem.replace(".", "-")
prefix = "$" if is_dollar_skills_agent(self.key, True) else "/"
invocation = prefix + "speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
Expand Down Expand Up @@ -1576,7 +1578,13 @@ def post_process_skill_content(self, content: str) -> str:
guidance for converting dotted hook command names to hyphenated
slash commands. Subclasses may override — see ``ClaudeIntegration``.
"""
return self._inject_hook_command_note(content)
uses_dollar_invocations = is_dollar_skills_agent(self.key, True)
if uses_dollar_invocations:
content = content.replace("$speckit-", "/speckit-")
content = self._inject_hook_command_note(content)
if uses_dollar_invocations:
content = content.replace("/speckit-", "$speckit-")
return content

def setup(
self,
Expand Down
26 changes: 26 additions & 0 deletions templates/commands/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ $ARGUMENTS

You **MUST** consider the user input before proceeding (if not empty).

## Scope Guard

This command updates project governance only. It may modify:

- `.specify/memory/constitution.md`
- dependent templates under `.specify/templates/`
- installed Spec Kit command files and runtime guidance documents when required to keep
them consistent with the amended constitution

It **MUST NOT** create or modify application source code, feature specifications, plans,
tasks, tests, or other implementation artifacts.

If the user input includes feature requests, implementation instructions, or other
non-governance work:

1. Extract that content as out-of-scope intent.
2. Do not execute it during this command.
3. Complete the constitution update using only the governance-related input.
4. Report the out-of-scope intent under **Suggested Follow-ups**, with the appropriate
next command (typically `__SPECKIT_COMMAND_SPECIFY__ <intent>`).
Comment thread
BenBtg marked this conversation as resolved.

Never invoke a suggested follow-up command automatically.

## Pre-Execution Checks

**Check for extension hooks (before constitution update)**:
Expand Down Expand Up @@ -103,6 +126,9 @@ Follow this execution flow:
8. Output a final summary to the user with:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- A **Suggested Follow-ups** section listing any out-of-scope intent captured by the
Scope Guard and its recommended command. Omit this section when there is no
out-of-scope intent.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).

Formatting & Style Requirements:
Expand Down
26 changes: 21 additions & 5 deletions tests/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,35 @@ def test_base_extension_command_bare(self):
def test_skills_core_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"
assert i.build_command_invocation("speckit.plan") == "$speckit-plan"
assert i.build_command_invocation("plan") == "$speckit-plan"

def test_skills_extension_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
assert i.build_command_invocation("speckit.git.commit") == "$speckit-git-commit"
assert i.build_command_invocation("git.commit") == "$speckit-git-commit"

def test_skills_extension_command_with_args(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "$speckit-git-commit fix typo"

@pytest.mark.parametrize("integration_key", ["codex", "zcode"])
def test_dollar_skill_post_processing_is_idempotent(self, integration_key):
from specify_cli.integrations import get_integration

content = (
"---\nname: test\n---\n\n"
"- For each executable hook, output the following based on its flag:\n"
)
integration = get_integration(integration_key)
once = integration.post_process_skill_content(content)
twice = integration.post_process_skill_content(once)

assert twice == once
assert once.count("replace dots (`.`) with hyphens") == 1
assert "$speckit-git-commit" in once

def test_forge_core_command_hyphenated(self):
"""Forge installs hyphenated slash-commands (/speckit-<name>), so the
Expand Down
10 changes: 10 additions & 0 deletions tests/integrations/test_integration_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ class TestCodexIntegration(SkillsIntegrationTests):
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"

def test_constitution_skill_uses_dollar_follow_up(self, tmp_path):
integration = get_integration("codex")
manifest = IntegrationManifest("codex", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")

skill = tmp_path / ".agents/skills/speckit-constitution/SKILL.md"
content = skill.read_text(encoding="utf-8")
assert "$speckit-specify <intent>" in content
assert "/speckit-specify <intent>" not in content


class TestCodexInitFlow:
"""--integration codex creates expected files."""
Expand Down
18 changes: 18 additions & 0 deletions tests/integrations/test_integration_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,24 @@ def test_implement_loads_constitution_context(self, tmp_path):
content = implement_file.read_text(encoding="utf-8")
assert ".specify/memory/constitution.md" in content

def test_constitution_command_rejects_implementation_scope(self, tmp_path):
"""The constitution command must defer non-governance work."""
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
constitution_file = (
tmp_path / ".custom" / "cmds" / "speckit.constitution.md"
)
assert constitution_file.exists()
content = constitution_file.read_text(encoding="utf-8")

assert "## Scope Guard" in content
assert "MUST NOT" in content
assert "application source code" in content
assert "Do not execute it during this command." in content
assert "Never invoke a suggested follow-up command automatically." in content
assert "/speckit.specify <intent>" in content

@pytest.mark.parametrize(
"command_stem",
[
Expand Down
13 changes: 13 additions & 0 deletions tests/integrations/test_integration_zcode.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Tests for ZcodeIntegration — skills-based integration (Z.AI)."""

from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest

from .test_integration_base_skills import SkillsIntegrationTests


Expand All @@ -9,6 +12,16 @@ class TestZcodeIntegration(SkillsIntegrationTests):
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".zcode/skills"

def test_constitution_skill_uses_dollar_follow_up(self, tmp_path):
integration = get_integration("zcode")
manifest = IntegrationManifest("zcode", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")

skill = tmp_path / ".zcode/skills/speckit-constitution/SKILL.md"
content = skill.read_text(encoding="utf-8")
assert "$speckit-specify <intent>" in content
assert "/speckit-specify <intent>" not in content


class TestZcodeInvocation:
"""ZCode renders $speckit-* chat invocations (like Codex)."""
Expand Down
Loading