diff --git a/README.md b/README.md index ae651a2b16..afca9b15a5 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ Essential commands for the Spec-Driven Development workflow: | `/speckit.tasks` | `speckit-tasks` | Generate actionable task lists for implementation | | `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution | | `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan | +| `/speckit.converge` | `speckit-converge` | Assess the codebase against spec/plan/tasks and append remaining work as new tasks | ### Optional Commands diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e2d0bfb0b9..3e4c15122f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -429,6 +429,7 @@ def _print_cli_warning( "plan": "Generate technical implementation plans from feature specifications.", "tasks": "Break down implementation plans into actionable task lists.", "implement": "Execute all tasks from the task breakdown to build the feature.", + "converge": "Assess the codebase against spec.md, plan.md, and tasks.md and append remaining work as new tasks.", "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md.", "clarify": "Structured clarification workflow for underspecified requirements.", "constitution": "Create or update project governing principles and development guidelines.", diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 76514caa82..997b9ee679 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -781,6 +781,9 @@ def _display_cmd(name: str) -> str: steps_lines.append( f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation" ) + steps_lines.append( + f" {step_num}.6 [cyan]{_display_cmd('converge')}[/] - Assess the codebase and append remaining work as tasks" + ) steps_panel = Panel( "\n".join(steps_lines), diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 29b49b8d27..ecc26fa877 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -38,6 +38,7 @@ "checklist", "clarify", "constitution", + "converge", "implement", "plan", "specify", diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index def5ad20ba..0bd56e2329 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -39,6 +39,7 @@ "clarify", "constitution", "implement", + "converge", "plan", "checklist", "specify", diff --git a/templates/commands/converge.md b/templates/commands/converge.md new file mode 100644 index 0000000000..3d366e1d30 --- /dev/null +++ b/templates/commands/converge.md @@ -0,0 +1,270 @@ +--- +description: Assess the current codebase against the feature's spec, plan, and tasks, then append any remaining unbuilt work as new tasks to tasks.md so implement can complete it. +scripts: + sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks + ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Pre-Execution Checks + +**Check for extension hooks (before convergence)**: + +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_converge` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + + ```text + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + + - **Mandatory hook** (`optional: false`): + + ```text + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Goal. + ``` + +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + +## Goal + +Close the gap between what a feature's specification, plan, and tasks call for and what the +codebase currently implements. Read `spec.md`, `plan.md`, and `tasks.md` as the **sole +source of intent** (with the constitution as governing constraints), assess the current +state of the code, determine which requirements, acceptance criteria, plan decisions, and +existing tasks are unmet, incomplete, or only partially satisfied, and **append each piece +of remaining work as a new, traceable task** at the bottom of `tasks.md` so that +`__SPECKIT_COMMAND_IMPLEMENT__` can complete it. This command MUST run only after +`__SPECKIT_COMMAND_IMPLEMENT__` has run on the current `tasks.md`, and after `__SPECKIT_COMMAND_TASKS__` has produced a complete `tasks.md`. + +This is **not** a diff tool and does **not** track changes. It assesses the present state +of the code relative to the feature's artifacts — no git, no branch comparison, no history. + +## Operating Constraints + +**APPEND-ONLY, NEVER REWRITE**: The command's **only** write is appending a new +`## Phase N: Convergence` section to `tasks.md`. It MUST NOT: + +- modify `spec.md` or `plan.md` in any way; +- rewrite, renumber, reorder, or delete any existing task (including tasks from a prior + Convergence phase); +- modify, create, or delete any application code — completing the appended tasks is the + job of `__SPECKIT_COMMAND_IMPLEMENT__`. + +When the codebase already satisfies everything, the command MUST leave `tasks.md` +**byte-for-byte unchanged** (no empty Convergence header) and report a clean result. + +**Constitution Authority**: The project constitution (`/memory/constitution.md`) is +**non-negotiable**. Code that violates a MUST principle is the highest-severity finding and +produces a corresponding remediation task. If the constitution is an unfilled template, +skip constitution checks gracefully rather than failing. + +## Execution Steps + +### 1. Initialize Convergence Context + +Run `{SCRIPT}` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths: + +- SPEC = FEATURE_DIR/spec.md +- PLAN = FEATURE_DIR/plan.md +- TASKS = FEATURE_DIR/tasks.md +- CONSTITUTION = `/memory/constitution.md` (if present) +If `spec.md`, `plan.md`, or `tasks.md` is missing, STOP with a clear, actionable message naming the +prerequisite command to run (`__SPECKIT_COMMAND_SPECIFY__` for a missing spec, `__SPECKIT_COMMAND_PLAN__` for a missing plan, +`__SPECKIT_COMMAND_TASKS__` for missing tasks). Do not produce partial output. +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 Artifacts (Progressive Disclosure) + +Load only the minimal necessary context from each artifact: + +**From spec.md:** + +- Functional Requirements (FR-###) +- Success Criteria (SC-###) — include only items requiring buildable work; exclude + post-launch outcome metrics and business KPIs +- User Stories and their Acceptance Scenarios +- Edge Cases (if present) + +**From plan.md:** + +- Architecture/stack choices and technical decisions +- Data Model references +- Phases and named touch-points (files/components the plan says will be created or edited) +- Technical constraints + +**From tasks.md:** + +- Task IDs (to compute the next ID and next phase number) +- Descriptions, phase grouping, and referenced file paths + +**From constitution (if not an unfilled template):** + +- Principle names and MUST/SHOULD normative statements + +### 3. Build the Intent Inventory + +Create an internal model (do not echo raw artifacts): + +- **Requirements inventory**: one stable key per FR-### / SC-### / user-story acceptance + scenario (e.g. `US1/AC2`), plus the plan decisions and constitution principles that + impose buildable obligations. +- **Code-scope map**: from the file paths named in `plan.md` and `tasks.md`, plus a keyword + search for the concepts each requirement describes, derive the set of source files and + components in scope for assessment. Bound the assessment to these — do **not** infer + scope beyond what the artifacts define. + +### 4. Assess the Codebase and Classify Findings + +For each item in the intent inventory, inspect the current code in scope and produce a +`Finding` only where there is a gap. Classify every finding by **gap type**: + +- **`missing`**: the required work is absent from the code entirely. +- **`partial`**: the work exists but does not yet fully satisfy the requirement / + acceptance criterion / plan decision. +- **`contradicts`**: the code does something that conflicts with stated intent or a + constitution MUST principle. +- **`unrequested`**: the code contains work not called for by the spec, plan, or tasks + (surfaced for awareness — converge does **not** delete code, it only appends a task to + review/justify or remove it). + +Each `Finding` records: a stable id, the `source-ref` it traces to, the `gap-type`, a +severity, and a short human-readable description with the evidence (the file/area observed). + +**Edge cases:** + +- **Little or no code yet**: treat the entire specified scope as `missing` remaining work + rather than failing. +- **Nothing remains**: produce zero findings and follow the converged branch in Step 7. + +### 5. Assign Severity + +- **CRITICAL**: violates a constitution MUST principle, or a `missing`/`contradicts` gap + that blocks baseline functionality of a P1 user story. +- **HIGH**: a `missing` or `partial` gap on a core functional requirement or acceptance + criterion. +- **MEDIUM**: a `partial` gap on a secondary requirement, or an `unrequested` addition with + unclear justification. +- **LOW**: minor partial gaps, polish, or low-risk `unrequested` additions. + +### 6. Present the In-Session Findings Summary + +Before appending anything, output a compact, severity-graded summary (no file writes yet): + +## Convergence Findings + +| ID | Gap Type | Severity | Source | Evidence | Remaining Work | +|----|----------|----------|--------|----------|----------------| +| F1 | missing | HIGH | FR-008 | Example: no append-only guard detected in path/to/module.py when writing tasks.md | Add append-only enforcement | + +**Summary metrics:** + +- Requirements / acceptance criteria checked +- Plan decisions checked +- Constitution principles checked (or "skipped — template") +- Findings by gap type (missing / partial / contradicts / unrequested) +- Findings by severity + +### 7. Append Convergence Tasks (or report converged) + +**If there are one or more actionable findings** (`tasks_appended` outcome): + +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). +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}, …`: + + ```markdown + - [ ] T042 per () + ``` + + `` traces the task to its origin: e.g. `FR-003`, `SC-002`, + `US1/AC2`, `plan: storage decision`, `Constitution II`. + + `` is one of `missing`, `partial`, `contradicts`, `unrequested`. + + Constitution-violation tasks MUST be emitted first and described as + `CRITICAL`. +4. Never reuse or renumber existing IDs. If a prior Convergence phase exists, add a new, + separately-numbered one below it — do not touch the old one. + +**If there are no actionable findings** (`converged` outcome): + +- Do **not** modify `tasks.md` at all — no empty phase header. +- Report: **"✅ Converged — the implementation satisfies the spec, plan, and tasks."** +- Include the summary counts of what was checked. + +### 8. Provide Next Actions (Handoff) + +- On `tasks_appended`: state how many tasks were appended under which phase, and recommend + running `__SPECKIT_COMMAND_IMPLEMENT__` to complete them; note that a follow-up converge + run will find fewer or no remaining items. +- On `converged`: recommend proceeding to review / opening a PR. No further implement pass + is needed for this feature's specified scope. + +### 9. Check for extension hooks + +After producing the result, check if `.specify/extensions.yml` exists in the project root. + +- If it exists, read it and look for entries under the `hooks.after_converge` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Report the convergence outcome (`converged` or `tasks_appended`) in-session before listing + any hooks, so users can decide whether to run optional follow-up commands. +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + + ```text + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + + - **Mandatory hook** (`optional: false`): + + ```text + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 19b52167a9..b0b408a995 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -254,7 +254,7 @@ def test_init_options_includes_context_file(self, tmp_path): COMMAND_STEMS = [ "agent-context.update", - "analyze", "clarify", "constitution", "implement", + "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", ] diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 8a3f9d0f34..e903d918e2 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -100,7 +100,7 @@ def test_skill_directory_structure(self, tmp_path): skill_files = [f for f in created if "scripts" not in f.parts] expected_commands = { - "analyze", "clarify", "constitution", "implement", + "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", } @@ -393,7 +393,7 @@ def test_options_include_skills_flag(self): # -- Complete file inventory ------------------------------------------ _SKILL_COMMANDS = [ - "analyze", "clarify", "constitution", "implement", + "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", ] diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 37f6966e35..a9b933875a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -486,6 +486,7 @@ def test_init_options_includes_context_file(self, tmp_path): "analyze", "clarify", "constitution", + "converge", "implement", "plan", "checklist", diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 7814844c51..646e21607d 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -365,6 +365,7 @@ def test_init_options_includes_context_file(self, tmp_path): "analyze", "clarify", "constitution", + "converge", "implement", "plan", "checklist", diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index aafc7b11de..e8350114a7 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -341,18 +341,30 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): class TestClaudeArgumentHints: """Verify that argument-hint frontmatter is injected for Claude skills.""" + def test_converge_has_no_argument_hint(self): + """Converge should not advertise unsupported feature-name arguments.""" + assert "converge" not in ARGUMENT_HINTS + def test_all_skills_have_hints(self, tmp_path): - """Every generated SKILL.md must contain an argument-hint line.""" + """Every skill with a configured hint must contain an argument-hint line.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) created = i.setup(tmp_path, m, script_type="sh") skill_files = [f for f in created if f.name == "SKILL.md"] assert len(skill_files) > 0 for f in skill_files: + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] content = f.read_text(encoding="utf-8") - assert "argument-hint:" in content, ( - f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" - ) + if stem in ARGUMENT_HINTS: + assert "argument-hint:" in content, ( + f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" + ) + else: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) def test_hints_match_expected_values(self, tmp_path): """Each skill's argument-hint must match the expected text.""" @@ -366,13 +378,15 @@ def test_hints_match_expected_values(self, tmp_path): if stem.startswith("speckit-"): stem = stem[len("speckit-"):] expected_hint = ARGUMENT_HINTS.get(stem) - assert expected_hint is not None, ( - f"No expected hint defined for skill '{stem}'" - ) content = f.read_text(encoding="utf-8") - assert f'argument-hint: "{expected_hint}"' in content, ( - f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" - ) + if expected_hint is None: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + else: + assert f'argument-hint: "{expected_hint}"' in content, ( + f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" + ) def test_hint_is_inside_frontmatter(self, tmp_path): """argument-hint must appear between the --- delimiters, not in the body.""" @@ -386,12 +400,20 @@ def test_hint_is_inside_frontmatter(self, tmp_path): assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md" frontmatter = parts[1] body = parts[2] - assert "argument-hint:" in frontmatter, ( - f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" - ) - assert "argument-hint:" not in body, ( - f"{f.parent.name}/SKILL.md: argument-hint leaked into body" - ) + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem in ARGUMENT_HINTS: + assert "argument-hint:" in frontmatter, ( + f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" + ) + assert "argument-hint:" not in body, ( + f"{f.parent.name}/SKILL.md: argument-hint leaked into body" + ) + else: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) def test_hint_appears_after_description(self, tmp_path): """argument-hint must immediately follow the description line.""" @@ -402,6 +424,14 @@ def test_hint_appears_after_description(self, tmp_path): for f in skill_files: content = f.read_text(encoding="utf-8") lines = content.splitlines() + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem not in ARGUMENT_HINTS: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + continue found_description = False for idx, line in enumerate(lines): if line.startswith("description:"): diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index d5b3c1deeb..6b7cc7c13f 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -125,9 +125,9 @@ def test_directory_structure(self, tmp_path): agents_dir = tmp_path / ".github" / "agents" assert agents_dir.is_dir() agent_files = sorted(agents_dir.glob("speckit.*.agent.md")) - assert len(agent_files) == 9 + assert len(agent_files) == 10 expected_commands = { - "analyze", "clarify", "constitution", "implement", + "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", } actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files} @@ -198,6 +198,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", ".github/agents/speckit.constitution.agent.md", + ".github/agents/speckit.converge.agent.md", ".github/agents/speckit.implement.agent.md", ".github/agents/speckit.plan.agent.md", ".github/agents/speckit.specify.agent.md", @@ -208,6 +209,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", ".github/prompts/speckit.constitution.prompt.md", + ".github/prompts/speckit.converge.prompt.md", ".github/prompts/speckit.implement.prompt.md", ".github/prompts/speckit.plan.prompt.md", ".github/prompts/speckit.specify.prompt.md", @@ -268,6 +270,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", ".github/agents/speckit.constitution.agent.md", + ".github/agents/speckit.converge.agent.md", ".github/agents/speckit.implement.agent.md", ".github/agents/speckit.plan.agent.md", ".github/agents/speckit.specify.agent.md", @@ -278,6 +281,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", ".github/prompts/speckit.constitution.prompt.md", + ".github/prompts/speckit.converge.prompt.md", ".github/prompts/speckit.implement.prompt.md", ".github/prompts/speckit.plan.prompt.md", ".github/prompts/speckit.specify.prompt.md", @@ -321,7 +325,7 @@ class TestCopilotSkillsMode: """Tests for Copilot integration in --skills mode.""" _SKILL_COMMANDS = [ - "analyze", "clarify", "constitution", "implement", + "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", ] diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index b7c64cdf67..fe935cc98b 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -214,6 +214,7 @@ def test_implement_loads_constitution_context(self, tmp_path): [ "analyze", "clarify", + "converge", "implement", "plan", "checklist", @@ -306,6 +307,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", ".myagent/commands/speckit.constitution.md", + ".myagent/commands/speckit.converge.md", ".myagent/commands/speckit.implement.md", ".myagent/commands/speckit.plan.md", ".myagent/commands/speckit.specify.md", @@ -370,6 +372,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", ".myagent/commands/speckit.constitution.md", + ".myagent/commands/speckit.converge.md", ".myagent/commands/speckit.implement.md", ".myagent/commands/speckit.plan.md", ".myagent/commands/speckit.specify.md",