fix: resolve command references per integration type (dot vs hyphen) (#2354)

* fix: resolve command references per integration type (dot vs hyphen)

Replace hardcoded /speckit.<cmd> references in templates with
__SPECKIT_COMMAND_<NAME>__ placeholders that are resolved at
setup time based on the integration type:

- Markdown/TOML/YAML agents: separator='.' → /speckit.plan
- Skills agents: separator='-' → /speckit-plan

Changes:
- Add resolve_command_refs() static method to IntegrationBase
- Add invoke_separator class attribute (.  for base, - for skills)
- Wire into process_template() as step 8
- Update _install_shared_infra() to process page templates
- Replace /speckit.* in 5 command templates and 3 page templates
- Add unit tests for resolve_command_refs (positive + negative)
- Add integration tests verifying on-disk content for all agents
- Add end-to-end CLI tests for Claude (skills) and Copilot (markdown)

Fixes #2347

* review: use effective_invoke_separator() for Copilot skills mode

Address PR review feedback: instead of bleeding _skills_mode
knowledge into the CLI layer, add effective_invoke_separator()
method to IntegrationBase that accepts parsed_options.

CopilotIntegration overrides it to return "-" when skills
mode is requested. The CLI layer simply asks the integration
for its separator — no hasattr or _skills_mode coupling.

Also adds tests for the new method on both base and Copilot,
plus an end-to-end test for 'specify init --integration copilot
--integration-options --skills' verifying page templates get
hyphen refs.

* fix: build_command_invocation preserves full suffix for extension commands

Previously rsplit('.', 1)[-1] on 'speckit.git.commit' yielded
just 'commit', producing /speckit.commit instead of
/speckit.git.commit (or /speckit-git-commit for skills).

Fix: strip only the 'speckit.' prefix when present, then join
remaining segments with the appropriate separator.

Updated in IntegrationBase, SkillsIntegration, and
CopilotIntegration. Added tests for extension commands in
build_command_invocation across all three.

* fix: Copilot dispatch_command() preserves full extension command suffix

dispatch_command() had the same rsplit('.', 1)[-1] bug as
build_command_invocation() — speckit.git.commit would dispatch
as /speckit-commit instead of /speckit-git-commit in skills
mode, or --agent speckit.commit instead of speckit.git.commit
in default mode.
This commit is contained in:
Manfred Riem
2026-04-24 10:04:14 -05:00
committed by GitHub
parent 6413414907
commit 52c0a5f88f
21 changed files with 434 additions and 55 deletions

View File

@@ -723,6 +723,7 @@ def _install_shared_infra(
script_type: str, script_type: str,
tracker: StepTracker | None = None, tracker: StepTracker | None = None,
force: bool = False, force: bool = False,
invoke_separator: str = ".",
) -> bool: ) -> bool:
"""Install shared infrastructure files into *project_path*. """Install shared infrastructure files into *project_path*.
@@ -730,12 +731,17 @@ def _install_shared_infra(
bundled core_pack or source checkout. Tracks all installed files bundled core_pack or source checkout. Tracks all installed files
in ``speckit.manifest.json``. in ``speckit.manifest.json``.
Page templates are processed to resolve ``__SPECKIT_COMMAND_<NAME>__``
placeholders using *invoke_separator* (``"."`` for markdown agents,
``"-"`` for skills agents).
When *force* is ``True``, existing files are overwritten with the When *force* is ``True``, existing files are overwritten with the
latest bundled versions. When ``False`` (default), only missing latest bundled versions. When ``False`` (default), only missing
files are added and existing ones are skipped. files are added and existing ones are skipped.
Returns ``True`` on success. Returns ``True`` on success.
""" """
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest from .integrations.manifest import IntegrationManifest
core = _locate_core_pack() core = _locate_core_pack()
@@ -786,7 +792,11 @@ def _install_shared_infra(
if dst.exists() and not force: if dst.exists() and not force:
skipped_files.append(str(dst.relative_to(project_path))) skipped_files.append(str(dst.relative_to(project_path)))
else: else:
shutil.copy2(f, dst) content = f.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(
content, invoke_separator
)
dst.write_text(content, encoding="utf-8")
rel = dst.relative_to(project_path).as_posix() rel = dst.relative_to(project_path).as_posix()
manifest.record_existing(rel) manifest.record_existing(rel)
@@ -1295,7 +1305,7 @@ def init(
# Install shared infrastructure (scripts, templates) # Install shared infrastructure (scripts, templates)
tracker.start("shared-infra") tracker.start("shared-infra")
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force) _install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options))
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
ensure_constitution_from_template(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker)
@@ -2072,9 +2082,16 @@ def integration_install(
selected_script = _resolve_script_type(project_root, script) selected_script = _resolve_script_type(project_root, script)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
# Ensure shared infrastructure is present (safe to run unconditionally; # Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting). # _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script) _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options))
if os.name != "nt": if os.name != "nt":
ensure_executable_scripts(project_root) ensure_executable_scripts(project_root)
@@ -2082,11 +2099,6 @@ def integration_install(
integration.key, project_root, version=get_speckit_version() integration.key, project_root, version=get_speckit_version()
) )
# Build parsed options from --integration-options
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
try: try:
integration.setup( integration.setup(
project_root, manifest, project_root, manifest,
@@ -2356,9 +2368,16 @@ def integration_switch(
opts.pop("context_file", None) opts.pop("context_file", None)
save_init_options(project_root, opts) save_init_options(project_root, opts)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(target_integration, integration_options)
# Ensure shared infrastructure is present (safe to run unconditionally; # Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting). # _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script) _install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options))
if os.name != "nt": if os.name != "nt":
ensure_executable_scripts(project_root) ensure_executable_scripts(project_root)
@@ -2368,10 +2387,6 @@ def integration_switch(
target_integration.key, project_root, version=get_speckit_version() target_integration.key, project_root, version=get_speckit_version()
) )
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(target_integration, integration_options)
try: try:
target_integration.setup( target_integration.setup(
project_root, manifest, project_root, manifest,
@@ -2465,8 +2480,15 @@ def integration_upgrade(
selected_script = _resolve_script_type(project_root, script) selected_script = _resolve_script_type(project_root, script)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
# Ensure shared infrastructure is up to date; --force overwrites existing files. # Ensure shared infrastructure is up to date; --force overwrites existing files.
_install_shared_infra(project_root, selected_script, force=force) _install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options))
if os.name != "nt": if os.name != "nt":
ensure_executable_scripts(project_root) ensure_executable_scripts(project_root)
@@ -2474,10 +2496,6 @@ def integration_upgrade(
console.print(f"Upgrading integration: [cyan]{key}[/cyan]") console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
try: try:
integration.setup( integration.setup(
project_root, project_root,

View File

@@ -84,6 +84,9 @@ class IntegrationBase(ABC):
context_file: str | None = None context_file: str | None = None
"""Relative path to the agent context file (e.g. ``CLAUDE.md``).""" """Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
# -- Markers for managed context section ------------------------------ # -- Markers for managed context section ------------------------------
CONTEXT_MARKER_START = "<!-- SPECKIT START -->" CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
@@ -96,6 +99,18 @@ class IntegrationBase(ABC):
"""Return options this integration accepts. Default: none.""" """Return options this integration accepts. Default: none."""
return [] return []
def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
) -> str:
"""Return the invoke separator for the given options.
Subclasses whose separator depends on runtime options (e.g.
Copilot in ``--skills`` mode) should override this method.
The default implementation ignores *parsed_options* and returns
the class-level ``invoke_separator``.
"""
return self.invoke_separator
def build_exec_args( def build_exec_args(
self, self,
prompt: str, prompt: str,
@@ -122,11 +137,12 @@ class IntegrationBase(ABC):
agents or ``"/speckit-specify my-feature"`` for skills agents. agents or ``"/speckit-specify my-feature"`` for skills agents.
*command_name* may be a full dotted name like *command_name* may be a full dotted name like
``"speckit.specify"`` or a bare stem like ``"specify"``. ``"speckit.specify"``, an extension command like
``"speckit.git.commit"``, or a bare stem like ``"specify"``.
""" """
stem = command_name stem = command_name
if "." in stem: if stem.startswith("speckit."):
stem = stem.rsplit(".", 1)[-1] stem = stem[len("speckit."):]
invocation = f"/speckit.{stem}" invocation = f"/speckit.{stem}"
if args: if args:
@@ -597,6 +613,24 @@ class IntegrationBase(ABC):
return True return True
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
Each placeholder encodes a command name in upper-case with
underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``,
``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses
*separator* to join the segments:
* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``
"""
return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
content,
)
@staticmethod @staticmethod
def process_template( def process_template(
content: str, content: str,
@@ -604,6 +638,7 @@ class IntegrationBase(ABC):
script_type: str, script_type: str,
arg_placeholder: str = "$ARGUMENTS", arg_placeholder: str = "$ARGUMENTS",
context_file: str = "", context_file: str = "",
invoke_separator: str = ".",
) -> str: ) -> str:
"""Process a raw command template into agent-ready content. """Process a raw command template into agent-ready content.
@@ -615,6 +650,7 @@ class IntegrationBase(ABC):
5. Replace ``__AGENT__`` with *agent_name* 5. Replace ``__AGENT__`` with *agent_name*
6. Replace ``__CONTEXT_FILE__`` with *context_file* 6. Replace ``__CONTEXT_FILE__`` with *context_file*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
""" """
# 1. Extract script command from frontmatter # 1. Extract script command from frontmatter
script_command = "" script_command = ""
@@ -684,6 +720,9 @@ class IntegrationBase(ABC):
content = CommandRegistrar.rewrite_project_relative_paths(content) content = CommandRegistrar.rewrite_project_relative_paths(content)
# 8. Replace __SPECKIT_COMMAND_<NAME>__ with invocation strings
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
return content return content
def setup( def setup(
@@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase):
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter. ``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
""" """
invoke_separator = "-"
def build_exec_args( def build_exec_args(
self, self,
prompt: str, prompt: str,
@@ -1311,10 +1352,10 @@ class SkillsIntegration(IntegrationBase):
def build_command_invocation(self, command_name: str, args: str = "") -> str: def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills use ``/speckit-<stem>`` (hyphenated directory name).""" """Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
stem = command_name stem = command_name
if "." in stem: if stem.startswith("speckit."):
stem = stem.rsplit(".", 1)[-1] stem = stem[len("speckit."):]
invocation = f"/speckit-{stem}" invocation = "/speckit-" + stem.replace(".", "-")
if args: if args:
invocation = f"{invocation} {args}" invocation = f"{invocation} {args}"
return invocation return invocation
@@ -1395,6 +1436,7 @@ class SkillsIntegration(IntegrationBase):
processed_body = self.process_template( processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder, raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "", context_file=self.context_file or "",
invoke_separator=self.invoke_separator,
) )
# Strip the processed frontmatter — we rebuild it for skills. # Strip the processed frontmatter — we rebuild it for skills.
# Preserve leading whitespace in the body to match release ZIP # Preserve leading whitespace in the body to match release ZIP

View File

@@ -103,6 +103,16 @@ class CopilotIntegration(IntegrationBase):
# Mutable flag set by setup() — indicates the active scaffolding mode. # Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False _skills_mode: bool = False
def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
return "-"
if self._skills_mode:
return "-"
return self.invoke_separator
@classmethod @classmethod
def options(cls) -> list[IntegrationOption]: def options(cls) -> list[IntegrationOption]:
return [ return [
@@ -145,9 +155,9 @@ class CopilotIntegration(IntegrationBase):
""" """
if self._skills_mode: if self._skills_mode:
stem = command_name stem = command_name
if "." in stem: if stem.startswith("speckit."):
stem = stem.rsplit(".", 1)[-1] stem = stem[len("speckit."):]
invocation = f"/speckit-{stem}" invocation = "/speckit-" + stem.replace(".", "-")
if args: if args:
invocation = f"{invocation} {args}" invocation = f"{invocation} {args}"
return invocation return invocation
@@ -175,8 +185,8 @@ class CopilotIntegration(IntegrationBase):
import subprocess import subprocess
stem = command_name stem = command_name
if "." in stem: if stem.startswith("speckit."):
stem = stem.rsplit(".", 1)[-1] stem = stem[len("speckit."):]
# Detect skills mode from project layout when not set via setup() # Detect skills mode from project layout when not set via setup()
skills_mode = self._skills_mode skills_mode = self._skills_mode
@@ -189,7 +199,7 @@ class CopilotIntegration(IntegrationBase):
) )
if skills_mode: if skills_mode:
prompt = f"/speckit-{stem}" prompt = "/speckit-" + stem.replace(".", "-")
if args: if args:
prompt = f"{prompt} {args}" prompt = f"{prompt} {args}"
else: else:

View File

@@ -4,13 +4,13 @@
**Created**: [DATE] **Created**: [DATE]
**Feature**: [Link to spec.md or relevant documentation] **Feature**: [Link to spec.md or relevant documentation]
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. **Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
<!-- <!--
============================================================================ ============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only. IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The /speckit.checklist command MUST replace these with actual items based on: The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
- User's specific checklist request - User's specific checklist request
- Feature requirements from spec.md - Feature requirements from spec.md
- Technical context from plan.md - Technical context from plan.md

View File

@@ -49,13 +49,13 @@ You **MUST** consider the user input before proceeding (if not empty).
## Goal ## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`. Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `__SPECKIT_COMMAND_TASKS__` has successfully produced a complete `tasks.md`.
## Operating Constraints ## Operating Constraints
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually). **STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`. **Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `__SPECKIT_COMMAND_ANALYZE__`.
## Execution Steps ## Execution Steps
@@ -191,9 +191,9 @@ Output a Markdown report (no file writes) with the following structure:
At end of report, output a concise Next Actions block: At end of report, output a concise Next Actions block:
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement` - If CRITICAL issues exist: Recommend resolving before `__SPECKIT_COMMAND_IMPLEMENT__`
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions - If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'" - Provide explicit command suggestions: e.g., "Run __SPECKIT_COMMAND_SPECIFY__ with refinement", "Run __SPECKIT_COMMAND_PLAN__ to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
### 8. Offer Remediation ### 8. Offer Remediation

View File

@@ -249,7 +249,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Actor/timing - Actor/timing
- Any explicit user-specified must-have items incorporated - Any explicit user-specified must-have items incorporated
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows: **Important**: Each `__SPECKIT_COMMAND_CHECKLIST__` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`) - Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
- Simple, memorable filenames that indicate checklist purpose - Simple, memorable filenames that indicate checklist purpose

View File

@@ -55,7 +55,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file. Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases. Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `__SPECKIT_COMMAND_PLAN__`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Execution steps: Execution steps:
@@ -63,7 +63,7 @@ Execution steps:
- `FEATURE_DIR` - `FEATURE_DIR`
- `FEATURE_SPEC` - `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.) - (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment. - If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment.
- 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"). - 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 the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked). 2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
@@ -202,13 +202,13 @@ Execution steps:
- Path to updated spec. - Path to updated spec.
- Sections touched (list names). - Sections touched (list names).
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact). - Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan. - If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
- Suggested next command. - Suggested next command.
Behavior rules: Behavior rules:
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding. - If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here). - If spec file missing, instruct user to run `__SPECKIT_COMMAND_SPECIFY__` first (do not create a new spec here).
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions). - Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
- Avoid speculative tech stack questions unless the absence blocks functional clarity. - Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed"). - Respect user early termination signals ("stop", "done", "proceed").

View File

@@ -169,7 +169,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Confirm the implementation follows the technical plan - Confirm the implementation follows the technical plan
- Report final status with summary of completed work - Report final status with summary of completed work
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list. Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `__SPECKIT_COMMAND_TASKS__` first to regenerate the task list.
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root. 10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_implement` key - If it exists, read it and look for entries under the `hooks.after_implement` key

View File

@@ -54,7 +54,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline ## Outline
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command. The text the user typed after `__SPECKIT_COMMAND_SPECIFY__` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
Given that feature description, do this: Given that feature description, do this:
@@ -100,10 +100,10 @@ Given that feature description, do this:
} }
``` ```
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`. Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions. This allows downstream commands (`__SPECKIT_COMMAND_PLAN__`, `__SPECKIT_COMMAND_TASKS__`, etc.) to locate the feature directory without relying on git branch name conventions.
**IMPORTANT**: **IMPORTANT**:
- You must only create one feature per `/speckit.specify` invocation - You must only create one feature per `__SPECKIT_COMMAND_SPECIFY__` invocation
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
- The spec directory and file are always created by this command, never by the hook - The spec directory and file are always created by this command, never by the hook
@@ -174,7 +174,7 @@ Given that feature description, do this:
## Notes ## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` - Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
``` ```
b. **Run Validation Check**: Review the spec against each checklist item: b. **Run Validation Check**: Review the spec against each checklist item:
@@ -232,7 +232,7 @@ Given that feature description, do this:
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path - `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
- `SPEC_FILE` — the spec file path - `SPEC_FILE` — the spec file path
- Checklist results summary - Checklist results summary
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`) - Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. 9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_specify` key - If it exists, read it and look for entries under the `hooks.after_specify` key

View File

@@ -3,7 +3,7 @@
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` **Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. **Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary ## Summary
@@ -39,12 +39,12 @@
```text ```text
specs/[###-feature]/ specs/[###-feature]/
├── plan.md # This file (/speckit.plan command output) ├── plan.md # This file (__SPECKIT_COMMAND_PLAN__ command output)
├── research.md # Phase 0 output (/speckit.plan command) ├── research.md # Phase 0 output (__SPECKIT_COMMAND_PLAN__ command)
├── data-model.md # Phase 1 output (/speckit.plan command) ├── data-model.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
├── quickstart.md # Phase 1 output (/speckit.plan command) ├── quickstart.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
├── contracts/ # Phase 1 output (/speckit.plan command) ├── contracts/ # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) └── tasks.md # Phase 2 output (__SPECKIT_COMMAND_TASKS__ command - NOT created by __SPECKIT_COMMAND_PLAN__)
``` ```
### Source Code (repository root) ### Source Code (repository root)

View File

@@ -29,7 +29,7 @@ description: "Task list template for feature implementation"
============================================================================ ============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only. IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The /speckit.tasks command MUST replace these with actual tasks based on: The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...) - User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md - Feature requirements from plan.md
- Entities from data-model.md - Entities from data-model.md

View File

@@ -6,6 +6,7 @@ from specify_cli.integrations.base import (
IntegrationBase, IntegrationBase,
IntegrationOption, IntegrationOption,
MarkdownIntegration, MarkdownIntegration,
SkillsIntegration,
) )
from specify_cli.integrations.manifest import IntegrationManifest from specify_cli.integrations.manifest import IntegrationManifest
from .conftest import StubIntegration from .conftest import StubIntegration
@@ -167,3 +168,130 @@ class TestBasePrimitives:
assert f.parent.name == "commands" assert f.parent.name == "commands"
assert f.name.startswith("speckit.") assert f.name.startswith("speckit.")
assert f.name.endswith(".md") assert f.name.endswith(".md")
class TestBuildCommandInvocation:
"""Tests for build_command_invocation across integration types."""
def test_base_core_command_dotted(self):
i = StubIntegration()
assert i.build_command_invocation("speckit.plan") == "/speckit.plan"
def test_base_core_command_bare(self):
i = StubIntegration()
assert i.build_command_invocation("plan") == "/speckit.plan"
def test_base_core_command_with_args(self):
i = StubIntegration()
assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature"
def test_base_extension_command(self):
i = StubIntegration()
assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit"
def test_base_extension_command_bare(self):
i = StubIntegration()
assert i.build_command_invocation("git.commit") == "/speckit.git.commit"
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"
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"
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"
class TestResolveCommandRefs:
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""
def test_dot_separator_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run `/speckit.plan` to plan."
def test_hyphen_separator_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run `/speckit-plan` to plan."
def test_multiple_placeholders(self):
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.specify then /speckit.plan then /speckit.tasks"
def test_extension_command_dot(self):
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run /speckit.git.commit to commit."
def test_extension_command_hyphen(self):
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run /speckit-git-commit to commit."
def test_no_placeholders_unchanged(self):
text = "No placeholders here."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_default_separator_is_dot(self):
text = "__SPECKIT_COMMAND_PLAN__"
assert IntegrationBase.resolve_command_refs(text) == "/speckit.plan"
def test_invoke_separator_class_attribute(self):
assert IntegrationBase.invoke_separator == "."
assert SkillsIntegration.invoke_separator == "-"
def test_effective_invoke_separator_default(self):
"""Base classes return invoke_separator regardless of parsed_options."""
from .conftest import StubIntegration
stub = StubIntegration()
assert stub.effective_invoke_separator() == "."
assert stub.effective_invoke_separator({"skills": True}) == "."
def test_process_template_resolves_placeholders(self):
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
result = IntegrationBase.process_template(
content, "test-agent", "sh", invoke_separator="."
)
assert "/speckit.plan" in result
assert "__SPECKIT_COMMAND_" not in result
def test_process_template_skills_separator(self):
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
result = IntegrationBase.process_template(
content, "test-agent", "sh", invoke_separator="-"
)
assert "/speckit-plan" in result
assert "__SPECKIT_COMMAND_" not in result
def test_unclosed_placeholder_unchanged(self):
text = "Run __SPECKIT_COMMAND_PLAN to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_empty_name_not_matched(self):
text = "Run __SPECKIT_COMMAND___ to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_lowercase_placeholder_not_matched(self):
text = "Run __SPECKIT_COMMAND_plan__ to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_placeholder_adjacent_to_text(self):
text = "foo__SPECKIT_COMMAND_PLAN__bar"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "foo/speckit.planbar"
def test_placeholder_with_digits(self):
text = "__SPECKIT_COMMAND_V2_PLAN__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.v2.plan"

View File

@@ -471,3 +471,133 @@ class TestGitExtensionAutoInstall:
assert claude_skills.exists(), "Claude skills directory was not created" assert claude_skills.exists(), "Claude skills directory was not created"
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")] git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
assert len(git_skills) > 0, "no git extension commands registered" assert len(git_skills) > 0, "no git extension commands registered"
class TestSharedInfraCommandRefs:
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
def test_dot_separator_in_page_templates(self, tmp_path):
"""Markdown agents get /speckit.<name> in page templates."""
from specify_cli import _install_shared_infra
project = tmp_path / "dot-test"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(project, "sh", invoke_separator=".")
plan = project / ".specify" / "templates" / "plan-template.md"
assert plan.exists()
content = plan.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
assert "/speckit.plan" in content
checklist = project / ".specify" / "templates" / "checklist-template.md"
content = checklist.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit.checklist" in content
def test_hyphen_separator_in_page_templates(self, tmp_path):
"""Skills agents get /speckit-<name> in page templates."""
from specify_cli import _install_shared_infra
project = tmp_path / "hyphen-test"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(project, "sh", invoke_separator="-")
plan = project / ".specify" / "templates" / "plan-template.md"
assert plan.exists()
content = plan.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
assert "/speckit-plan" in content
assert "/speckit.plan" not in content, "dot-notation leaked into skills page template"
tasks = project / ".specify" / "templates" / "tasks-template.md"
content = tasks.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit-tasks" in content
def test_full_init_claude_resolves_page_templates(self, tmp_path):
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-claude"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "claude",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
assert "__SPECKIT_COMMAND_" not in content
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-copilot"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "copilot",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
assert "__SPECKIT_COMMAND_" not in content
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-copilot-skills"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
assert "__SPECKIT_COMMAND_" not in content

View File

@@ -98,6 +98,7 @@ class MarkdownIntegrationTests:
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
def test_plan_references_correct_context_file(self, tmp_path): def test_plan_references_correct_context_file(self, tmp_path):

View File

@@ -159,6 +159,22 @@ class SkillsIntegrationTests:
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_command_refs_use_hyphen_separator(self, tmp_path):
"""Skills agents must resolve command refs with hyphen separator."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
# Skills agents must use /speckit-<name>, not /speckit.<name>
assert "/speckit." not in content, (
f"{f.name} contains dot-notation /speckit. reference; "
f"skills agents must use /speckit-<name>"
)
def test_skill_body_has_content(self, tmp_path): def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content after the frontmatter.""" """Each SKILL.md body should contain template content after the frontmatter."""

View File

@@ -106,6 +106,7 @@ class TomlIntegrationTests:
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_toml_has_description(self, tmp_path): def test_toml_has_description(self, tmp_path):
"""Every TOML command file should have a description key.""" """Every TOML command file should have a description key."""

View File

@@ -105,6 +105,7 @@ class YamlIntegrationTests:
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_yaml_has_title(self, tmp_path): def test_yaml_has_title(self, tmp_path):
"""Every YAML recipe should have a title field.""" """Every YAML recipe should have a title field."""

View File

@@ -55,6 +55,8 @@ class TestClaudeIntegration:
assert "{SCRIPT}" not in content assert "{SCRIPT}" not in content
assert "{ARGS}" not in content assert "{ARGS}" not in content
assert "__AGENT__" not in content assert "__AGENT__" not in content
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
assert "/speckit." not in content, "skills agent must use /speckit-<name> not /speckit.<name>"
parts = content.split("---", 2) parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1]) parsed = yaml.safe_load(parts[1])

View File

@@ -144,6 +144,7 @@ class TestCopilotIntegration:
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}" assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__" assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}" assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content assert "\nscripts:\n" not in content
def test_plan_references_correct_context_file(self, tmp_path): def test_plan_references_correct_context_file(self, tmp_path):
@@ -444,6 +445,27 @@ class TestCopilotSkillsMode:
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_skills_command_refs_use_hyphen(self, tmp_path):
"""Copilot skills mode must use /speckit-<name> not /speckit.<name>."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "/speckit." not in content, (
f"{f.name} contains dot-notation /speckit. reference; "
f"skills mode must use /speckit-<name>"
)
def test_skills_mode_invoke_separator(self):
"""Copilot effective_invoke_separator should reflect skills mode."""
copilot = self._make_copilot()
assert copilot.effective_invoke_separator() == "."
assert copilot.effective_invoke_separator({"skills": True}) == "-"
assert copilot.effective_invoke_separator({"skills": False}) == "."
def test_skill_body_has_content(self, tmp_path): def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content.""" """Each SKILL.md body should contain template content."""
@@ -509,6 +531,12 @@ class TestCopilotSkillsMode:
assert copilot.build_command_invocation("plan") == "/speckit-plan" assert copilot.build_command_invocation("plan") == "/speckit-plan"
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args" assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
def test_build_command_invocation_skills_extension_command(self):
copilot = self._make_copilot()
copilot._skills_mode = True
assert copilot.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert copilot.build_command_invocation("git.commit") == "/speckit-git-commit"
def test_build_command_invocation_default_mode(self): def test_build_command_invocation_default_mode(self):
copilot = self._make_copilot() copilot = self._make_copilot()
assert copilot.build_command_invocation("plan", "my args") == "my args" assert copilot.build_command_invocation("plan", "my args") == "my args"

View File

@@ -152,6 +152,7 @@ class TestForgeIntegration:
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}" assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__" assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}" assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{cmd_file.name} has unprocessed __SPECKIT_COMMAND_*__"
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}} # Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS" assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
# Frontmatter sections should be stripped # Frontmatter sections should be stripped

View File

@@ -101,6 +101,7 @@ class TestGenericIntegration:
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_all_files_tracked_in_manifest(self, tmp_path): def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration("generic") i = get_integration("generic")